diff --git a/.changeset/planning-model-pair-rehydration.md b/.changeset/planning-model-pair-rehydration.md new file mode 100644 index 0000000000..59d5cfeaf7 --- /dev/null +++ b/.changeset/planning-model-pair-rehydration.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Planning Mode failing mid-interview with a provider auth error on a model you never selected. +category: fix +dev: `ensureSessionAgent` rebuilt the planning agent with an empty provider/model pair, so resumed turns (`/planning/respond`, `/planning/:id/retry`, rewind, drafts resumed after the in-memory agent was dropped) fell through to the runtime's built-in default model (`anthropic/claude-opus-4-8`) and hit api.anthropic.com with a key the operator never configured. The pair is now resolved from the persisted draft, then the lane's `resolvePlanningSettingsModel` result, on every rebuild and on the non-streaming start. Planning also now constructs sessions through `createResolvedAgentSession` (`sessionPurpose: "executor"`) like chat/executor/merger, so CLI and plugin runtimes can own their own auth and planning emits `session:runtime-resolved`. diff --git a/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts b/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts index c89ef5ffd5..d1c82b071e 100644 --- a/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts +++ b/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { createFnAgentMock, resolveMcpServersForStoreMock } = vi.hoisted(() => ({ +const { createFnAgentMock, createResolvedAgentSessionMock, resolveMcpServersForStoreMock } = vi.hoisted(() => { /* FNXC:DashboardTests 2026-07-18-12:20: Planning defaults clarificationEnabled=false, so createSession forces a summary after the @@ -10,7 +10,7 @@ const { createFnAgentMock, resolveMcpServersForStoreMock } = vi.hoisted(() => ({ complete payload so MCP-forwarding assertions exercise the default product path instead of throwing Clarification-disabled follow-up did not produce a summary. */ - createFnAgentMock: vi.fn(async () => ({ + const makeScriptedAgent = () => ({ session: { state: { messages: [] as Array<{ role: string; content: string }> }, prompt: vi.fn(async function (this: { state: { messages: Array<{ role: string; content: string }> } }, _message: string) { @@ -42,12 +42,23 @@ const { createFnAgentMock, resolveMcpServersForStoreMock } = vi.hoisted(() => ({ }), dispose: vi.fn(), }, - })), - resolveMcpServersForStoreMock: vi.fn(async () => ({ - servers: [{ name: "docs", transport: "stdio", command: "node", env: { TOKEN: "materialized-secret" } }], - errors: [], - })), -})); + }); + return { + createFnAgentMock: vi.fn(makeScriptedAgent), + /* + FNXC:PlanningRuntimeResolution 2026-07-24-16:20: + Planning now builds its session through the shared runtime-resolving seam + (`createResolvedAgentSession`), not a bare `createFnAgent` call, so the MCP-forwarding + contract for the planning lanes is asserted on that seam. The mission/target interview + lanes still construct through `createFnAgent` and keep their own mock. + */ + createResolvedAgentSessionMock: vi.fn(makeScriptedAgent), + resolveMcpServersForStoreMock: vi.fn(async () => ({ + servers: [{ name: "docs", transport: "stdio", command: "node", env: { TOKEN: "materialized-secret" } }], + errors: [], + })), + }; +}); vi.mock("@fusion/core", async (importOriginal) => { const actual = await importOriginal(); @@ -68,6 +79,7 @@ vi.mock("@fusion/engine", async (importOriginal) => { createChatTaskDocumentTools: vi.fn(() => []), createWorkflowAuthoringTools: vi.fn(() => []), createFnAgent: createFnAgentMock, + createResolvedAgentSession: createResolvedAgentSessionMock, resolveMcpServersForStore: resolveMcpServersForStoreMock, }; }); @@ -99,16 +111,17 @@ describe("dashboard MCP lane forwarding", () => { beforeEach(() => { __resetPlanningState(); createFnAgentMock.mockClear(); + createResolvedAgentSessionMock.mockClear(); resolveMcpServersForStoreMock.mockClear(); }); - it("forwards the materialized MCP set to chat/planning createFnAgent sessions", async () => { + it("forwards the materialized MCP set to chat/planning agent sessions", async () => { const store = makePlanningStore(); await createSession("127.0.0.1", "Build a feature", store, "/tmp/fusion-dashboard-test"); expect(resolveMcpServersForStoreMock).toHaveBeenCalledWith(store); - expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ + expect(createResolvedAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/tmp/fusion-dashboard-test", tools: "readonly", allowMcpToolsInReadonly: true, @@ -121,7 +134,7 @@ describe("dashboard MCP lane forwarding", () => { await createSession("127.0.0.1", "Build without MCP", makePlanningStore(), "/tmp/fusion-dashboard-test"); - expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ + expect(createResolvedAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ tools: "readonly", allowMcpToolsInReadonly: true, mcpServers: [], @@ -142,7 +155,7 @@ describe("dashboard MCP lane forwarding", () => { expect(startInitialTurn).toBeTypeOf("function"); startInitialTurn?.(); - await vi.waitFor(() => expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ + await vi.waitFor(() => expect(createResolvedAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ tools: "readonly", allowMcpToolsInReadonly: true, mcpServers: [], diff --git a/packages/dashboard/src/__tests__/planning-answered-question-reemit.test.ts b/packages/dashboard/src/__tests__/planning-answered-question-reemit.test.ts index 1e8d4c9a47..cb2726595a 100644 --- a/packages/dashboard/src/__tests__/planning-answered-question-reemit.test.ts +++ b/packages/dashboard/src/__tests__/planning-answered-question-reemit.test.ts @@ -25,6 +25,10 @@ vi.mock("@fusion/engine", () => ({ skillSource: "role-fallback" as const, }), createFnAgent: vi.fn(), + // FNXC:PlanningRuntimeResolution 2026-07-24-16:20: planning builds sessions through the + // shared runtime-resolving seam; these suites drive it via __setCreateFnAgent, so the + // engine export only needs to exist on the mock factory. + createResolvedAgentSession: vi.fn(), createWorkflowAuthoringTools: () => [], createChatTaskDocumentTools: () => [], createChatTaskLogsReadTool: () => ({}), diff --git a/packages/dashboard/src/__tests__/planning-context-compaction.test.ts b/packages/dashboard/src/__tests__/planning-context-compaction.test.ts index 7fea9a95c5..0602a04889 100644 --- a/packages/dashboard/src/__tests__/planning-context-compaction.test.ts +++ b/packages/dashboard/src/__tests__/planning-context-compaction.test.ts @@ -27,6 +27,10 @@ vi.mock("@fusion/engine", () => ({ skillSource: "role-fallback" as const, }), createFnAgent: vi.fn(), + // FNXC:PlanningRuntimeResolution 2026-07-24-16:20: planning builds sessions through the + // shared runtime-resolving seam; these suites drive it via __setCreateFnAgent, so the + // engine export only needs to exist on the mock factory. + createResolvedAgentSession: vi.fn(), createWorkflowAuthoringTools: () => [], createChatTaskDocumentTools: () => [], createChatTaskLogsReadTool: () => ({}), diff --git a/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts b/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts index 24598fd58f..b2c20e827c 100644 --- a/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts +++ b/packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts @@ -9,6 +9,10 @@ vi.mock("@fusion/engine", () => ({ resolveMcpServersForStore: async () => ({ servers: [] }), buildSessionSkillContextSync: () => ({ skillSelectionContext: undefined, resolvedSkillNames: [], skillSource: "role-fallback" as const }), createFnAgent: vi.fn(), + // FNXC:PlanningRuntimeResolution 2026-07-24-16:20: planning builds sessions through the + // shared runtime-resolving seam; these suites drive it via __setCreateFnAgent, so the + // engine export only needs to exist on the mock factory. + createResolvedAgentSession: vi.fn(), createAgentTask: vi.fn(), createWorkflowAuthoringTools: () => [], createChatTaskDocumentTools: () => [], diff --git a/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts b/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts index aacccc44da..a5f018fee9 100644 --- a/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts +++ b/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts @@ -13,6 +13,10 @@ vi.mock("@fusion/engine", () => ({ skillSource: "role-fallback" as const, }), createFnAgent: vi.fn(), + // FNXC:PlanningRuntimeResolution 2026-07-24-16:20: planning builds sessions through the + // shared runtime-resolving seam; these suites drive it via __setCreateFnAgent, so the + // engine export only needs to exist on the mock factory. + createResolvedAgentSession: vi.fn(), createWorkflowAuthoringTools: () => [], createChatTaskDocumentTools: () => [], createChatTaskLogsReadTool: () => ({}), diff --git a/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts b/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts index 0fc696329b..e824cde2d9 100644 --- a/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts +++ b/packages/dashboard/src/__tests__/planning-infinite-interview.test.ts @@ -10,6 +10,10 @@ vi.mock("@fusion/engine", () => ({ resolveMcpServersForStore: async () => ({ servers: [] }), buildSessionSkillContextSync: () => ({ skillSelectionContext: undefined, resolvedSkillNames: [], skillSource: "role-fallback" as const }), createFnAgent: vi.fn(), + // FNXC:PlanningRuntimeResolution 2026-07-24-16:20: planning builds sessions through the + // shared runtime-resolving seam; these suites drive it via __setCreateFnAgent, so the + // engine export only needs to exist on the mock factory. + createResolvedAgentSession: vi.fn(), createWorkflowAuthoringTools: () => [], createChatTaskDocumentTools: () => [], createChatTaskLogsReadTool: () => ({}), diff --git a/packages/dashboard/src/__tests__/planning-model-pair-rehydration.test.ts b/packages/dashboard/src/__tests__/planning-model-pair-rehydration.test.ts new file mode 100644 index 0000000000..289353dc10 --- /dev/null +++ b/packages/dashboard/src/__tests__/planning-model-pair-rehydration.test.ts @@ -0,0 +1,244 @@ +// @vitest-environment node + +/* +FNXC:PlanningModelRehydration 2026-07-24-16:20: +Regression tests for the planning lane's provider/model pair. + +Reported symptom: a planning interview streamed Q1 and Q2 normally on the operator's +configured (custom) provider, then a later turn died with +`401 {"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}` +from api.anthropic.com — a provider the operator never selected for planning. Every other +Fusion lane using the same model worked. + +Cause: `ensureSessionAgent` rebuilt the agent with `undefined, undefined` for the model pair +(while still preserving draftThinkingLevel), and the non-streaming start never passed one at +all. With an incomplete pair the runtime forwards no `model`, so pi-coding-agent selects its +OWN built-in default (`anthropic/claude-opus-4-8`) — the resumed turn silently left the +operator's provider and hit Anthropic with a key they never configured. + +Invariant asserted here across ALL rebuild/start surfaces, not just the reported one: +every planning agent construction receives a complete `defaultProvider`/`defaultModelId` +pair — the pair persisted on the draft when present, otherwise the lane's settings-resolved +pair — and planning is created through the shared runtime-resolving seam. +*/ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import type { TaskStore } from "@fusion/core"; + +const { createResolvedAgentSessionMock } = vi.hoisted(() => ({ + createResolvedAgentSessionMock: vi.fn(), +})); + +vi.mock("@fusion/engine", () => ({ + listCliAdapterDescriptors: () => [], + resolveMcpServersForStore: async () => ({ servers: [] }), + buildSessionSkillContextSync: () => ({ + skillSelectionContext: undefined, + resolvedSkillNames: ["fusion"], + skillSource: "role-fallback" as const, + }), + createResolvedAgentSession: createResolvedAgentSessionMock, + createWorkflowAuthoringTools: () => [], + createChatTaskDocumentTools: () => [], + createChatTaskLogsReadTool: () => ({}), +})); + +import { + __resetPlanningState, + __setCreateFnAgent, + createSession, + createSessionWithAgent, + getSession, + planningStreamManager, + retrySession, + setAiSessionStore, + submitResponse, +} from "../planning.js"; + +const SETTINGS_PAIR = { planningProvider: "acme-custom", planningModelId: "acme-large" }; + +function taskStore(settings: Record = SETTINGS_PAIR): TaskStore { + return { + listTasks: vi.fn(async () => []), + getSettings: vi.fn(async () => settings), + getTask: vi.fn(async () => { + throw new Error("not found"); + }), + } as unknown as TaskStore; +} + +const QUESTION_JSON = JSON.stringify({ + type: "question", + data: { id: "q-next", type: "single_select", question: "What next?" }, +}); + +function scriptedAgent() { + const messages: Array<{ role: string; content: string }> = []; + return { + session: { + state: { messages }, + prompt: vi.fn(async () => { + messages.push({ role: "assistant", content: QUESTION_JSON }); + }), + dispose: vi.fn(), + }, + }; +} + +function lastPair(factory: ReturnType): { provider?: string; model?: string } | undefined { + const pairs = capturedPairs(factory); + return pairs[pairs.length - 1]; +} + +async function waitFor(predicate: () => Promise | boolean, attempts = 50): Promise { + for (let i = 0; i < attempts; i++) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("condition not reached"); +} + +/** Every provider/model pair the planning lane handed to the session factory. */ +function capturedPairs(factory: ReturnType): Array<{ provider?: string; model?: string }> { + return factory.mock.calls.map(([options]) => ({ + provider: options?.defaultProvider, + model: options?.defaultModelId, + })); +} + +describe("planning provider/model pair is never dropped", () => { + let factory: ReturnType; + + beforeEach(() => { + __resetPlanningState(); + createResolvedAgentSessionMock.mockReset(); + setAiSessionStore(Object.assign(new EventEmitter(), { + upsert: vi.fn(async () => {}), + get: vi.fn(async () => null), + updateThinking: vi.fn(), + }) as never); + factory = vi.fn(async () => scriptedAgent()); + }); + + /* + Ordered first deliberately: it exercises the module's DEFAULT session factory, which the + `__setCreateFnAgent` seam in the later tests permanently replaces for this module instance. + */ + it("creates planning sessions through the shared runtime-resolving seam", async () => { + createResolvedAgentSessionMock.mockImplementation(async () => scriptedAgent()); + + const sessionId = await createSessionWithAgent( + "10.9.0.1", + "Plan something small", + "/tmp/project", + taskStore(), + "acme-custom", + "acme-large", + ); + planningStreamManager.consumeInitialTurn(sessionId)?.(); + await waitFor(async () => Boolean((await getSession(sessionId))?.currentQuestion)); + + expect(createResolvedAgentSessionMock).toHaveBeenCalled(); + const [options] = createResolvedAgentSessionMock.mock.calls[0]; + // Runtime resolution is what lets a CLI/plugin runtime own its own auth instead of + // forcing every planning turn onto a direct, key-requiring provider call. + expect(options.sessionPurpose).toBe("executor"); + expect(options.defaultProvider).toBe("acme-custom"); + expect(options.defaultModelId).toBe("acme-large"); + }); + + it("rebuilds a resumed agent on the pair the session started with (reported symptom)", async () => { + __setCreateFnAgent(factory as never); + const store = taskStore(); + const sessionId = await createSessionWithAgent( + "10.9.0.2", + "Plan something small", + "/tmp/project", + store, + "acme-custom", + "acme-large", + ); + planningStreamManager.consumeInitialTurn(sessionId)?.(); + await waitFor(async () => Boolean((await getSession(sessionId))?.currentQuestion)); + + const session = (await getSession(sessionId))!; + const question = session.currentQuestion!; + // Exactly the reported state: the in-memory agent is gone (restart / eviction / + // draft resumed from History) and the next turn must rebuild it. + session.agent = undefined; + + await submitResponse(sessionId, { [question.id]: "option-1" }, "/tmp/project", undefined, store); + + expect(factory.mock.calls.length).toBeGreaterThan(1); + // The original failure: the rebuild call carried no pair at all, so the runtime fell + // through to its built-in anthropic default and 401'd on a raw x-api-key. + for (const pair of capturedPairs(factory)) { + expect(pair).toEqual({ provider: "acme-custom", model: "acme-large" }); + } + }); + + it("falls back to the settings-resolved pair when the draft carries none", async () => { + __setCreateFnAgent(factory as never); + const store = taskStore(); + // No explicit pair on start — the draft has nothing to replay from. + const sessionId = await createSessionWithAgent("10.9.0.3", "Plan something small", "/tmp/project", store); + planningStreamManager.consumeInitialTurn(sessionId)?.(); + await waitFor(async () => Boolean((await getSession(sessionId))?.currentQuestion)); + + const session = (await getSession(sessionId))!; + const question = session.currentQuestion!; + session.agent = undefined; + session.draftModelProvider = undefined; + session.draftModelId = undefined; + + await submitResponse(sessionId, { [question.id]: "option-1" }, "/tmp/project", undefined, store); + + const rebuild = lastPair(factory); + expect(rebuild).toEqual({ provider: "acme-custom", model: "acme-large" }); + }); + + it("keeps the pair on the retry rebuild surface", async () => { + __setCreateFnAgent(factory as never); + const store = taskStore(); + const sessionId = await createSessionWithAgent( + "10.9.0.4", + "Plan something small", + "/tmp/project", + store, + "acme-custom", + "acme-large", + ); + planningStreamManager.consumeInitialTurn(sessionId)?.(); + await waitFor(async () => Boolean((await getSession(sessionId))?.currentQuestion)); + + const session = (await getSession(sessionId))!; + session.error = "AI returned no valid JSON. Retry this planning session or start a new one."; + session.agent = undefined; + + await retrySession(sessionId, "/tmp/project", undefined, store); + + const rebuild = lastPair(factory); + expect(rebuild).toEqual({ provider: "acme-custom", model: "acme-large" }); + }); + + it("resolves a pair on the non-streaming start surface too", async () => { + __setCreateFnAgent(factory as never); + const store = taskStore(); + + await createSession("10.9.0.5", "Plan something small", store, "/tmp/project"); + + expect(capturedPairs(factory)[0]).toEqual({ provider: "acme-custom", model: "acme-large" }); + }); + + it("still constructs an agent when no pair can be resolved anywhere", async () => { + __setCreateFnAgent(factory as never); + // Empty settings: no lane pair, no project default. The session must still start + // (runtime built-in default) rather than throwing at the operator mid-interview. + const store = taskStore({}); + + await createSession("10.9.0.6", "Plan something small", store, "/tmp/project"); + + expect(capturedPairs(factory)[0]).toEqual({ provider: undefined, model: undefined }); + }); +}); diff --git a/packages/dashboard/src/__tests__/planning-provider-error-recovery.test.ts b/packages/dashboard/src/__tests__/planning-provider-error-recovery.test.ts index 427b7e0305..6ed6f4af47 100644 --- a/packages/dashboard/src/__tests__/planning-provider-error-recovery.test.ts +++ b/packages/dashboard/src/__tests__/planning-provider-error-recovery.test.ts @@ -26,6 +26,10 @@ vi.mock("@fusion/engine", () => ({ skillSource: "role-fallback" as const, }), createFnAgent: vi.fn(), + // FNXC:PlanningRuntimeResolution 2026-07-24-16:20: planning builds sessions through the + // shared runtime-resolving seam; these suites drive it via __setCreateFnAgent, so the + // engine export only needs to exist on the mock factory. + createResolvedAgentSession: vi.fn(), createWorkflowAuthoringTools: () => [], createChatTaskDocumentTools: () => [], createChatTaskLogsReadTool: () => ({}), diff --git a/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts b/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts index fb72702897..54a2ded1cb 100644 --- a/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts +++ b/packages/dashboard/src/__tests__/planning-question-regeneration.test.ts @@ -30,6 +30,10 @@ vi.mock("@fusion/engine", () => ({ skillSource: "role-fallback" as const, }), createFnAgent: vi.fn(), + // FNXC:PlanningRuntimeResolution 2026-07-24-16:20: planning builds sessions through the + // shared runtime-resolving seam; these suites drive it via __setCreateFnAgent, so the + // engine export only needs to exist on the mock factory. + createResolvedAgentSession: vi.fn(), createWorkflowAuthoringTools: () => [], createChatTaskDocumentTools: () => [], createChatTaskLogsReadTool: () => ({}), diff --git a/packages/dashboard/src/__tests__/planning-turn-admission.test.ts b/packages/dashboard/src/__tests__/planning-turn-admission.test.ts index 9fc7d5338c..fb013acde3 100644 --- a/packages/dashboard/src/__tests__/planning-turn-admission.test.ts +++ b/packages/dashboard/src/__tests__/planning-turn-admission.test.ts @@ -24,6 +24,10 @@ vi.mock("@fusion/engine", () => ({ skillSource: "role-fallback" as const, }), createFnAgent: vi.fn(), + // FNXC:PlanningRuntimeResolution 2026-07-24-16:20: planning builds sessions through the + // shared runtime-resolving seam; these suites drive it via __setCreateFnAgent, so the + // engine export only needs to exist on the mock factory. + createResolvedAgentSession: vi.fn(), createWorkflowAuthoringTools: () => [], createChatTaskDocumentTools: () => [], createChatTaskLogsReadTool: () => ({}), diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 92a7a60170..04ae321a83 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -28,6 +28,7 @@ import { TASK_PRIORITIES, THINKING_LEVELS, formatPlanningPlanMd, + resolvePlanningSettingsModel, summarizeTitle, type PromptOverrideMap, } from "@fusion/core"; @@ -47,7 +48,7 @@ import { buildSessionSkillContextSync, createChatTaskDocumentTools, createChatTaskLogsReadTool, - createFnAgent as engineCreateFnAgent, + createResolvedAgentSession, createWorkflowAuthoringTools, resolveMcpServersForStore, } from "@fusion/engine"; @@ -81,8 +82,31 @@ type PlanningSessionOptions = { messageStore?: MessageStore; pluginRunner?: SkillPluginRunner; }; +/* +FNXC:PlanningRuntimeResolution 2026-07-24-16:20: +Planning sessions must be created through the shared `createResolvedAgentSession` seam that chat, executor, reviewer, merger, and heartbeat already use — NOT through a bare `createFnAgent` call. `createFnAgent` pins the session to the default pi runtime, so a planning selection could never route to a CLI/plugin runtime (claude-local/ACP, grok, omp) that owns its own auth; it always issued a direct HTTPS call to the provider endpoint with a Fusion-resolved key. That divergence is why a subscription/CLI-authenticated operator saw planning fail with a raw-key `401 invalid x-api-key` while every other lane worked. Routing here also emits the `session:runtime-resolved` run-audit event, so planning's resolved runtime and post-transform model pair are finally visible. + +Planning has no ambient task or bound agent, so it carries no runtimeHint of its own; `resolveRuntime` falls back to the default pi runtime exactly as before when no plugin runtime claims the purpose. `sessionPurpose: "executor"` matches the role planning already requests for skill selection (`buildSessionSkillContextSync(null, "executor", ...)`) and the purpose chat/QuickChat pass for the same reason. +*/ // eslint-disable-next-line @typescript-eslint/no-explicit-any -let createFnAgent: any = engineCreateFnAgent; +async function createPlanningRuntimeSession(options: any): Promise { + const { pluginRunner, runtimeHint, settings, ...runtimeOptions } = options ?? {}; + // Prefer the live engine binding so `ensureEngineReady()`-driven late loading + // still resolves, mirroring how this module already reaches engine helpers. + const create = (engineModule as unknown as { + createResolvedAgentSession?: typeof createResolvedAgentSession; + }).createResolvedAgentSession ?? createResolvedAgentSession; + return create({ + sessionPurpose: "executor", + ...(runtimeHint ? { runtimeHint } : {}), + ...(pluginRunner ? { pluginRunner } : {}), + ...(settings ? { settings } : {}), + ...runtimeOptions, + }); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let createFnAgent: any = createPlanningRuntimeSession; function isThinkingLevel(value: unknown): value is ThinkingLevel { return THINKING_LEVELS.includes(value as ThinkingLevel); @@ -1324,6 +1348,14 @@ async function runCreateSessionFirstTurn( const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, pluginRunner); + /* + FNXC:PlanningModelRehydration 2026-07-24-16:20: + The non-streaming start is the same surface as the streaming start and the rebuild path: + it must resolve an explicit provider/model pair instead of leaving the runtime on its + built-in default. See resolveSessionPlanningModel for why an unset pair is a live bug. + */ + const { provider: startProvider, modelId: startModelId } = await resolveSessionPlanningModel(session, store); + /* FNXC:PlanningSkills 2026-06-17-19:33: Planning sessions are agent-acting lanes with planning and workflow tools, so they must request the same executor role fallback plus enabled plugin skills (for example ce-debug) as task execution sessions. @@ -1351,6 +1383,10 @@ async function runCreateSessionFirstTurn( ...createChatTaskDocumentTools(store), createChatTaskLogsReadTool(store), ], + ...(startProvider && startModelId + ? { defaultProvider: startProvider, defaultModelId: startModelId } + : {}), + ...(pluginRunner ? { pluginRunner } : {}), onThinking: () => { // Non-streaming path ignores thinking output }, @@ -2103,6 +2139,8 @@ async function createPlanningAgent( } : {}), ...(thinkingLevel ? { defaultThinkingLevel: thinkingLevel } : {}), + // Runtime resolution needs the plugin runner to see plugin-provided runtimes. + ...(pluginRunner ? { pluginRunner } : {}), onThinking: (delta: string) => { if (callbacksInvalidated()) return; markPlanningGenerationProgress(session.id, delta); @@ -2144,6 +2182,50 @@ function buildHistoryReplayPrompt( ].join("\n\n"); } +/* +FNXC:PlanningModelRehydration 2026-07-24-16:20: +A rebuilt planning agent MUST be given the same provider/model pair the session started on. `ensureSessionAgent` previously passed `undefined, undefined` while still preserving `draftThinkingLevel`, so any rebuild — `/planning/respond`, `/planning/:id/retry`, rewind, or a draft resumed after the in-memory agent was dropped — silently discarded the model selection. `createFnAgent`/`createResolvedAgentSession` forward NO `model` when the pair is incomplete (pi.ts `createSessionWithModel`), so pi-coding-agent then picked its OWN built-in default (`anthropic/claude-opus-4-8`). For an operator on a custom provider or a CLI/subscription runtime that means the resumed turn hit `api.anthropic.com` with a raw key they never configured and died on `401 invalid x-api-key` — mid-interview, after earlier turns on the correct model had already streamed. That is the exact reported symptom: the first turns work, the resumed turn does not. + +Resolution order mirrors the start route (`register-planning-subtask-routes.ts` → `resolvePlanningSettingsModel`): the pair persisted on the draft wins, then the lane's settings-resolved pair. Both halves must be present — a half-set pair is treated as unset, matching the runtime's own pair semantics. +*/ +async function resolveSessionPlanningModel( + session: Session, + store: TaskStore, +): Promise<{ provider?: string; modelId?: string }> { + if (session.draftModelProvider && session.draftModelId) { + return { provider: session.draftModelProvider, modelId: session.draftModelId }; + } + + try { + const settings = await store.getSettings(); + const resolved = resolvePlanningSettingsModel(settings); + if (resolved.provider && resolved.modelId) { + // Cache onto the session so later rebuilds in this process stay on the + // same pair even if settings change mid-interview. + session.draftModelProvider = resolved.provider; + session.draftModelId = resolved.modelId; + return { provider: resolved.provider, modelId: resolved.modelId }; + } + } catch (err) { + diagnostics.warn("Failed to resolve planning model for rebuilt agent", { + sessionId: session.id, + operation: "resolve-session-planning-model", + error: String(err), + }); + } + + /* + No complete pair resolved. The runtime falls back to its built-in default model, + which is provider-specific and may not match the operator's configured provider — + warn loudly rather than let that surface as an opaque provider auth error. + */ + diagnostics.warn( + "Rebuilt planning agent has no resolved provider/model pair; the runtime will use its built-in default model", + { sessionId: session.id, operation: "resolve-session-planning-model" }, + ); + return {}; +} + async function ensureSessionAgent( session: Session, rootDir: string | undefined, @@ -2172,7 +2254,21 @@ async function ensureSessionAgent( ); } - session.agent = await createPlanningAgent(session, effectiveRootDir, effectiveStore, undefined, undefined, session.draftThinkingLevel, promptOverrides, session.pluginRunner); + const { provider: resumeProvider, modelId: resumeModelId } = await resolveSessionPlanningModel( + session, + effectiveStore, + ); + + session.agent = await createPlanningAgent( + session, + effectiveRootDir, + effectiveStore, + resumeProvider, + resumeModelId, + session.draftThinkingLevel, + promptOverrides, + session.pluginRunner, + ); if (historyForReplay.length === 0) { return;