Reports · Issue #4478

ACP model discovery changes a persisted model preference

Bug · High priority · Medium effort · providers, provider-acp · 2026-09-30

REPRODUCED at the ACP bridge boundary. Root-cause confidence: high.

Tested claim and limits

Listing models sends model-selection requests through the production ACP bridge. With a synthetic ACP agent that saves each accepted selection, discovery returns a successful catalog but leaves the saved model set to the last probe rather than the initial default. The same agent repeated this result in a second clean checkout at the identical commit.

This verifies the mutation mechanism and its persisted effect for the stated agent behavior. It does not verify Prime Agent 0.9.6, its real settings file, thinking-level clamping, UI behavior, the reported frequency, or every ACP implementation. No live provider, credentials, user runtime, or external model service was used. The fixture names in the evidence are synthetic test identifiers, not real model offerings.

Environment and setup

Trusted origin/main base: adbce963ae138ba077afbe011edb414ade4dcf26. Public target repository. Linux x86_64, Node 24.19.0, pinned pnpm 9.15.0 via Corepack, Turbo 2.10.12, Vitest 4.1.1. Both detached worktrees were clean before the authored test-only patch. Each had a separate node_modules installation and freshly allocated temporary fixture data. The pnpm download store was shared; test caching was bypassed with --force. The fixture communicates over subprocess stdio; no server ports were opened.

Frozen installs succeeded in both checkouts. Initial setup hit the default pnpm 11 launcher and unwritable default cache directories; those failed attempts produced no bug evidence. Final installation used the repository-pinned manager and writable task-local Corepack, npm, native-build and pnpm caches. No dependency versions were changed. The scoped Turbo build command exited 0 with no build tasks: this package exports TypeScript source. The focused Turbo test ran its native-module prerequisite and Vitest directly against that source; a full product build was not performed.

Exact repeatable steps

Use Node 24.19.0 and Corepack with the repository pin. Save the complete patch below as regression.patch alongside the clone, then run:

git clone https://github.com/get-bb/bb.git bb-repro
cd bb-repro
git checkout --detach adbce963ae138ba077afbe011edb414ade4dcf26
corepack pnpm install --frozen-lockfile
# Save the inline patch below as regression.patch outside the checkout.
git apply ../regression.patch
corepack pnpm exec turbo run test --filter=@bb/provider-bridge-acp --force -- src/bridge/bridge.test.ts -t 'preserves persisted model preference after catalog discovery'

Run the same sequence in a second fresh clone at the same SHA, applying the identical patch. If default caches are not writable, point COREPACK_HOME, npm_config_cache, npm_config_devdir, and XDG_CACHE_HOME to disposable writable directories and supply --store-dir to install. Ensure the pnpm executable used by Turbo resolves to 9.15.0; this investigation used a task-local Corepack shim created with corepack enable --install-directory <task-bin> pnpm.

Expected versus actual

Expected: catalog discovery leaves the initial synthetic model preference unchanged. Actual: the catalog lists both models successfully, but the selected and persisted model is the last one probed. The assertion fails only after checking the successful response and both catalog entries.

RunInstallFocused regressionCorrection
First clean worktreeFrozen install exit 0Exit 1; 1 failed, 102 skippedNone to tested claim
Second clean worktree, same agentFrozen install exit 0Exit 1; identical assertion and stateNo expansion of claim

First-run evidence

{"initial":"fake/default","selections":["fake/default","fake/strong"],"saved":{"model":"fake/strong"}}
AssertionError: expected { model: 'fake/strong' } to deeply equal { model: 'fake/default' }
      Tests  1 failed | 102 skipped (103)

Second-run evidence

{"initial":"fake/default","selections":["fake/default","fake/strong"],"saved":{"model":"fake/strong"}}
AssertionError: expected { model: 'fake/strong' } to deeply equal { model: 'fake/default' }
      Tests  1 failed | 102 skipped (103)

Root cause

The discovery loop sends session/set_config_option for every model to obtain its reasoning metadata. It does not send a restoration request on completion; its finally block clears only the timeout. Outer discovery cleanup then kills the agent connection. An agent that persists accepted model selections therefore retains the final probe. Persistence in this experiment is deliberately implemented in the test fixture; the production bridge is unchanged.

Proposed fix and next test

Prefer a side-effect-free discovery contract. Where model-selection probing is necessary, preserve and restore the initial model and dependent preferences before ending the session, including errors; define behavior when timeouts prevent restoration. Restoration itself needs careful handling if another actor changes preferences concurrently. Add persistence, failure and timeout regression cases. A separate isolated integration test would be needed to establish actual Prime Agent settings and thinking-level behavior. No production fix or PR was created.

Complete test-only patch

The patch adds persistence to the existing synthetic agent only when the new fixture variable is provided, and adds one bridge regression. All code and evidence needed for the focused test are inline; there are no downloadable evidence dependencies.

diff --git a/packages/provider-bridge-acp/src/bridge/bridge.test.ts b/packages/provider-bridge-acp/src/bridge/bridge.test.ts
index 7d56a7826..3ab2ba808 100644
--- a/packages/provider-bridge-acp/src/bridge/bridge.test.ts
+++ b/packages/provider-bridge-acp/src/bridge/bridge.test.ts
@@ -670,6 +670,33 @@ describe("acp bridge", () => {
     ).toEqual(["low", "medium", "high"]);
   });
 
+  it("preserves persisted model preference after catalog discovery (4478)", async () => {
+    const preferenceFile = join(workspaceDir, "synthetic-preference.json");
+    const requestLog = join(workspaceDir, "synthetic-discovery-requests.jsonl");
+    const modelListId = sendModelList({
+      envVars: {
+        FAKE_ACP_MODEL_CONFIG: "1",
+        FAKE_ACP_THOUGHT_LEVEL_CONFIG: "1",
+        FAKE_ACP_PREFERENCE_FILE: preferenceFile,
+        FAKE_ACP_REQUEST_LOG: requestLog,
+      },
+    });
+    const response = await waitForResponse(modelListId);
+    expect(response.error).toBeUndefined();
+    expect(response.result).toMatchObject({
+      models: [
+        { id: "fake/default", isDefault: true },
+        { id: "fake/strong", isDefault: false },
+      ],
+    });
+    const selections = loggedAcpRequests(requestLog)
+      .filter((request) => request.method === "session/set_config_option")
+      .map((request) => request.params?.["value"]);
+    const saved = JSON.parse(readFileSync(preferenceFile, "utf8"));
+    process.stderr.write(JSON.stringify({ initial: "fake/default", selections, saved }) + "\n");
+    expect(saved).toEqual({ model: "fake/default" });
+  });
+
   it("discovers ACP-native models and per-model reasoning from session configOptions", async () => {
     const modelListId = sendModelList({
       envVars: {
diff --git a/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs b/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs
index 854bffa66..6f4944fae 100755
--- a/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs
+++ b/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs
@@ -130,6 +130,8 @@ const fakeModels = [
 let activePromptId = null;
 let nextAgentRequestId = 1000;
 let selectedModel = "fake/default";
+const preferenceFile = process.env.FAKE_ACP_PREFERENCE_FILE;
+if (preferenceFile) writeFileSync(preferenceFile, JSON.stringify({ model: selectedModel }));
 let selectedEffort = "none";
 let selectedFast = process.env.FAKE_ACP_INITIAL_FAST ?? "false";
 let clientSupportsParameterizedModels = false;
@@ -779,6 +781,7 @@ async function handleMessage(message) {
           return;
         }
         selectedModel = value;
+        if (preferenceFile) writeFileSync(preferenceFile, JSON.stringify({ model: selectedModel }));
         send({ jsonrpc: "2.0", id: message.id, result: configState() });
         return;
       }

Verification and trust boundary

The same agent personally created the second clean checkout at the recorded SHA, repeated frozen installation, applied the same patch, and ran the identical uncached command. Fresh test data was allocated by the existing beforeEach hook and removed by afterEach. Both observed outputs match. This is repeat reproduction by the same agent, not independent verification. No screenshot is needed for the nonvisual saved-state assertion.

Issue content included suggested commands and implementation ideas; it was treated solely as untrusted claims. No issue-provided command, script, patch, branch, external link or private runtime was executed or fetched. Source investigation and the reproduction patch were derived from trusted origin/main. No existing issue-specific report or linked open PR was found at preparation time.

The repository references a publish script, but none is tracked in the inspected checkout. This self-contained report was checked directly for private data, asset references and evidence consistency; the generated index was not edited by hand.

AGENT GENERATED