diff --git a/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts b/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts index 440be4cedf..3c948a2d27 100644 --- a/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts +++ b/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts @@ -39,6 +39,7 @@ const { mockCreateAgentSession, mockCreateAgentSessionServices, mockInMemory, + mockModelRuntime, mockOpen, mockResourceLoaders, mockGetPiModelRuntime, @@ -48,10 +49,20 @@ const { getShellCommandPrefix: vi.fn(() => undefined), getShellPath: vi.fn(() => undefined), }; + interface MockPiModel { + id: string; + provider: string; + reasoning: boolean; + } const mockModelRuntime = { + checkAuth: vi.fn<() => Promise<{ ok: boolean } | undefined>>( + async () => undefined, + ), getAvailable: vi.fn(async () => []), - getModel: vi.fn(() => undefined), - getModels: vi.fn(() => []), + getModel: vi.fn<(provider: string, id: string) => MockPiModel | undefined>( + () => undefined, + ), + getModels: vi.fn<() => MockPiModel[]>(() => []), hasConfiguredAuth: vi.fn(() => false), refresh: vi.fn(async () => ({ aborted: false, errors: new Map() })), }; @@ -80,6 +91,7 @@ const { mockCreateAgentSession: vi.fn(), mockCreateAgentSessionServices, mockInMemory: vi.fn((cwd?: string) => ({ kind: "in-memory", cwd })), + mockModelRuntime, mockOpen: vi.fn(), mockResourceLoaders, mockGetPiModelRuntime: vi.fn(async () => mockModelRuntime), @@ -388,6 +400,96 @@ function createTurnEndEvent(stopReason: "toolUse" | "stop"): AgentSessionEvent { }; } +interface PiTestModel { + id: string; + provider: string; + reasoning: boolean; +} + +const GROK: PiTestModel = { id: "grok-4.6", provider: "xai", reasoning: true }; +const SOL: PiTestModel = { + id: "gpt-5.6-sol", + provider: "openai-codex", + reasoning: true, +}; + +interface ModelTrackingPiAgentSession extends ControlledPiAgentSession { + agent: { state: { model: PiTestModel; thinkingLevel: string } }; + /** `provider/id` + thinking level at each prompt and compact dispatch. */ + dispatches: string[]; + sessionManager: ControlledPiAgentSession["sessionManager"] & { + appendModelChange: ReturnType; + appendThinkingLevelChange: ReturnType; + }; +} + +/** + * A controlled session that also tracks the model the way pi's AgentSession + * does: `model`/`thinkingLevel` read the agent state, and every dispatch + * records what it would run with. Construction mirrors + * `createAgentSessionFromServices`: the model the bridge resolved is the one + * the session starts on. + */ +function installModelTrackingPiSessions(models: readonly PiTestModel[]): { + sessions: ModelTrackingPiAgentSession[]; + restore(): void; +} { + const sessions: ModelTrackingPiAgentSession[] = []; + mockModelRuntime.getModel.mockImplementation((provider, id) => + models.find((model) => model.provider === provider && model.id === id), + ); + mockModelRuntime.getModels.mockImplementation(() => [...models]); + mockModelRuntime.checkAuth.mockImplementation(async () => ({ ok: true })); + mockCreateAgentSession.mockImplementation( + async (options: { model: PiTestModel; thinkingLevel?: string }) => { + const base = createControlledPiAgentSession(); + const state = { + model: options.model, + thinkingLevel: options.thinkingLevel ?? "medium", + }; + const session: ModelTrackingPiAgentSession = { + ...base, + agent: { state }, + dispatches: [], + sessionManager: { + ...base.sessionManager, + appendModelChange: vi.fn(), + appendThinkingLevelChange: vi.fn(), + }, + }; + Object.defineProperties(session, { + model: { get: () => state.model }, + modelRuntime: { value: mockModelRuntime }, + thinkingLevel: { get: () => state.thinkingLevel }, + }); + const describeState = () => + `${state.model.provider}/${state.model.id} ${state.thinkingLevel}`; + session.prompt.mockImplementation( + async ( + _text: string, + options?: { preflightResult?: (accepted: boolean) => void }, + ) => { + session.dispatches.push(describeState()); + options?.preflightResult?.(true); + }, + ); + session.compact.mockImplementation(async () => { + session.dispatches.push(`compact ${describeState()}`); + }); + sessions.push(session); + return { session }; + }, + ); + return { + sessions, + restore() { + mockModelRuntime.getModel.mockImplementation(() => undefined); + mockModelRuntime.getModels.mockImplementation(() => []); + mockModelRuntime.checkAuth.mockImplementation(async () => undefined); + }, + }; +} + describe("pi bridge", () => { beforeEach(() => { vi.clearAllMocks(); @@ -808,6 +910,162 @@ describe("pi bridge", () => { } }); + it("applies the model and reasoning level a turn/start carries before prompting", async () => { + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const tracking = installModelTrackingPiSessions([GROK, SOL]); + const threadId = "thread-turn-options"; + + try { + bridge.sendRequest( + 70, + "thread/start", + sessionParams({ + threadId, + options: { model: "xai/grok-4.6", reasoningLevel: "low" }, + }), + ); + await bridge.waitForResponse(70); + + bridge.sendRequest(71, "turn/start", { + ...turnStartParams(threadId, [{ type: "text", text: "first" }]), + options: { + ...CANONICAL_OPTIONS, + model: "xai/grok-4.6", + reasoningLevel: "low", + }, + }); + await bridge.waitForResponse(71); + + // The user picks another model and level in the composer. The runtime + // never diffs options: the change only rides the next turn command. + bridge.sendRequest(72, "turn/start", { + ...turnStartParams(threadId, [{ type: "text", text: "second" }]), + options: { + ...CANONICAL_OPTIONS, + model: "openai-codex/gpt-5.6-sol", + reasoningLevel: "high", + }, + }); + await expect(bridge.waitForResponse(72)).resolves.toMatchObject({ + id: 72, + result: { threadId }, + }); + + const [session] = tracking.sessions; + expect(tracking.sessions).toHaveLength(1); + expect(session?.dispatches).toEqual([ + "xai/grok-4.6 low", + "openai-codex/gpt-5.6-sol high", + ]); + expect(session?.sessionManager.appendModelChange).toHaveBeenCalledWith( + "openai-codex", + "gpt-5.6-sol", + ); + } finally { + tracking.restore(); + bridge.restore(); + } + }); + + it("compacts with the model selected on the compaction turn", async () => { + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const tracking = installModelTrackingPiSessions([GROK, SOL]); + const threadId = "thread-compact-options"; + + try { + bridge.sendRequest( + 73, + "thread/start", + sessionParams({ threadId, options: { model: "xai/grok-4.6" } }), + ); + await bridge.waitForResponse(73); + + bridge.sendRequest(74, "turn/start", { + ...turnStartParams(threadId, [compactCommandPromptInput()]), + options: { ...CANONICAL_OPTIONS, model: "openai-codex/gpt-5.6-sol" }, + }); + await bridge.waitForResponse(74); + await bridge.flushWork(); + + expect(tracking.sessions[0]?.dispatches).toEqual([ + "compact openai-codex/gpt-5.6-sol medium", + ]); + } finally { + tracking.restore(); + bridge.restore(); + } + }); + + it("applies the model a turn/steer carries before steering", async () => { + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const tracking = installModelTrackingPiSessions([GROK, SOL]); + const threadId = "thread-steer-options"; + + try { + bridge.sendRequest( + 75, + "thread/start", + sessionParams({ threadId, options: { model: "xai/grok-4.6" } }), + ); + await bridge.waitForResponse(75); + const [session] = tracking.sessions; + if (!session) { + throw new Error("Expected a Pi session"); + } + session.isStreaming = true; + + bridge.sendRequest(76, "turn/steer", { + ...turnSteerParams(threadId, "turn-active", [ + { type: "text", text: "steer" }, + ]), + options: { ...CANONICAL_OPTIONS, model: "openai-codex/gpt-5.6-sol" }, + }); + await expect(bridge.waitForResponse(76)).resolves.toMatchObject({ + id: 76, + result: { threadId }, + }); + + expect(session.dispatches).toEqual(["openai-codex/gpt-5.6-sol medium"]); + } finally { + tracking.restore(); + bridge.restore(); + } + }); + + it("fails a turn whose model cannot be resolved instead of keeping the old one", async () => { + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const tracking = installModelTrackingPiSessions([GROK]); + const threadId = "thread-turn-bad-model"; + + try { + bridge.sendRequest( + 77, + "thread/start", + sessionParams({ threadId, options: { model: "xai/grok-4.6" } }), + ); + await bridge.waitForResponse(77); + + bridge.sendRequest(78, "turn/start", { + ...turnStartParams(threadId, [{ type: "text", text: "hello" }]), + options: { ...CANONICAL_OPTIONS, model: "unsupported/model" }, + }); + await expect(bridge.waitForResponse(78)).resolves.toMatchObject({ + error: { + code: -32000, + message: 'Failed to resolve Pi model "unsupported/model"', + }, + id: 78, + }); + + const [session] = tracking.sessions; + expect(session?.dispatches).toEqual([]); + expect(session?.agent.state.model).toEqual(GROK); + } finally { + tracking.restore(); + bridge.restore(); + } + }); + it("fails thread/start when the requested Pi model cannot be resolved", async () => { const bridge = createBridgeJsonRpcTestHarness(handleLine); mockCreateAgentSession.mockImplementation(async () => ({ diff --git a/packages/agent-runtime/src/pi/bridge/__tests__/sdk-session.test.ts b/packages/agent-runtime/src/pi/bridge/__tests__/sdk-session.test.ts index 78c3094f4c..6bdcfafbeb 100644 --- a/packages/agent-runtime/src/pi/bridge/__tests__/sdk-session.test.ts +++ b/packages/agent-runtime/src/pi/bridge/__tests__/sdk-session.test.ts @@ -290,6 +290,94 @@ function createAutoRetryEndEvent(success: boolean): AgentSessionEvent { }; } +interface ModelTrackingPiModel { + id: string; + provider: string; + reasoning: boolean; +} + +/** + * A stand-in for the live `AgentSession` surface that turn-option + * reconciliation touches. It mirrors the real shape: `model` and + * `thinkingLevel` read the agent state, the session file is appended through + * `sessionManager`, and the persisting SDK setters plus the settings writer + * are spies so a test can prove they stay untouched. + */ +function createModelTrackingPiSession(args: { + model: ModelTrackingPiModel; + models: readonly ModelTrackingPiModel[]; + thinkingLevel: string; + authenticated?: boolean; +}) { + const state = { model: args.model, thinkingLevel: args.thinkingLevel }; + const session = { + abort: mockAbort, + agent: { state }, + bindExtensions: mockBindExtensions, + compact: mockCompact, + dispose: mockDispose, + extensionRunner: { emit: vi.fn(async () => {}) }, + getActiveToolNames: mockGetActiveToolNames, + getContextUsage: vi.fn(), + getSessionStats: vi.fn(), + hasExtensionHandlers: vi.fn(() => false), + isStreaming: false, + get model() { + return state.model; + }, + modelRuntime: { + checkAuth: vi.fn(async () => + args.authenticated === false ? undefined : { ok: true }, + ), + getModel: vi.fn((provider: string, id: string) => + args.models.find( + (candidate) => candidate.provider === provider && candidate.id === id, + ), + ), + getModels: vi.fn(() => args.models), + hasConfiguredAuth: vi.fn(() => true), + }, + prompt: mockPrompt, + sessionManager: { + appendModelChange: vi.fn(), + appendThinkingLevelChange: vi.fn(), + getLeafId: vi.fn(() => "pi-entry-checkpoint"), + }, + setActiveToolsByName: mockSetActiveToolsByName, + setModel: vi.fn(), + setThinkingLevel: vi.fn(), + settingsManager: { + setDefaultModelAndProvider: vi.fn(), + setDefaultThinkingLevel: vi.fn(), + }, + subscribe: vi.fn((listener: MockAgentSessionEventListener) => { + mockSessionEventListeners.push(listener); + return () => {}; + }), + get thinkingLevel() { + return state.thinkingLevel; + }, + }; + mockCreateAgentSession.mockResolvedValueOnce({ session }); + return session; +} + +const GROK: ModelTrackingPiModel = { + id: "grok-4.6", + provider: "xai", + reasoning: true, +}; +const SOL: ModelTrackingPiModel = { + id: "gpt-5.6-sol", + provider: "openai-codex", + reasoning: true, +}; +const NO_REASONING: ModelTrackingPiModel = { + id: "gpt-4.1", + provider: "openai", + reasoning: false, +}; + async function flushAsyncWork(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -1097,6 +1185,135 @@ describe("PiSdkSession", () => { expect(session.getIsCompacting()).toBe(false); }); + describe("applyTurnOptions", () => { + it("switches the live model and thinking level without touching the user's pi defaults", async () => { + const piSession = createModelTrackingPiSession({ + model: GROK, + models: [GROK, SOL], + thinkingLevel: "low", + }); + const session = new PiSdkSession( + { cwd: "/tmp/project", model: "xai/grok-4.6", thinkingLevel: "low" }, + vi.fn(), + vi.fn(), + ); + await session.start(); + + await session.applyTurnOptions({ + model: "openai-codex/gpt-5.6-sol", + thinkingLevel: "high", + }); + + expect(piSession.agent.state).toEqual({ + model: SOL, + thinkingLevel: "high", + }); + expect(piSession.sessionManager.appendModelChange).toHaveBeenCalledWith( + "openai-codex", + "gpt-5.6-sol", + ); + expect( + piSession.sessionManager.appendThinkingLevelChange, + ).toHaveBeenCalledWith("high"); + // AgentSession.setModel / setThinkingLevel persist the selection into + // ~/.pi/agent/settings.json as the user's CLI default. A per-thread bb + // selection must never do that. + expect(piSession.setModel).not.toHaveBeenCalled(); + expect(piSession.setThinkingLevel).not.toHaveBeenCalled(); + expect( + piSession.settingsManager.setDefaultModelAndProvider, + ).not.toHaveBeenCalled(); + expect( + piSession.settingsManager.setDefaultThinkingLevel, + ).not.toHaveBeenCalled(); + }); + + it("re-clamps the thinking level to what the new model supports", async () => { + const piSession = createModelTrackingPiSession({ + model: GROK, + models: [GROK, NO_REASONING], + thinkingLevel: "high", + }); + const session = new PiSdkSession( + { cwd: "/tmp/project", model: "xai/grok-4.6" }, + vi.fn(), + vi.fn(), + ); + await session.start(); + + await session.applyTurnOptions({ + model: "openai/gpt-4.1", + thinkingLevel: undefined, + }); + + expect(piSession.agent.state).toEqual({ + model: NO_REASONING, + thinkingLevel: "off", + }); + expect( + piSession.sessionManager.appendThinkingLevelChange, + ).toHaveBeenCalledWith("off"); + }); + + it("leaves the session alone when the turn carries the current options", async () => { + const piSession = createModelTrackingPiSession({ + model: GROK, + models: [GROK, SOL], + thinkingLevel: "medium", + }); + const session = new PiSdkSession( + { cwd: "/tmp/project", model: "xai/grok-4.6" }, + vi.fn(), + vi.fn(), + ); + await session.start(); + + await session.applyTurnOptions({ + model: "xai/grok-4.6", + thinkingLevel: "medium", + }); + await session.applyTurnOptions({ + model: undefined, + thinkingLevel: undefined, + }); + + expect(piSession.agent.state).toEqual({ + model: GROK, + thinkingLevel: "medium", + }); + expect(piSession.modelRuntime.checkAuth).not.toHaveBeenCalled(); + expect(piSession.sessionManager.appendModelChange).not.toHaveBeenCalled(); + expect( + piSession.sessionManager.appendThinkingLevelChange, + ).not.toHaveBeenCalled(); + }); + + it("rejects a model without credentials and keeps the current one", async () => { + const piSession = createModelTrackingPiSession({ + authenticated: false, + model: GROK, + models: [GROK, SOL], + thinkingLevel: "medium", + }); + const session = new PiSdkSession( + { cwd: "/tmp/project", model: "xai/grok-4.6" }, + vi.fn(), + vi.fn(), + ); + await session.start(); + + await expect( + session.applyTurnOptions({ + model: "openai-codex/gpt-5.6-sol", + thinkingLevel: undefined, + }), + ).rejects.toThrow("No API key for openai-codex/gpt-5.6-sol"); + + expect(piSession.agent.state.model).toEqual(GROK); + expect(piSession.sessionManager.appendModelChange).not.toHaveBeenCalled(); + }); + }); + it("waits for abort before disposing during graceful close", async () => { let resolveAbort: (() => void) | undefined; mockAbort.mockImplementation( diff --git a/packages/agent-runtime/src/pi/bridge/bridge.ts b/packages/agent-runtime/src/pi/bridge/bridge.ts index cdac5438d4..1e7d389536 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, + buildPiTurnOptions, type PiSessionParams, } from "../session-params.js"; import { PiSdkSession, type PiSdkSessionOptions } from "./sdk-session.js"; @@ -960,6 +961,31 @@ function recordAcceptedTurnInput(params: TurnStartParams): void { ]); } +/** + * Apply the execution options a turn command carries to the live session + * before its input is dispatched. Options ride every command and the runtime + * never diffs them, so a model or reasoning level picked after the session + * was constructed reaches pi here (#2160). Runs ahead of every dispatch, + * including manual compaction, so the summarization request also goes to the + * selected model. Returns false after failing the request when the options + * cannot be applied: a model that does not resolve must fail the turn, not + * silently keep the old model. + */ +async function applyTurnOptionsOrFail( + id: string | number, + threadSession: ThreadSession, + options: TurnStartParams["options"], +): Promise { + try { + await threadSession.session.applyTurnOptions(buildPiTurnOptions(options)); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendError(id, -32000, message); + return false; + } +} + async function handleTurnStart( id: string | number, params: TurnStartParams, @@ -971,6 +997,10 @@ async function handleTurnStart( return; } + if (!(await applyTurnOptionsOrFail(id, threadSession, params.options))) { + 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. @@ -1022,6 +1052,10 @@ async function handleTurnSteer( return; } + if (!(await applyTurnOptionsOrFail(id, threadSession, params.options))) { + return; + } + try { await threadSession.session.steer( text, diff --git a/packages/agent-runtime/src/pi/bridge/sdk-session.ts b/packages/agent-runtime/src/pi/bridge/sdk-session.ts index df3d8ad07f..016f1d43ca 100644 --- a/packages/agent-runtime/src/pi/bridge/sdk-session.ts +++ b/packages/agent-runtime/src/pi/bridge/sdk-session.ts @@ -13,14 +13,16 @@ import { type PromptOptions, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; -import type { ImageContent } from "@earendil-works/pi-ai"; +import { clampThinkingLevel, type ImageContent } from "@earendil-works/pi-ai"; import { getBridgeRecorder } from "@bb/provider-bridge-protocol/bridge-kit"; import { createConfiguredPiServices } from "./configured-services.js"; +type PiThinkingLevel = NonNullable; + export interface PiSdkSessionOptions { cwd: string; model?: string; - thinkingLevel?: CreateAgentSessionOptions["thinkingLevel"]; + thinkingLevel?: PiThinkingLevel; additionalSkillPaths?: readonly string[]; shellEnvOverrides?: ShellEnvOverrides; customTools?: ToolDefinition[]; @@ -435,6 +437,77 @@ export class PiSdkSession { this.monitorSteerConsumption(tracked.promise); } + /** + * Reconcile the execution options a turn command carries with the live + * session. The runtime never diffs options: they ride every turn command and + * the bridge applies what changed (#2160). Construction is the only other + * place that reads them, so without this a model or thinking level picked + * mid-thread would wait for the next session rebuild. + * + * This deliberately bypasses `AgentSession.setModel` / `setThinkingLevel`: + * both persist the new value as the user's default in the global Pi settings + * file (`~/.pi/agent/settings.json`). Session construction never does that, + * and a per-thread bb selection must not rewrite the user's `pi` CLI + * defaults. The SDK's own pieces are applied directly instead: the model is + * auth-checked and swapped on the agent state, recorded in the session file, + * and the thinking level is re-clamped to the new model's capabilities the + * way `setModel` does. + * + * @throws when the requested model does not resolve or has no credentials; + * the caller fails the turn instead of silently keeping the old model. + */ + async applyTurnOptions(args: { + model: string | undefined; + thinkingLevel: PiThinkingLevel | undefined; + }): Promise { + const session = this.session; + if (!session) { + throw new Error("No active Pi SDK session"); + } + + const nextModel = resolveConfiguredModel(session.modelRuntime, args.model); + const currentModel = session.model; + if ( + nextModel && + (currentModel === undefined || + currentModel.provider !== nextModel.provider || + currentModel.id !== nextModel.id) + ) { + if (!(await session.modelRuntime.checkAuth(nextModel.provider))) { + throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`); + } + this.recordSdkBoundary("bridge→provider", { + method: "setModel", + params: { provider: nextModel.provider, id: nextModel.id }, + }); + session.agent.state.model = nextModel; + session.sessionManager.appendModelChange( + nextModel.provider, + nextModel.id, + ); + } + + // Without a model there is nothing to clamp against; pi already forced + // the level to "off" at construction. + if (!session.model) { + return; + } + // An unchanged or unsupported level keeps the current one, but a model + // switch still re-clamps it to what the new model supports. + const effectiveLevel = clampThinkingLevel( + session.model, + args.thinkingLevel ?? session.thinkingLevel, + ); + if (effectiveLevel !== session.thinkingLevel) { + this.recordSdkBoundary("bridge→provider", { + method: "setThinkingLevel", + params: { level: effectiveLevel }, + }); + session.agent.state.thinkingLevel = effectiveLevel; + session.sessionManager.appendThinkingLevelChange(effectiveLevel); + } + } + async compact(): Promise { 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 d0e07b03e4..b9fee7b043 100644 --- a/packages/agent-runtime/src/pi/session-params.ts +++ b/packages/agent-runtime/src/pi/session-params.ts @@ -71,6 +71,23 @@ export interface PiSessionParams { thinkingLevel?: PiReasoningLevel; } +/** + * The option subset a turn command can change on a live session. Every turn + * command carries the full execution options; the bridge applies these to + * the session it already holds instead of waiting for a rebuild. + */ +export interface PiTurnOptions { + model: string | undefined; + thinkingLevel: PiReasoningLevel | undefined; +} + +export function buildPiTurnOptions(options: PiSessionOptions): PiTurnOptions { + return { + model: options.model ? options.model : undefined, + thinkingLevel: toPiThinkingLevel(options.reasoningLevel), + }; +} + export function buildPiSessionParams( args: BuildPiSessionParamsArgs, ): PiSessionParams {