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:
7
.changeset/planning-model-pair-rehydration.md
Normal file
7
.changeset/planning-model-pair-rehydration.md
Normal 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`.
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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: () => ({}),
|
||||
|
||||
@@ -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: () => ({}),
|
||||
|
||||
@@ -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: () => [],
|
||||
|
||||
@@ -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: () => ({}),
|
||||
|
||||
@@ -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: () => ({}),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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: () => ({}),
|
||||
|
||||
@@ -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: () => ({}),
|
||||
|
||||
@@ -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: () => ({}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user