← reports

#2160 · Pi keeps using previous model after model picker change until /compact

Bug Priority: High Effort: unset providers provider-pi open on GitHub 2026-08-21 · base fcada5a3b

Verdict: REPRODUCED (live against the real Pi SDK, twice, plus a unit test that fails on base; independently verified) · Root-cause confidence: high

One sub-claim is refuted: /compact is not what resynchronises the model. Only a provider-session rebuild (thread/resume: after bb thread stop, a daemon restart, bridge recovery, or the idle-session reaper) does.

1. TL;DR

In a Pi thread, picking a different model in bb's composer changes what bb records (every client/turn/requested event carries the new model) but not what Pi uses: every later turn, and any manual compaction, is still sent to the model the Pi session was constructed with. The user sees "Grok 4.6" in the picker while Pi keeps billing gpt-5-mini (or, in the reporter's case, keeps billing Grok after they picked GPT).

The cause is a missing piece in the Pi provider bridge. Since the provider-bridge-protocol migration (c5b53caab, #1640, 2026-08-17) the runtime no longer diffs execution options: it sends them on every turn/start and expects each bridge to reconcile them itself (the Codex and Claude bridges do). The Pi bridge reads options.model and options.reasoningLevel only when it constructs a session (thread/start/resume/fork); its handleTurnStart ignores params.options entirely, so AgentSession.setModel is never called and the session is never rebuilt. Before #1640 the legacy Pi adapter classified a model change as a "session" change, which made the runtime send thread/resume and rebuild the Pi session with the new model; that path was deleted and nothing replaced it.

The reporter's observation that /compact fixed it is a coincidence: compaction is just another turn/start through the same handler, and I reproduced a real compaction followed by another stale-model turn. What does fix it is anything that rebuilds the Pi session; I demonstrated this with bb thread stop followed by a new turn.

2. Claims vs findings

Claim from the issueStatusEvidence
Changing the model in bb's picker does not change the model Pi uses for new turns.VerifiedLive, three independent runs (author x2, verifier x1): thread started on github-copilot/gpt-5-mini, every later turn requested with github-copilot/grok-4.6 (five in the revised run); Pi's session file records github-copilot/gpt-5-mini on every assistant message until the session is rebuilt (section 4a). Unit test fails on base (section 4b).
bb's client/turn/requested events record the newly selected model.Verifiedbb-turn-models.txt (revised run, thr_ahgjgq8fc9): seq 16, 25, 34, 41, 50 all carry model=github-copilot/grok-4.6; only seq 1 (the spawn) carries gpt-5-mini. Original run (thread-events-final.json): seq 12, 21, 28, 37, 44.
The Pi session has only the initial model_change entry.VerifiedSession file has exactly one model_change (the construction one). Note: even a successful resume with a new model does not append a model_change (the SDK appends it only for brand-new sessions), so the per-message provider/model fields are the reliable evidence, not model_change entries.
Manual compaction is the synchronisation boundary; after /compact Pi used the selected model.RefutedPerformed a real compaction (tokensBefore=30011 in the original run, 47429 in the revised run, summary written both times) and sent another turn with grok-4.6 selected: Pi still answered with gpt-5-mini both times (and a third time for the independent verifier, tokensBefore=33854). No code path rebuilds or re-models the session on compaction (section 5). What resynchronised the reporter's thread was almost certainly a session rebuild (thread/resume) that happened around the same time: daemon restart (they run nightlies with auto-update), bridge recovery, or the idle reaper. I demonstrated that bb thread stop + a new turn does switch the model (revised run: seq 51 thread/identity, then a grok-4.6 assistant message; original run: seq 60).
The compaction itself used the stale model and may consume unintended quota.Verified (code + pricing)AgentSession.compact() summarises with this.model (the stale one). The compaction entry records no model, but its usage cost (305 input tokens at $7.6e-05, i.e. $0.25/M) matches gpt-5-mini pricing, not grok-4.6.
This was real inference, not usage-dashboard polling.VerifiedPi's assistant messages carry usage.input of 106 to 47583 tokens (84 to 29958 in the original run) with the stale model name; those are real requests.
Starting a new thread is the safest workaround.VerifiedA fresh thread constructs a new session with the picked model (thr_chmuwwhpyh on grok-4.6 answered as grok-4.6). A cheaper workaround that keeps history: bb thread stop <id> (release) and then send the next message; the resume rebuilds the session with the current model.

3. Environment

4. Minimal reproduction

4a. Live (real Pi SDK, bb CLI)

  1. From your bb worktree, start an isolated dev instance and load its env. Every helper script below reads BB_SERVER_URL / BB_HOST_DAEMON_PORT from this env, so run all steps from the same shell (or re-run the eval in each new one):
    scripts/bb-dev-app current            # prints App / Server / Host daemon URLs and the data dir
    eval "$(scripts/bb-dev-app env)"
    unset BB_THREAD_ID BB_ENVIRONMENT_ID BB_THREAD_STORAGE   # only needed if you run this from inside a bb thread
  2. Create a scratch repo and project:
    mkdir -p /tmp/bb-2160-qa && git -C /tmp/bb-2160-qa init -q && echo "# qa" > /tmp/bb-2160-qa/README.md \
      && git -C /tmp/bb-2160-qa add -A && git -C /tmp/bb-2160-qa -c user.email=qa@example.com -c user.name=qa commit -qm init
    curl -s -X POST $BB_SERVER_URL/api/v1/projects -H 'content-type: application/json' \
      -d '{"name":"qa","source":{"type":"local_path","path":"/tmp/bb-2160-qa","hostId":"<host id from: pnpm bb:dev machine list>"}}'
    # -> {"id":"proj_6uckkwts97", ...}      (your id will differ; use it in the next step)
    export BB_PROJECT_ID=proj_6uckkwts97
  3. Spawn a Pi thread on model A and wait for it to go idle (the paths below assume the report's 2160/repro/ directory; any two models you are authenticated for in Pi will do):
    pnpm bb:dev thread spawn --project $BB_PROJECT_ID --provider pi --model github-copilot/gpt-5-mini \
      --reasoning-level low --title "2160 repro" --prompt "Reply only with ok." --json
    # -> "id": "thr_ahgjgq8fc9"
    2160/repro/wait-idle.sh thr_ahgjgq8fc9            # polls $BB_SERVER_URL until idle
    08:48:39 poll 2 status=starting
    08:48:43 poll 4 status=active
    08:48:46 poll 5 status=idle
  4. Send a new turn with model B selected (this is exactly what the composer's picker does):
    pnpm bb:dev thread tell thr_ahgjgq8fc9 --model github-copilot/grok-4.6 --mode auto "Reply only with ok."
    2160/repro/wait-idle.sh thr_ahgjgq8fc9
  5. Compare what bb recorded with what Pi used (the Pi bridge always writes its session files under ~/.bb/pi-bridge-sessions/, even for a dev instance; see section 3):
    python3 2160/repro/bb-turn-models.py thr_ahgjgq8fc9                                # bb's event log, via $BB_SERVER_URL
    2160/repro/pi-assistant-models.sh ~/.bb/pi-bridge-sessions/thr_ahgjgq8fc9.jsonl   # Pi's session file
    expected (Pi session file): second assistant message is github-copilot/grok-4.6
    actual:
    2026-08-21T15:48:42.946Z	model_change	github-copilot/gpt-5-mini
    2026-08-21T15:48:42.961Z	user	'Reply only with ok.'
    2026-08-21T15:48:45.403Z	assistant	github-copilot/gpt-5-mini	in=5338 out=28
    2026-08-21T15:48:52.370Z	user	'Reply only with ok.'
    2026-08-21T15:48:55.173Z	assistant	github-copilot/gpt-5-mini	in=106 out=25   <-- requested as grok-4.6, answered by gpt-5-mini
    
    bb event log for the same turns:
    1 client/turn/requested model=github-copilot/gpt-5-mini source=spawn
    2 client/thread/start
    7 thread/identity
    8 turn/started
    13 agentMessage "ok"
    15 turn/completed completed
    16 client/turn/requested model=github-copilot/grok-4.6 source=tell
    17 turn/started
    22 agentMessage "ok"
    24 turn/completed completed
  6. Test the "compaction syncs it" claim. A real compaction needs more than 20k tokens of history (Pi's default keepRecentTokens), so generate ~160 KB of filler, send it as one turn, then compact, then send another turn:
    2160/repro/make-filler.sh                                   # writes /tmp/bb-2160-qa/filler.txt (2000 lines, 164088 bytes)
    2160/repro/tell-file.sh thr_ahgjgq8fc9 github-copilot/grok-4.6 /tmp/bb-2160-qa/filler.txt
    2160/repro/wait-idle.sh thr_ahgjgq8fc9
    pnpm bb:dev thread compact thr_ahgjgq8fc9
    2160/repro/wait-idle.sh thr_ahgjgq8fc9
    pnpm bb:dev thread tell thr_ahgjgq8fc9 --model github-copilot/grok-4.6 --mode auto "Reply only with ok."
    2160/repro/wait-idle.sh thr_ahgjgq8fc9
    2160/repro/pi-assistant-models.sh ~/.bb/pi-bridge-sessions/thr_ahgjgq8fc9.jsonl
    expected: the turn after the compaction is github-copilot/grok-4.6
    actual:
    2026-08-21T15:49:07.158Z	user	'Do not reply to the text below, it is filler for a compactio'
    2026-08-21T15:49:11.889Z	assistant	github-copilot/gpt-5-mini	in=42138 out=43
    2026-08-21T15:49:23.740Z	compaction	tokensBefore=47429
    2026-08-21T15:49:26.610Z	user	'Reply only with ok.'
    2026-08-21T15:49:30.205Z	assistant	github-copilot/gpt-5-mini	in=47583 out=28   <-- still gpt-5-mini after a real compaction (tokensBefore=47429, summary written)
    (The filler is one ~42k-token message, larger than keepRecentTokens, so Pi keeps it whole and the post-compaction in= does not drop. That is a property of Pi's compaction cut-point, not of this bug; the compaction itself is real: a compaction entry with a summary was appended to the session file, and bb emitted thread/compacted at seq 38.)
  7. Show what does switch the model: release the runtime so the next turn resumes (rebuilds) the Pi session:
    pnpm bb:dev thread stop thr_ahgjgq8fc9
    pnpm bb:dev thread tell thr_ahgjgq8fc9 --model github-copilot/grok-4.6 --mode auto "Reply only with ok."
    2160/repro/wait-idle.sh thr_ahgjgq8fc9
    2160/repro/pi-assistant-models.sh ~/.bb/pi-bridge-sessions/thr_ahgjgq8fc9.jsonl | tail -2
    python3 2160/repro/bb-turn-models.py thr_ahgjgq8fc9 | tail -5
    2026-08-21T15:49:45.088Z	user	'Reply only with ok.'
    2026-08-21T15:49:49.444Z	assistant	github-copilot/grok-4.6	in=47794 out=12   <-- first turn after the rebuild: grok-4.6
    
    bb event log: seq 51 thread/identity (= thread/resume, new Pi session) precedes the grok-4.6 turn
    50 client/turn/requested model=github-copilot/grok-4.6 source=tell
    51 thread/identity
    52 turn/started
    61 agentMessage "ok"
    63 turn/completed completed
    The stop/resume cycle took about 6 s here and about 9 s for the independent verifier. (In the original run the first resumed turn hung once for ~4 minutes and had to be interrupted; the retry worked. That one-off is not part of this bug and not reproducible; see the caveats in section 9.)
bb thread view showing Grok 4.6 selected in the composer while the turns above were answered by gpt-5-mini
The bb thread after step 7 (original run, thr_iyyz7w3cxf). The composer shows Grok 4.6 · Low as the thread's model, and every "ok" above it was requested with that model; Pi's session file shows all but the last one were answered by gpt-5-mini.
bb model picker open on the Pi thread
The model picker (shift-cmd-M) on the same thread. Picking a model here only changes the options.model that rides the next turn/start; the Pi bridge never reads it.

4b. Unit-level repro (fails on base, no network)

File: 2160/repro/model-switch.repro.test.ts (lives at packages/agent-runtime/src/pi/bridge/__tests__/). It drives the real bridge (handleLine) through the canonical JSON-RPC harness with the Pi SDK's session constructor mocked by a stand-in that tracks its model the way AgentSession does (model getter, setModel, modelRuntime). Run with:

cd packages/agent-runtime && pnpm exec vitest run src/pi/bridge/__tests__/model-switch.repro.test.ts

On fcada5a3b both tests fail (full log). The first assertion shows the second prompt still going to xai/grok-4.6 with no setModel call and no rebuild; the second shows the compaction and both turns after a model change still on the construction model:

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯

 FAIL   @bb/agent-runtime:isolated  src/pi/bridge/__tests__/model-switch.repro.test.ts > pi bridge model switch (#2160) > applies a model that changed between turns before prompting pi
AssertionError: expected { setModelCalls: [], …(2) } to deeply equal { …(3) }

- Expected
+ Received

  {
    "promptModelsPerSession": [
      [
        "xai/grok-4.6",
-       "openai-codex/gpt-5.6-sol",
+       "xai/grok-4.6",
      ],
    ],
    "rebuilt": false,
-   "setModelCalls": [
-     [
-       {
-         "id": "gpt-5.6-sol",
-         "provider": "openai-codex",
-       },
-     ],
-   ],
+   "setModelCalls": [],
  }

 ❯ src/pi/bridge/__tests__/model-switch.repro.test.ts:253:10
    251|         rebuilt,
    252|         promptModelsPerSession: sessions.map((s) => s.promptModels),
    253|       }).toEqual({
       |          ^
    254|         setModelCalls: rebuilt ? [] : [[SOL]],
    255|         rebuilt,

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯

 FAIL   @bb/agent-runtime:isolated  src/pi/bridge/__tests__/model-switch.repro.test.ts > pi bridge model switch (#2160) > does not resynchronize the model on /compact either
AssertionError: expected { allPromptModels: [ …(2) ], …(1) } to deeply equal { allPromptModels: [ …(2) ], …(1) }

- Expected
+ Received

  {
    "allCompactModels": [
-     "openai-codex/gpt-5.6-sol",
+     "xai/grok-4.6",
    ],
    "allPromptModels": [
-     "openai-codex/gpt-5.6-sol",
-     "openai-codex/gpt-5.6-sol",
+     "xai/grok-4.6",
+     "xai/grok-4.6",
    ],
  }

 ❯ src/pi/bridge/__tests__/model-switch.repro.test.ts:308:53
    306|       // Expected: the compaction and every turn after the picker chan…
    307|       // on the selected model.
    308|       expect({ allPromptModels, allCompactModels }).toEqual({
       |                                                     ^
    309|         allPromptModels: ["openai-codex/gpt-5.6-sol", "openai-codex/gp…
    310|         allCompactModels: ["openai-codex/gpt-5.6-sol"],

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯


 Test Files  1 failed (1)
      Tests  2 failed (2)
   Start at  08:13:38
   Duration  1.28s (transform 247ms, setup 0ms, import 1.17s, tests 29ms, environment 0ms)
Test source
/**
 * Repro for get-bb/bb#2160: "Pi keeps using previous model after model
 * picker change until /compact".
 *
 * The canonical bridge protocol says execution options ride every command and
 * the bridge reconciles them ("apply live where it can, rebuild its provider
 * session where it must", `bridgeExecutionOptionsSchema` docs). The runtime's
 * generic adapter therefore classifies every option change as "live" and never
 * sends thread/resume for a model change.
 *
 * The pi bridge reads `options.model` only in `buildSessionOptions()` during
 * thread/start|resume|fork. `handleTurnStart` ignores `params.options`, so a
 * turn that arrives with a different model neither calls
 * `AgentSession.setModel` nor rebuilds the session. Pi keeps answering with
 * whatever model the session was constructed with.
 */
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";

const GROK = { provider: "xai", id: "grok-4.6" };
const SOL = { provider: "openai-codex", id: "gpt-5.6-sol" };

const {
  mockCreateAgentSession,
  mockCreateAgentSessionServices,
  mockModelRuntime,
} = vi.hoisted(() => {
  const models = [
    { provider: "xai", id: "grok-4.6" },
    { provider: "openai-codex", id: "gpt-5.6-sol" },
  ];
  const mockModelRuntime = {
    getAvailable: vi.fn(async () => models),
    getModel: vi.fn((provider: string, id: string) =>
      models.find((m) => m.provider === provider && m.id === id),
    ),
    getModels: vi.fn(() => models),
    hasConfiguredAuth: vi.fn(() => true),
    checkAuth: vi.fn(async () => true),
    refresh: vi.fn(async () => ({ aborted: false, errors: new Map() })),
  };
  const mockSettingsManager = {
    getShellCommandPrefix: vi.fn(() => undefined),
    getShellPath: vi.fn(() => undefined),
  };
  const mockCreateAgentSessionServices = vi.fn(
    async (options: { agentDir: string; cwd: string }) => ({
      agentDir: options.agentDir,
      cwd: options.cwd,
      diagnostics: [],
      modelRuntime: mockModelRuntime,
      resourceLoader: { options },
      settingsManager: mockSettingsManager,
    }),
  );
  return {
    mockCreateAgentSession: vi.fn(),
    mockCreateAgentSessionServices,
    mockModelRuntime,
  };
});

vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
  const actual =
    await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
  return {
    ...actual,
    createAgentSessionFromServices: mockCreateAgentSession,
    createAgentSessionServices: mockCreateAgentSessionServices,
    getAgentDir: vi.fn(() => "/tmp/pi-agent"),
    SessionManager: {
      forkFrom: actual.SessionManager.forkFrom.bind(actual.SessionManager),
      open: vi.fn((path: string, dir?: string, cwd?: string) =>
        actual.SessionManager.open(path, dir, cwd),
      ),
      inMemory: vi.fn((cwd?: string) => ({ kind: "in-memory", cwd })),
    },
  };
});

vi.mock("../configured-services.js", () => ({
  createConfiguredPiServices: mockCreateAgentSessionServices,
}));

vi.mock("../model-runtime.js", () => ({
  getPiModelRuntime: vi.fn(async () => mockModelRuntime),
}));

import { handleLine } from "../bridge.js";
import { PI_BRIDGE_SESSION_DIR_ENV } from "../session-paths.js";
import { createBridgeJsonRpcTestHarness } from "@bb/provider-bridge-protocol/testing";
import { createStandaloneBuiltinCompactCommandInput } from "@bb/domain";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const CANONICAL_OPTIONS = {
  approvalReviewer: null,
  permissionEscalation: null,
  permissionMode: "full",
  permissionScope: "full",
} as const;

/**
 * A controllable stand-in for pi's AgentSession that tracks the model the
 * way the real one does: `model` is what the session was constructed with and
 * `setModel` swaps it. `prompt` records the model that each run would use.
 */
function createModelTrackingPiSession(constructedModel: {
  provider: string;
  id: string;
}) {
  const listeners: Array<(event: AgentSessionEvent) => void> = [];
  const session = {
    model: constructedModel,
    modelRuntime: mockModelRuntime,
    promptModels: [] as string[],
    compactModels: [] as string[],
    setModel: vi.fn(async (model: { provider: string; id: string }) => {
      session.model = model;
    }),
    setThinkingLevel: vi.fn(),
    thinkingLevel: "medium",
    abort: vi.fn(async () => undefined),
    bindExtensions: vi.fn(async () => undefined),
    compact: vi.fn(async () => {
      session.compactModels.push(`${session.model.provider}/${session.model.id}`);
      emit({ type: "compaction_start", reason: "manual" });
      emit({
        type: "compaction_end",
        reason: "manual",
        result: undefined,
        aborted: false,
        willRetry: false,
      } as AgentSessionEvent);
    }),
    dispose: vi.fn(),
    extensionRunner: { emit: vi.fn(async () => undefined) },
    getActiveToolNames: vi.fn(() => []),
    getContextUsage: vi.fn(() => undefined),
    hasExtensionHandlers: vi.fn(() => false),
    isStreaming: false,
    prompt: vi.fn(
      async (
        _text: string,
        options?: { preflightResult?: (accepted: boolean) => void },
      ) => {
        options?.preflightResult?.(true);
        session.promptModels.push(
          `${session.model.provider}/${session.model.id}`,
        );
        emit({ type: "agent_start" } as AgentSessionEvent);
        emit({
          type: "agent_end",
          messages: [],
          willRetry: false,
        } as AgentSessionEvent);
      },
    ),
    sessionManager: { getLeafId: vi.fn(() => "pi-entry-checkpoint") },
    setActiveToolsByName: vi.fn(),
    subscribe: vi.fn((listener: (event: AgentSessionEvent) => void) => {
      listeners.push(listener);
      return () => {
        const index = listeners.indexOf(listener);
        if (index !== -1) listeners.splice(index, 1);
      };
    }),
  };
  function emit(event: AgentSessionEvent): void {
    for (const listener of [...listeners]) listener(event);
  }
  return session;
}

function turnStart(threadId: string, model: string, text: string) {
  return {
    clientRequestId: "creq_abcdefghjk",
    input: [{ type: "text", text }],
    options: { ...CANONICAL_OPTIONS, model },
    providerThreadId: threadId,
    threadId,
  };
}

describe("pi bridge model switch (#2160)", () => {
  const sessions: ReturnType<typeof createModelTrackingPiSession>[] = [];

  beforeEach(() => {
    vi.clearAllMocks();
    sessions.length = 0;
    process.env[PI_BRIDGE_SESSION_DIR_ENV] = mkdtempSync(
      join(tmpdir(), "bb-2160-pi-sessions-"),
    );
    // Construct a session with whatever model the bridge resolved, like the
    // real createAgentSessionFromServices does.
    mockCreateAgentSession.mockImplementation(
      async (options: { model?: { provider: string; id: string } }) => {
        const session = createModelTrackingPiSession(options.model ?? GROK);
        sessions.push(session);
        return { session };
      },
    );
  });

  it("applies a model that changed between turns before prompting pi", async () => {
    const bridge = createBridgeJsonRpcTestHarness(handleLine);
    const threadId = "thread-2160";
    try {
      // 1. Start the thread on Grok.
      bridge.sendRequest(1, "thread/start", {
        cwd: "/tmp/worktree",
        instructionMode: "append",
        options: { ...CANONICAL_OPTIONS, model: `${GROK.provider}/${GROK.id}` },
        threadId,
      });
      const started = await bridge.waitForResponse(1);
      expect(started.error).toBeUndefined();
      expect(mockCreateAgentSession).toHaveBeenCalledWith(
        expect.objectContaining({ model: GROK }),
      );

      // 2. First turn on Grok.
      bridge.sendRequest(
        2,
        "turn/start",
        turnStart(threadId, `${GROK.provider}/${GROK.id}`, "Reply only with ok."),
      );
      await bridge.waitForResponse(2);
      await bridge.flushWork();

      // 3. The user picks an OpenAI model in bb. The runtime classifies the
      //    change as "live" and sends it on the next turn/start.
      bridge.sendRequest(
        3,
        "turn/start",
        turnStart(threadId, `${SOL.provider}/${SOL.id}`, "Reply only with ok."),
      );
      const response = await bridge.waitForResponse(3);
      expect(response.error).toBeUndefined();
      await bridge.flushWork();

      // The bridge must have reconciled the model somehow: either by calling
      // setModel on the live session, or by rebuilding the session with the
      // new model (reported via session/replaced). On main it does neither.
      const live = sessions.at(-1);
      expect(live).toBeDefined();
      const setModelCalls = sessions.flatMap((s) => s.setModel.mock.calls);
      const rebuilt = mockCreateAgentSession.mock.calls.length > 1;
      expect({
        setModelCalls,
        rebuilt,
        promptModelsPerSession: sessions.map((s) => s.promptModels),
      }).toEqual({
        setModelCalls: rebuilt ? [] : [[SOL]],
        rebuilt,
        promptModelsPerSession: rebuilt
          ? [["xai/grok-4.6"], ["openai-codex/gpt-5.6-sol"]]
          : [["xai/grok-4.6", "openai-codex/gpt-5.6-sol"]],
      });
    } finally {
      bridge.restore();
    }
  });

  it("does not resynchronize the model on /compact either", async () => {
    const bridge = createBridgeJsonRpcTestHarness(handleLine);
    const threadId = "thread-2160-compact";
    try {
      bridge.sendRequest(1, "thread/start", {
        cwd: "/tmp/worktree",
        instructionMode: "append",
        options: { ...CANONICAL_OPTIONS, model: `${GROK.provider}/${GROK.id}` },
        threadId,
      });
      await bridge.waitForResponse(1);

      bridge.sendRequest(
        2,
        "turn/start",
        turnStart(threadId, `${SOL.provider}/${SOL.id}`, "Reply only with ok."),
      );
      await bridge.waitForResponse(2);
      await bridge.flushWork();

      // bb's manual compaction is a turn/start carrying the builtin /compact
      // mention, and it carries the selected model like every turn.
      bridge.sendRequest(3, "turn/start", {
        ...turnStart(threadId, `${SOL.provider}/${SOL.id}`, ""),
        input: JSON.parse(
          JSON.stringify(createStandaloneBuiltinCompactCommandInput()),
        ),
      });
      await bridge.waitForResponse(3);
      await bridge.flushWork();

      bridge.sendRequest(
        4,
        "turn/start",
        turnStart(threadId, `${SOL.provider}/${SOL.id}`, "Reply only with ok."),
      );
      await bridge.waitForResponse(4);
      await bridge.flushWork();

      const allPromptModels = sessions.flatMap((s) => s.promptModels);
      const allCompactModels = sessions.flatMap((s) => s.compactModels);
      // Expected: the compaction and every turn after the picker change run
      // on the selected model.
      expect({ allPromptModels, allCompactModels }).toEqual({
        allPromptModels: ["openai-codex/gpt-5.6-sol", "openai-codex/gpt-5.6-sol"],
        allCompactModels: ["openai-codex/gpt-5.6-sol"],
      });
    } finally {
      bridge.restore();
    }
  });
});

Repro files: 2160/repro/: helper scripts (wait-idle.sh, tell-file.sh, make-filler.sh, bb-turn-models.py, pi-assistant-models.sh; all take the server from $BB_SERVER_URL), the full Pi session files (pi-session-thr_ahgjgq8fc9.jsonl for the run shown above, pi-session-thr_iyyz7w3cxf.jsonl / pi-session-thr_chmuwwhpyh.jsonl for the original run), bb event dumps (thread-events-thr_ahgjgq8fc9.json, thread-events-final.json for the original run), logs, and the prototype patch. The verifier's independent artifacts are in 2160/verify/.

5. Root cause

The contract. Since #1640 the canonical bridge protocol declares that execution options are never diffed by the runtime; they ride every command and each bridge reconciles them:

/**
 * The runtime never diffs these options. They ride every command; the bridge
 * reconciles internally (apply live where it can, rebuild its provider
 * session where it must) and a rebuild is always reported via the
 * `session/replaced` notification — never silent.
 */

packages/provider-bridge-protocol/src/execution-options.ts#L17-L23. The generic adapter the runtime uses for every bridge therefore answers "live" for every change (packages/agent-runtime/src/bridge-protocol-adapter.ts#L318-L319), and reconfigureThreadIfNeeded only stores the new options instead of sending thread/resume (packages/agent-runtime/src/runtime.ts#L971-L1001):

    if (settingsChange !== "session") {
      // Live settings ride on the next turn command; record them without
      // replacing the session (which would kill its background tasks).
      setThreadRuntimeConfig(args.threadId, { ...currentConfig, options: nextOptions });
      return;
    }

The Pi bridge's half of the contract is missing. options.model and options.reasoningLevel are consumed in exactly one place, the session-construction mapping used by thread/start, thread/resume and thread/fork (packages/agent-runtime/src/pi/bridge/bridge.ts#L552-L571 then packages/agent-runtime/src/pi/bridge/sdk-session.ts#L301-L324):

    const configuredModel = resolveConfiguredModel(services.modelRuntime, this.options.model);
    ...
    const { session } = await createAgentSessionFromServices({
      services,
      sessionManager: ...,
      ...(configuredModel ? { model: configuredModel } : {}),
      ...(this.options.thinkingLevel ? { thinkingLevel: this.options.thinkingLevel } : {}),
      customTools,
    });

handleTurnStart (packages/agent-runtime/src/pi/bridge/bridge.ts#L963-L1002) reads params.threadId, params.input and params.clientRequestId and nothing else; it goes straight to startPiCompaction or startPiPrompt on whatever PiSdkSession is registered for the thread. PiSdkSession has no method that touches the model after start(), even though the SDK exposes AgentSession.setModel(model), setThinkingLevel(level), model and modelRuntime. So the live Pi Agent keeps state.model from construction, and every prompt() and compact() (which summarises with this.model) uses it.

Why compaction does not help. bb's manual compaction is a turn/start whose input is the standalone builtin /compact mention (apps/server/src/routes/threads/actions.ts#L135-L164); the bridge routes it to session.compact() on the same unchanged session (packages/agent-runtime/src/pi/bridge/bridge.ts#L977-L981). Nothing in the server, runtime or bridge rebuilds a session after compaction_end. The only things that construct a new Pi session, and thus pick up the current model, are thread/start, thread/resume and thread/fork. A resume is triggered by: the user releasing the thread (bb thread stop, verified above), a daemon/app restart, bridge-process recovery, or the idle-session reaper (30 min, behind the providerSessionReaping experiment which defaults to off). The reporter, on a nightly with auto-update, most plausibly hit one of those near their /compact.

How it regressed. Before c5b53caab (#1640, "Agent providers as a first-class plugin surface") the legacy Pi adapter was built with classifyExecutionSettingsChange: classifySessionExecutionSettingsChange, which returns "session" for any model/reasoning/serviceTier change (git show c5b53caab -- packages/agent-runtime/src/pi/adapter.ts). The runtime then sent thread/resume with the new options, and startPiThreadSession closed the old PiSdkSession and constructed a new one on the same session file with the new model. #1640 deleted the per-provider classification (every bridge is now "live") and moved the responsibility into each bridge. Codex (requireLiveSessionForTurn, plugins/provider-codex/src/bridge/bridge.ts#L1363-L1396) and Claude (applyLiveSessionSettings calling session.setModel, plugins/provider-claude-code/src/bridge/bridge.ts#L580-L588) got that code; the Pi bridge did not.

Deeper issue. The same gap covers reasoningLevel: Pi's thinking level is also applied only at construction, so changing "Low" to "High" in the picker mid-thread is silently ignored too (not live-tested; same code path). And the Pi bridge's conformance/bridge tests never send a turn/start whose options differ from the construction options, which is why the contract gap was invisible.

6. Proposed fix (first principles)

Make the Pi bridge honour the contract: reconcile options.model and options.reasoningLevel on every turn/start (and turn/steer) before dispatching, applying them live to the existing AgentSession. The prototype below does this and turns both repro tests green while the 11 pre-existing Pi suites (147 tests) stay green (12 files / 149 tests with the repro file included) and pnpm exec turbo run typecheck --filter=@bb/agent-runtime --force passes. It is saved as 2160/repro/prototype-fix.patch.

diff --git a/packages/agent-runtime/src/pi/bridge/bridge.ts b/packages/agent-runtime/src/pi/bridge/bridge.ts
index cdac5438d..667774fa4 100644
--- a/packages/agent-runtime/src/pi/bridge/bridge.ts
+++ b/packages/agent-runtime/src/pi/bridge/bridge.ts
@@ -50,6 +50,7 @@ import type { ImageContent } from "@earendil-works/pi-ai";
 import { createPiDeltaTranslator } from "../delta-translation.js";
 import {
   buildPiSessionParams,
+  toPiThinkingLevel,
   type PiSessionParams,
 } from "../session-params.js";
 import { PiSdkSession, type PiSdkSessionOptions } from "./sdk-session.js";
@@ -971,6 +972,20 @@ async function handleTurnStart(
     return;
   }
 
+  // Execution options ride every turn command and the runtime never diffs
+  // them (#2160): a model or reasoning level picked after the session was
+  // constructed is applied to the live session here, before dispatch.
+  try {
+    await threadSession.session.applyTurnOptions({
+      model: params.options.model,
+      thinkingLevel: toPiThinkingLevel(params.options.reasoningLevel),
+    });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : String(error);
+    sendError(id, -32000, message);
+    return;
+  }
+
   // A standalone builtin `/compact` mention is bb's manual-compaction request,
   // not model input. Prompting with the literal text would make the model talk
   // about compaction while the context keeps growing.
diff --git a/packages/agent-runtime/src/pi/bridge/sdk-session.ts b/packages/agent-runtime/src/pi/bridge/sdk-session.ts
index df3d8ad07..b1b79ad17 100644
--- a/packages/agent-runtime/src/pi/bridge/sdk-session.ts
+++ b/packages/agent-runtime/src/pi/bridge/sdk-session.ts
@@ -435,6 +435,49 @@ export class PiSdkSession {
     this.monitorSteerConsumption(tracked.promise);
   }
 
+  /**
+   * Reconcile the execution options a turn command carries with the live
+   * session. Options ride every command (the runtime never diffs them), so a
+   * model or thinking level the user changed after construction is applied
+   * here, before the input is dispatched. Unchanged values are a no-op.
+   */
+  async applyTurnOptions(args: {
+    model: string | undefined;
+    thinkingLevel: CreateAgentSessionOptions["thinkingLevel"] | undefined;
+  }): Promise<void> {
+    if (!this.session) {
+      throw new Error("No active Pi SDK session");
+    }
+    if (args.model !== undefined) {
+      const next = resolveConfiguredModel(this.session.modelRuntime, args.model);
+      const current = this.session.model;
+      if (
+        next &&
+        (current === undefined ||
+          current.provider !== next.provider ||
+          current.id !== next.id)
+      ) {
+        this.recordSdkBoundary("bridge→provider", {
+          method: "setModel",
+          params: { provider: next.provider, id: next.id },
+        });
+        await this.session.setModel(next);
+        this.options.model = args.model;
+      }
+    }
+    if (
+      args.thinkingLevel !== undefined &&
+      args.thinkingLevel !== this.session.thinkingLevel
+    ) {
+      this.recordSdkBoundary("bridge→provider", {
+        method: "setThinkingLevel",
+        params: { level: args.thinkingLevel },
+      });
+      this.session.setThinkingLevel(args.thinkingLevel);
+      this.options.thinkingLevel = args.thinkingLevel;
+    }
+  }
+
   async compact(): Promise<void> {
     if (!this.session) {
       throw new Error("No active Pi SDK session");
diff --git a/packages/agent-runtime/src/pi/session-params.ts b/packages/agent-runtime/src/pi/session-params.ts
index d0e07b03e..fa0fef9ca 100644
--- a/packages/agent-runtime/src/pi/session-params.ts
+++ b/packages/agent-runtime/src/pi/session-params.ts
@@ -13,7 +13,7 @@ type PiReasoningLevel = "off" | "low" | "medium" | "high" | "xhigh" | "max";
 // Levels Pi does not support ("ultracode", "ultra") are dropped so the bridge
 // never receives a value it would reject; reconciliation picks the closest
 // supported level before this point, so this is a defensive floor.
-function toPiThinkingLevel(
+export function toPiThinkingLevel(
   reasoningLevel: ReasoningLevel | undefined,
 ): PiReasoningLevel | undefined {
   switch (reasoningLevel) {

What to watch when turning this into a real PR:

7. PR review

No open PRs are linked to this issue.

8. Related issues

9. Appendix

Pi session file, assistant/model per message (pi-assistant-models.txt)

2026-08-21T15:48:42.946Z	model_change	github-copilot/gpt-5-mini
2026-08-21T15:48:42.961Z	user	'Reply only with ok.'
2026-08-21T15:48:45.403Z	assistant	github-copilot/gpt-5-mini	in=5338 out=28
2026-08-21T15:48:52.370Z	user	'Reply only with ok.'
2026-08-21T15:48:55.173Z	assistant	github-copilot/gpt-5-mini	in=106 out=25
2026-08-21T15:49:07.158Z	user	'Do not reply to the text below, it is filler for a compactio'
2026-08-21T15:49:11.889Z	assistant	github-copilot/gpt-5-mini	in=42138 out=43
2026-08-21T15:49:23.740Z	compaction	tokensBefore=47429
2026-08-21T15:49:26.610Z	user	'Reply only with ok.'
2026-08-21T15:49:30.205Z	assistant	github-copilot/gpt-5-mini	in=47583 out=28
2026-08-21T15:49:45.088Z	user	'Reply only with ok.'
2026-08-21T15:49:49.444Z	assistant	github-copilot/grok-4.6	in=47794 out=12

bb event log, model per requested turn (bb-turn-models.txt)

1 client/turn/requested model=github-copilot/gpt-5-mini source=spawn
2 client/thread/start
7 thread/identity
8 turn/started
13 agentMessage "ok"
15 turn/completed completed
16 client/turn/requested model=github-copilot/grok-4.6 source=tell
17 turn/started
22 agentMessage "ok"
24 turn/completed completed
25 client/turn/requested model=github-copilot/grok-4.6 source=tell
26 turn/started
31 agentMessage "ok"
33 turn/completed completed
34 client/turn/requested model=github-copilot/grok-4.6 source=tell
35 turn/started
38 thread/compacted
39 turn/completed completed
41 client/turn/requested model=github-copilot/grok-4.6 source=tell
42 turn/started
47 agentMessage "ok"
49 turn/completed completed
50 client/turn/requested model=github-copilot/grok-4.6 source=tell
51 thread/identity
52 turn/started
61 agentMessage "ok"
63 turn/completed completed

Compaction entry (model-less; cost matches gpt-5-mini)

# pi-session-thr_ahgjgq8fc9.jsonl (revised run; summary text elided)
{"type": "compaction", "id": "f023936f", "timestamp": "2026-08-21T15:49:23.740Z",
 "firstKeptEntryId": "d9bce5e5", "tokensBefore": 47429,
 "usage": {"input": 305, "output": 305, "reasoning": 64, "totalTokens": 610,
           "cost": {"input": 7.625e-05, "output": 0.00061, "total": 0.00068625}}, "fromHook": false}
# original run, pi-session-thr_iyyz7w3cxf.jsonl: same shape, tokensBefore 30011, cost.input 7.625e-05 for 305 input tokens

Regression evidence

$ git log --oneline -S'classifyExecutionSettingsChange: () => "live"' -- packages/agent-runtime/src/bridge-protocol-adapter.ts
c5b53caab Agent providers as a first-class plugin surface (provider bridge protocol) (#1640)

$ git show c5b53caab -- packages/agent-runtime/src/pi/ | grep -n classifySessionExecutionSettingsChange
-import { classifySessionExecutionSettingsChange } from "../execution-options.js";
-      classifyExecutionSettingsChange: classifySessionExecutionSettingsChange,

$ git log fcada5a3b..origin/main --oneline -- packages/agent-runtime/src/pi
(empty: not fixed on origin/main as of 2026-08-21)

Prototype verification

$ pnpm exec vitest run src/pi/bridge/__tests__/model-switch.repro.test.ts   # with prototype-fix.patch
 Test Files  1 passed (1)
      Tests  2 passed (2)
$ pnpm exec vitest run src/pi      # base, before adding the repro file: the 11 pre-existing suites
 Test Files  11 passed (11)
      Tests  147 passed (147)
$ pnpm exec vitest run src/pi      # with prototype-fix.patch + the repro file
 Test Files  12 passed (12)
      Tests  149 passed (149)
$ pnpm exec turbo run typecheck --filter=@bb/agent-runtime --force
 Tasks:    4 successful, 4 total
Cached:    0 cached, 4 total

All commands run (abridged)

gh issue view 2160 --comments
pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build
git fetch origin main && git log fcada5a3b..origin/main --oneline -- packages/agent-runtime/src/pi
cd packages/agent-runtime && pnpm exec vitest run src/pi/bridge/__tests__/model-switch.repro.test.ts   # fails on base
scripts/bb-dev-app current ; eval "$(scripts/bb-dev-app env)"
curl -s -X POST $BB_SERVER_URL/api/v1/projects ... /tmp/bb-2160-qa
pnpm bb:dev thread spawn --provider pi --model anthropic/claude-haiku-4-5 ...      # provider/error: OAuth refresh failed (anthropic)
pnpm bb:dev thread spawn --provider pi --model openai-codex/gpt-5.4-mini ...      # provider/error: refresh_token_reused (openai-codex)
pnpm bb:dev thread spawn --provider pi --model github-copilot/gpt-5-mini ...      # original run: thr_iyyz7w3cxf
pnpm bb:dev thread tell thr_iyyz7w3cxf --model github-copilot/grok-4.6 --mode auto "Reply only with ok."   # x2
2160/repro/tell-file.sh thr_iyyz7w3cxf github-copilot/grok-4.6 /tmp/bb-2160-qa/filler.txt
pnpm bb:dev thread compact thr_iyyz7w3cxf
pnpm bb:dev thread tell thr_iyyz7w3cxf --model github-copilot/grok-4.6 --mode auto "Reply only with ok."
pnpm bb:dev thread stop thr_iyyz7w3cxf ; pnpm bb:dev thread tell ... (hung 4 min once, interrupted) ; stop ; tell again (ok, grok-4.6)
pnpm bb:dev thread spawn --provider pi --model github-copilot/grok-4.6 ...        # thr_chmuwwhpyh, grok-4.6 works fresh
doobie --headless < 2160/repro/screenshot-thread.js ; ... screenshot-picker.js
# revised run (section 4a output), same commands via the env-based helper scripts, thread thr_ahgjgq8fc9:
pnpm bb:dev thread spawn --project proj_6uckkwts97 --provider pi --model github-copilot/gpt-5-mini ...
pnpm bb:dev thread tell thr_ahgjgq8fc9 --model github-copilot/grok-4.6 --mode auto "Reply only with ok."
2160/repro/make-filler.sh ; 2160/repro/tell-file.sh thr_ahgjgq8fc9 github-copilot/grok-4.6 /tmp/bb-2160-qa/filler.txt
pnpm bb:dev thread compact thr_ahgjgq8fc9 ; pnpm bb:dev thread tell thr_ahgjgq8fc9 --model github-copilot/grok-4.6 ...
pnpm bb:dev thread stop thr_ahgjgq8fc9 ; pnpm bb:dev thread tell thr_ahgjgq8fc9 --model github-copilot/grok-4.6 ...
python3 2160/repro/bb-turn-models.py thr_ahgjgq8fc9 ; 2160/repro/pi-assistant-models.sh ~/.bb/pi-bridge-sessions/thr_ahgjgq8fc9.jsonl
pnpm dev:stop ; cleanup

Caveats and observations outside this issue

10. Verification

An independent verifier (own worktree at fcada5a3b, own dev instance App :16919 / Server :24919 / daemon :32919) followed both repros. Unit repro: both tests fail on base exactly as in section 4b (setModelCalls: [], rebuilt: false; compaction and later prompts still on xai/grok-4.6), log 2160/verify/unit-test-base.log. Live repro on thr_66n5vx94kr: spawn on github-copilot/gpt-5-mini, tell --model github-copilot/grok-4.6 recorded as grok-4.6 by bb (seq 16) but answered by gpt-5-mini (in=86 out=30); a real compaction (tokensBefore=33854) followed by another grok-4.6 turn was still answered by gpt-5-mini (in=34019); thread stop + a new turn produced thread/identity (seq 51) and the next assistant messages were grok-4.6. The prototype patch applied cleanly; with it the repro tests pass and the Pi tree and @bb/agent-runtime typecheck are green (2160/verify/pi-suite-with-fix.log). The author's 4-minute hang after stop/resume did not recur.

Verifier findings and what changed in this revision.