fix(planning): keep the selected model pair on rebuilt planning agents

ensureSessionAgent rebuilt the agent with an empty provider/model pair while
preserving draftThinkingLevel, so resumed turns (respond, 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 non-streaming start had the same
hole. Resolve the pair from the persisted draft, then the lane's settings, on
every rebuild and start.

Also route planning through createResolvedAgentSession like chat/executor/merger
so CLI and plugin runtimes can own their own auth and planning emits
session:runtime-resolved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-24 22:17:39 -07:00
parent e01dc7d3d3
commit 1959e7c02c
12 changed files with 407 additions and 15 deletions

View File

@@ -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`.

View File

@@ -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<typeof import("@fusion/core")>();
@@ -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: [],

View File

@@ -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: () => ({}),

View File

@@ -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: () => ({}),

View File

@@ -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: () => [],

View File

@@ -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: () => ({}),

View File

@@ -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: () => ({}),

View File

@@ -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<string, unknown> = 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<typeof vi.fn>): { provider?: string; model?: string } | undefined {
const pairs = capturedPairs(factory);
return pairs[pairs.length - 1];
}
async function waitFor(predicate: () => Promise<boolean> | boolean, attempts = 50): Promise<void> {
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<typeof vi.fn>): 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<typeof vi.fn>;
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 });
});
});

View File

@@ -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: () => ({}),

View File

@@ -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: () => ({}),

View File

@@ -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: () => ({}),

View File

@@ -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<AgentResult> {
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;