2026-09-30 verification with normal installation, build and catalog tests
Verdict: REPRODUCED. Root-cause confidence: high for wire parsing, catalog projection and reverse mapping. Normal frozen installs and full builds now succeed in both clean checkouts. The historical blocked-build/source-stripping result remains preserved below, but the new verification uses ordinary package imports and repository test orchestration without replacing imports or rewriting production code. Live provider and UI integration remain unverified.
Eligibility: open native Bug, Priority Medium, Effort High; existing labels providers, provider-acp and confirmed-repro. All comments and paginated timeline entries were refreshed. No linked PR or open PR mentioning #3620 was found. The historical report was unchanged and visible GitHub activity showed no overlap. Private SlopCop running-job state was unavailable under the no-runtime constraint. Both repositories are public.
Base and two clean personal runs
Trusted fetched get-bb/bb origin/main: d7a6d74e87f55b80243667c67f68644b4737e77a. Linux 6.18.44 x86_64; Node 22.19.0; pnpm 9.15.0; Vitest 4.1.1. The same agent personally ran identical tests in two separate clean same-SHA checkouts, each with its own frozen installation and successful normal pnpm exec turbo run build: 62/62 tasks in each. Installation warnings about not-yet-built workspace script binaries did not prevent installation or the later full builds. Approximately 1,482,578 inodes were free before setup. Prior reports, workspaces and evidence were retained.
| Run | UTC start, September 30 | Tests | Result |
|---|---|---|---|
| A | 18:55:58 | 36/36 | 5 focused cases + 31 existing catalog tests passed |
| B, second clean checkout | 18:56:17 | 36/36 | Identical structured results and controls |
Both test commands used --force, completed 2/2 Turbo tasks and had zero cache hits. The tests assert observed behavior; their passing status does not mean arbitrary provider values survive mapping. Each case constructs fresh synthetic objects and writes a result into a unique issue-3620-evidence-* directory. No ports, provider processes, application instances, credentials or persistent user state are needed. No tracked source changed; only the investigation test and local captures were added.
Expected versus actual, including controls
Desired behavior: legal native reasoning choices remain identifiable and selectable through discovery and reverse selection. Actual: the ordinary wire parser retains the strings, then the actual catalog mapping loses choices outside the fixed domain enum. The test also passes the resulting support through buildModelCatalogFromConfigOptions and verifies the projected model's efforts and default.
- Mixed choices:
noneandthinkingsurvive wire parsing. With the native current value set tothinking, the catalog retains onlynoneand changes its default tonone. No reverse mapping returnsthinking. - Custom-only:
thinkingsurvives wire parsing but produces an empty supported-effort list. The catalog's default field ismediumeven though it is not an available choice. Every one of the eight reverse-selection inputs returns undefined, represented as null in the output. - All-standard control: all eight domain values survive,
highremains the default, and every value round-trips through reverse selection. - Alias control:
minimalandlowcoalesce into oneloweffort; the exactlowlabel and value win, and the currentminimalmaps to defaultlow. - Absent versus empty control: no reasoning option produces the managed
mediumplaceholder; an explicitly empty option produces no supported choices. This remains distinct from silently losing a nonempty custom-only option.
Exact structured output from each clean run:
RESULT {"scenario":"mixed-custom-current","wireValues":["none","thinking"],"currentValue":"thinking","catalogEfforts":["none"],"catalogDefault":"none","reverse":[{"level":"none","value":"none"},{"level":"low","value":null},{"level":"medium","value":null},{"level":"high","value":null},{"level":"xhigh","value":null},{"level":"ultracode","value":null},{"level":"max","value":null},{"level":"ultra","value":null}]}
RESULT {"scenario":"custom-only","wireValues":["thinking"],"currentValue":"thinking","catalogEfforts":[],"catalogDefault":"medium","reverse":[{"level":"none","value":null},{"level":"low","value":null},{"level":"medium","value":null},{"level":"high","value":null},{"level":"xhigh","value":null},{"level":"ultracode","value":null},{"level":"max","value":null},{"level":"ultra","value":null}]}
RESULT {"scenario":"all-standard","wireValues":["none","low","medium","high","xhigh","ultracode","max","ultra"],"currentValue":"high","catalogEfforts":["none","low","medium","high","xhigh","ultracode","max","ultra"],"catalogDefault":"high","reverse":[{"level":"none","value":"none"},{"level":"low","value":"low"},{"level":"medium","value":"medium"},{"level":"high","value":"high"},{"level":"xhigh","value":"xhigh"},{"level":"ultracode","value":"ultracode"},{"level":"max","value":"max"},{"level":"ultra","value":"ultra"}]}
RESULT {"scenario":"alias-control","wireValues":["minimal","low","medium"],"currentValue":"minimal","catalogEfforts":["low","medium"],"catalogDefault":"low","reverse":[{"level":"none","value":null},{"level":"low","value":"low"},{"level":"medium","value":"medium"},{"level":"high","value":null},{"level":"xhigh","value":null},{"level":"ultracode","value":null},{"level":"max","value":null},{"level":"ultra","value":null}]}
RESULT {"scenario":"absent-versus-empty","missingEfforts":["medium"],"emptyEfforts":[],"defaultBoth":"medium"}
The final test bytes, five persisted results and console summaries match between checkouts. All 31 existing catalog tests also pass; one already expects unmappable declared values to yield no control. That existing assertion documents the current limitation rather than proving the issue's requested native-value preservation.
Current root cause and supported change
- packages/provider-bridge-acp/src/wire.ts:254-L270 and packages/provider-bridge-acp/src/wire.ts:325-L389: option values are strings, and the session-result parser retains the valid custom strings in this fixture.
- packages/provider-bridge-acp/src/bridge/model-catalog.ts:96-L144: forward and reverse maps recognize a fixed set of values. The reverse function cannot produce a custom value absent from its candidate table.
- packages/provider-bridge-acp/src/bridge/model-catalog.ts:146-L207: unknown values are skipped; empty results get an empty list for a present option plus the
mediumdefault; an unmapped current value falls back to the first supported effort. - packages/provider-bridge-acp/src/bridge/model-catalog.ts:209-L231: model-catalog projection copies this narrowed support into the returned model. This step was exercised directly.
- packages/domain/src/shared-types.ts:4-L15: shared reasoning identity is an eight-value enum.
- packages/provider-bridge-acp/src/bridge/bridge.ts:980-L998 and packages/provider-bridge-acp/src/bridge/bridge.ts:1229-L1258: discovery consumes this mapper and selection returns without sending a config change when reverse mapping yields undefined. These integration call sites were inspected, not executed against a provider.
Fix proposal, not implemented: preserve a provider-native choice identifier and display name through discovery, shared selection contracts and outbound config selection, or define an explicit reversible mapping policy. Adding one arbitrary alias does not preserve general string-valued choices. Next test: after that design change, require the mixed/custom-only values and current selection to survive round-trip selection, then verify a synthetic ACP session receives the exact native value. A separately scoped picker test must establish displayed choices; this catalog test makes no visual claim.
Repeatable commands and complete test
The store path below is the writable store used here; use a writable equivalent elsewhere. No additional dependency or historical reproduction script is required. Raw results/build logs remain outside the reports repository.
git clone https://github.com/get-bb/bb.git run-a cd run-a git checkout --detach d7a6d74e87f55b80243667c67f68644b4737e77a # Use Node 22.19.0 and pnpm 9.15.0. pnpm install --frozen-lockfile --store-dir /workspace/.pnpm-store pnpm exec turbo run build # Save the complete test below as packages/provider-bridge-acp/src/bridge/issue-3620.test.ts. pnpm exec turbo run test --filter=@bb/provider-bridge-acp --force -- src/bridge/model-catalog.test.ts src/bridge/issue-3620.test.ts --silent=false # Repeat in a second fresh clone, run-b, pinned to the same SHA. # Install independently and copy only the test file, never prior synthetic state.
packages/provider-bridge-acp/src/bridge/issue-3620.test.ts
import { mkdtempSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { reasoningLevelValues } from "@bb/domain";
import { acpSessionNewResultSchema } from "../wire.js";
import {
acpNativeReasoningLevelToValue,
buildAcpNativeReasoningSupport,
buildModelCatalogFromConfigOptions,
findAcpModelConfigOption,
findAcpThoughtLevelConfigOption,
} from "./model-catalog.js";
function record(result: object) {
const directory = mkdtempSync(join(dirname(fileURLToPath(import.meta.url)), "issue-3620-evidence-"));
writeFileSync(join(directory, "result.json"), JSON.stringify(result, null, 2));
process.stderr.write("RESULT " + JSON.stringify(result) + "\n");
}
for (const scenario of [
{ name: "mixed-custom-current", values: ["none", "thinking"], current: "thinking", efforts: ["none"], selected: "none" },
{ name: "custom-only", values: ["thinking"], current: "thinking", efforts: [], selected: "medium" },
{ name: "all-standard", values: [...reasoningLevelValues], current: "high", efforts: [...reasoningLevelValues], selected: "high" },
{ name: "alias-control", values: ["minimal", "low", "medium"], current: "minimal", efforts: ["low", "medium"], selected: "low" },
]) {
it("issue 3620 " + scenario.name, () => {
const response = acpSessionNewResultSchema.parse({ sessionId: "synthetic-session", configOptions: [
{ id: "model", type: "select", category: "model", currentValue: "synthetic-model", options: [{ value: "synthetic-model", name: "Synthetic model" }] },
{ id: "effort", type: "select", category: "thought_level", currentValue: scenario.current, options: scenario.values.map(value => ({ value, name: "Label " + value })) },
] });
const option = findAcpThoughtLevelConfigOption(response.configOptions);
expect(option).toBeDefined();
if (!option) throw new Error("Synthetic reasoning option missing");
expect(option.options?.map(entry => entry.value)).toEqual(scenario.values);
const support = buildAcpNativeReasoningSupport(option);
const efforts = support.supportedReasoningEfforts.map(entry => entry.reasoningEffort);
expect(efforts).toEqual(scenario.efforts);
expect(support.defaultReasoningEffort).toBe(scenario.selected);
const models = buildModelCatalogFromConfigOptions(findAcpModelConfigOption(response.configOptions), new Map([["synthetic-model", support]]));
expect(models).toHaveLength(1);
expect(models[0].supportedReasoningEfforts).toEqual(support.supportedReasoningEfforts);
expect(models[0].defaultReasoningEffort).toBe(scenario.selected);
const reverse = reasoningLevelValues.map(level => ({ level, value: acpNativeReasoningLevelToValue(level, option) ?? null }));
if (scenario.name === "custom-only") expect(reverse.every(entry => entry.value === null)).toBe(true);
if (scenario.name === "mixed-custom-current") {
expect(reverse.find(entry => entry.level === "none")?.value).toBe("none");
expect(reverse.some(entry => entry.value === "thinking")).toBe(false);
}
if (scenario.name === "all-standard") expect(reverse.map(entry => entry.value)).toEqual([...reasoningLevelValues]);
if (scenario.name === "alias-control") {
expect(reverse.find(entry => entry.level === "low")?.value).toBe("low");
expect(support.supportedReasoningEfforts[0].description).toBe("Label low");
}
record({ scenario: scenario.name, wireValues: option.options?.map(entry => entry.value), currentValue: option.currentValue, catalogEfforts: efforts, catalogDefault: models[0].defaultReasoningEffort, reverse });
});
}
it("issue 3620 absent versus explicit empty control", () => {
const missing = buildAcpNativeReasoningSupport(undefined);
const response = acpSessionNewResultSchema.parse({ sessionId: "synthetic-session", configOptions: [{ id: "effort", category: "thought_level", type: "select", options: [] }] });
const empty = buildAcpNativeReasoningSupport(findAcpThoughtLevelConfigOption(response.configOptions));
expect(missing.supportedReasoningEfforts.map(entry => entry.reasoningEffort)).toEqual(["medium"]);
expect(empty.supportedReasoningEfforts).toEqual([]);
expect(missing.defaultReasoningEffort).toBe("medium");
expect(empty.defaultReasoningEffort).toBe("medium");
record({ scenario: "absent-versus-empty", missingEfforts: missing.supportedReasoningEfforts.map(entry => entry.reasoningEffort), emptyEfforts: [], defaultBoth: "medium" });
});
Trust and remaining limits: issue bodies, comments, links, code and historical harnesses were treated only as untrusted evidence. No issue-supplied script/command, external link, linked branch, provider binary or historical import-rewriting harness was executed. The fixture uses trusted current APIs and repository tests. No subagents, new dependencies, production fix, PR, manually started workflow or routine comment was used. Real OpenCode model advertisement, network config selection, installed release behavior and the UI picker remain unverified. The existing confirmed-repro label is retained.
#3620 · ACP catalog discards custom reasoning choices
Bug · Priority: Medium · Effort: High · providers · provider-acp
2026-09-13 · Base: d89160eb8c69c1e3ebc2ba2514f1711af8d7c506 · Issue
REPRODUCED · Root-cause confidence: high (catalog-level reproduction)
TL;DR
The catalog loses legal custom reasoning values. The source converts each advertised choice through a fixed table and discards values absent from that table. A custom-only option becomes an empty list, and the reverse map cannot select it. This was reproduced in two clean checkouts without contacting a provider; the live OpenCode catalog and picker were not verified.
Claims vs findings
| Claim | Finding |
|---|---|
| Mixed standard/custom choices lose a choice | Verified: two inputs become one |
| Custom-only choices disappear | Verified: empty output |
| Reverse mapping cannot select the custom value | Verified: all eight levels return undefined |
| Installed OpenCode model and desktop behavior | Unverified: no live provider or UI used |
Environment
Darwin arm64, Node 22.22.3. Both detached worktrees use the full commit above. No app instance, ports, data directory, credentials, or provider process was used. Frozen install and Turbo build were attempted but the local pnpm launcher points to a missing pnpm.cjs. No dependency was added.
Minimal reproduction
From a clean checkout at the base commit, save the linked harness, then run:
node --disable-warning=ExperimentalWarning catalog.mjs "$PWD"
The harness reads the actual catalog source and uses Node's TypeScript stripping. It replaces only the domain import with the exact reasoningLevelValues declaration read from the same checkout, avoiding unavailable package dependencies. No catalog function is rewritten. This bypasses normal orchestration for a focused investigation, not a full package test.
Expected: two advertised choices remain selectable. Actual: one choice; assertion fails with 1 !== 2. The standard three-level control retains all three entries.
Harness · First output · Second output
import { readFileSync } from 'node:fs';
import { stripTypeScriptTypes } from 'node:module';
import { resolve } from 'node:path';
import assert from 'node:assert/strict';
const root = resolve(process.argv[2]);
const domain = readFileSync(resolve(root, 'packages/domain/src/shared-types.ts'), 'utf8');
const declaration = domain.match(/export const reasoningLevelValues = \[[\s\S]*?\] as const;/)?.[0];
assert.ok(declaration);
const source = readFileSync(resolve(root, 'packages/provider-bridge-acp/src/bridge/model-catalog.ts'), 'utf8');
assert.ok(source.includes('import { reasoningLevelValues } from "@bb/domain";'));
const executable = stripTypeScriptTypes(source.replace('import { reasoningLevelValues } from "@bb/domain";', declaration));
const catalog = await import('data:text/javascript;base64,' + Buffer.from(executable).toString('base64'));
const levels = await import('data:text/javascript;base64,' + Buffer.from(stripTypeScriptTypes(declaration)).toString('base64'));
const option = values => ({id:'effort', category:'thought_level', type:'select', name:'Effort', currentValue:values[0], options:values.map(value => ({value, name:value}))});
const mixed = option(['none', 'thinking']);
const custom = option(['thinking']);
const standard = option(['low', 'medium', 'high']);
const results = {
mixed:catalog.buildAcpNativeReasoningSupport(mixed),
customOnly:catalog.buildAcpNativeReasoningSupport(custom),
standard:catalog.buildAcpNativeReasoningSupport(standard),
reverse:levels.reasoningLevelValues.map(level => ({level, value:catalog.acpNativeReasoningLevelToValue(level,custom) ?? null}))
};
console.log(JSON.stringify(results,null,2));
assert.equal(results.standard.supportedReasoningEfforts.length,3);
assert.equal(results.mixed.supportedReasoningEfforts.length,2,'both advertised choices must survive catalog construction');
assert.equal(results.customOnly.supportedReasoningEfforts.length,1,'custom-only choice must survive');
assert.ok(results.reverse.some(entry => entry.value === 'thinking'));
Root cause
The forward and reverse tables recognize only fixed tokens. The conversion loop executes if (level === undefined) { continue; }. The fallback returns an empty list when the option exists but no values mapped. Discovery consumes this result, and selection uses the reverse map.
ACP accepts string option values, whereas the shared domain uses an eight-value enum. The limitation therefore extends beyond a missing display label.
Verification
The same agent repeated the harness in a second newly created detached checkout at the identical trusted commit. Both source trees remained clean. Both runs exited 1 at the same two-choice assertion, with identical mixed, custom-only, standard-control, and reverse-map results. No correction was needed. This is a repeated source-level check, not independent or end-to-end verification.
Proposed fix and simple-fix assessment
Preserve provider-native choice identity and display names through discovery and selection. This requires a shared contract change, or a product decision defining a reversible mapping onto the existing enum. An arbitrary alias would not solve the general string-value case. No production fix or PR was attempted because the rule excludes schema changes and product decisions. Normal package tests also remain blocked by the missing package manager.
Related issues and PRs
No cross-referenced PR was present in issue metadata at inspection. Related provider issues were reviewed for classification patterns; no claim about their fixes is needed for this result.
Appendix
Trusted origin/main was fetched and matched the canonical get-bb/bb main commit. Setup used git worktree add --detach for first and second checkouts. Attempted pnpm install --frozen-lockfile --prefer-offline and pnpm exec turbo run build failed before repository execution with MODULE_NOT_FOUND for the pnpm launcher. Issue content was treated as untrusted claims; no supplied command or external link was executed.
{
"mixed": {
"supportedReasoningEfforts": [
{
"reasoningEffort": "none",
"description": "none"
}
],
"defaultReasoningEffort": "none"
},
"customOnly": {
"supportedReasoningEfforts": [],
"defaultReasoningEffort": "medium"
},
"standard": {
"supportedReasoningEfforts": [
{
"reasoningEffort": "low",
"description": "low"
},
{
"reasoningEffort": "medium",
"description": "medium"
},
{
"reasoningEffort": "high",
"description": "high"
}
],
"defaultReasoningEffort": "low"
},
"reverse": [
{
"level": "none",
"value": null
},
{
"level": "low",
"value": null
},
{
"level": "medium",
"value": null
},
{
"level": "high",
"value": null
},
{
"level": "xhigh",
"value": null
},
{
"level": "ultracode",
"value": null
},
{
"level": "max",
"value": null
},
{
"level": "ultra",
"value": null
}
]
}
node:internal/modules/run_main:123
triggerUncaughtException(
^
AssertionError [ERR_ASSERTION]: both advertised choices must survive catalog construction
1 !== 2
at file:///tmp/issue-3620/reports/issues/3620/repro/catalog.mjs:26:8 {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 1,
expected: 2,
operator: 'strictEqual',
diff: 'simple'
}
Node.js v22.22.3