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";
|
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:
|
FNXC:DashboardTests 2026-07-18-12:20:
|
||||||
Planning defaults clarificationEnabled=false, so createSession forces a summary after the
|
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
|
complete payload so MCP-forwarding assertions exercise the default product path instead of
|
||||||
throwing Clarification-disabled follow-up did not produce a summary.
|
throwing Clarification-disabled follow-up did not produce a summary.
|
||||||
*/
|
*/
|
||||||
createFnAgentMock: vi.fn(async () => ({
|
const makeScriptedAgent = () => ({
|
||||||
session: {
|
session: {
|
||||||
state: { messages: [] as Array<{ role: string; content: string }> },
|
state: { messages: [] as Array<{ role: string; content: string }> },
|
||||||
prompt: vi.fn(async function (this: { state: { messages: Array<{ role: string; content: string }> } }, _message: 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(),
|
dispose: vi.fn(),
|
||||||
},
|
},
|
||||||
})),
|
});
|
||||||
resolveMcpServersForStoreMock: vi.fn(async () => ({
|
return {
|
||||||
servers: [{ name: "docs", transport: "stdio", command: "node", env: { TOKEN: "materialized-secret" } }],
|
createFnAgentMock: vi.fn(makeScriptedAgent),
|
||||||
errors: [],
|
/*
|
||||||
})),
|
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) => {
|
vi.mock("@fusion/core", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("@fusion/core")>();
|
const actual = await importOriginal<typeof import("@fusion/core")>();
|
||||||
@@ -68,6 +79,7 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
|||||||
createChatTaskDocumentTools: vi.fn(() => []),
|
createChatTaskDocumentTools: vi.fn(() => []),
|
||||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||||
createFnAgent: createFnAgentMock,
|
createFnAgent: createFnAgentMock,
|
||||||
|
createResolvedAgentSession: createResolvedAgentSessionMock,
|
||||||
resolveMcpServersForStore: resolveMcpServersForStoreMock,
|
resolveMcpServersForStore: resolveMcpServersForStoreMock,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -99,16 +111,17 @@ describe("dashboard MCP lane forwarding", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
__resetPlanningState();
|
__resetPlanningState();
|
||||||
createFnAgentMock.mockClear();
|
createFnAgentMock.mockClear();
|
||||||
|
createResolvedAgentSessionMock.mockClear();
|
||||||
resolveMcpServersForStoreMock.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();
|
const store = makePlanningStore();
|
||||||
|
|
||||||
await createSession("127.0.0.1", "Build a feature", store, "/tmp/fusion-dashboard-test");
|
await createSession("127.0.0.1", "Build a feature", store, "/tmp/fusion-dashboard-test");
|
||||||
|
|
||||||
expect(resolveMcpServersForStoreMock).toHaveBeenCalledWith(store);
|
expect(resolveMcpServersForStoreMock).toHaveBeenCalledWith(store);
|
||||||
expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({
|
expect(createResolvedAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
cwd: "/tmp/fusion-dashboard-test",
|
cwd: "/tmp/fusion-dashboard-test",
|
||||||
tools: "readonly",
|
tools: "readonly",
|
||||||
allowMcpToolsInReadonly: true,
|
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");
|
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",
|
tools: "readonly",
|
||||||
allowMcpToolsInReadonly: true,
|
allowMcpToolsInReadonly: true,
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
@@ -142,7 +155,7 @@ describe("dashboard MCP lane forwarding", () => {
|
|||||||
expect(startInitialTurn).toBeTypeOf("function");
|
expect(startInitialTurn).toBeTypeOf("function");
|
||||||
startInitialTurn?.();
|
startInitialTurn?.();
|
||||||
|
|
||||||
await vi.waitFor(() => expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({
|
await vi.waitFor(() => expect(createResolvedAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
tools: "readonly",
|
tools: "readonly",
|
||||||
allowMcpToolsInReadonly: true,
|
allowMcpToolsInReadonly: true,
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ vi.mock("@fusion/engine", () => ({
|
|||||||
skillSource: "role-fallback" as const,
|
skillSource: "role-fallback" as const,
|
||||||
}),
|
}),
|
||||||
createFnAgent: vi.fn(),
|
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: () => [],
|
createWorkflowAuthoringTools: () => [],
|
||||||
createChatTaskDocumentTools: () => [],
|
createChatTaskDocumentTools: () => [],
|
||||||
createChatTaskLogsReadTool: () => ({}),
|
createChatTaskLogsReadTool: () => ({}),
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ vi.mock("@fusion/engine", () => ({
|
|||||||
skillSource: "role-fallback" as const,
|
skillSource: "role-fallback" as const,
|
||||||
}),
|
}),
|
||||||
createFnAgent: vi.fn(),
|
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: () => [],
|
createWorkflowAuthoringTools: () => [],
|
||||||
createChatTaskDocumentTools: () => [],
|
createChatTaskDocumentTools: () => [],
|
||||||
createChatTaskLogsReadTool: () => ({}),
|
createChatTaskLogsReadTool: () => ({}),
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ vi.mock("@fusion/engine", () => ({
|
|||||||
resolveMcpServersForStore: async () => ({ servers: [] }),
|
resolveMcpServersForStore: async () => ({ servers: [] }),
|
||||||
buildSessionSkillContextSync: () => ({ skillSelectionContext: undefined, resolvedSkillNames: [], skillSource: "role-fallback" as const }),
|
buildSessionSkillContextSync: () => ({ skillSelectionContext: undefined, resolvedSkillNames: [], skillSource: "role-fallback" as const }),
|
||||||
createFnAgent: vi.fn(),
|
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(),
|
createAgentTask: vi.fn(),
|
||||||
createWorkflowAuthoringTools: () => [],
|
createWorkflowAuthoringTools: () => [],
|
||||||
createChatTaskDocumentTools: () => [],
|
createChatTaskDocumentTools: () => [],
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ vi.mock("@fusion/engine", () => ({
|
|||||||
skillSource: "role-fallback" as const,
|
skillSource: "role-fallback" as const,
|
||||||
}),
|
}),
|
||||||
createFnAgent: vi.fn(),
|
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: () => [],
|
createWorkflowAuthoringTools: () => [],
|
||||||
createChatTaskDocumentTools: () => [],
|
createChatTaskDocumentTools: () => [],
|
||||||
createChatTaskLogsReadTool: () => ({}),
|
createChatTaskLogsReadTool: () => ({}),
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ vi.mock("@fusion/engine", () => ({
|
|||||||
resolveMcpServersForStore: async () => ({ servers: [] }),
|
resolveMcpServersForStore: async () => ({ servers: [] }),
|
||||||
buildSessionSkillContextSync: () => ({ skillSelectionContext: undefined, resolvedSkillNames: [], skillSource: "role-fallback" as const }),
|
buildSessionSkillContextSync: () => ({ skillSelectionContext: undefined, resolvedSkillNames: [], skillSource: "role-fallback" as const }),
|
||||||
createFnAgent: vi.fn(),
|
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: () => [],
|
createWorkflowAuthoringTools: () => [],
|
||||||
createChatTaskDocumentTools: () => [],
|
createChatTaskDocumentTools: () => [],
|
||||||
createChatTaskLogsReadTool: () => ({}),
|
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,
|
skillSource: "role-fallback" as const,
|
||||||
}),
|
}),
|
||||||
createFnAgent: vi.fn(),
|
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: () => [],
|
createWorkflowAuthoringTools: () => [],
|
||||||
createChatTaskDocumentTools: () => [],
|
createChatTaskDocumentTools: () => [],
|
||||||
createChatTaskLogsReadTool: () => ({}),
|
createChatTaskLogsReadTool: () => ({}),
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ vi.mock("@fusion/engine", () => ({
|
|||||||
skillSource: "role-fallback" as const,
|
skillSource: "role-fallback" as const,
|
||||||
}),
|
}),
|
||||||
createFnAgent: vi.fn(),
|
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: () => [],
|
createWorkflowAuthoringTools: () => [],
|
||||||
createChatTaskDocumentTools: () => [],
|
createChatTaskDocumentTools: () => [],
|
||||||
createChatTaskLogsReadTool: () => ({}),
|
createChatTaskLogsReadTool: () => ({}),
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ vi.mock("@fusion/engine", () => ({
|
|||||||
skillSource: "role-fallback" as const,
|
skillSource: "role-fallback" as const,
|
||||||
}),
|
}),
|
||||||
createFnAgent: vi.fn(),
|
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: () => [],
|
createWorkflowAuthoringTools: () => [],
|
||||||
createChatTaskDocumentTools: () => [],
|
createChatTaskDocumentTools: () => [],
|
||||||
createChatTaskLogsReadTool: () => ({}),
|
createChatTaskLogsReadTool: () => ({}),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
TASK_PRIORITIES,
|
TASK_PRIORITIES,
|
||||||
THINKING_LEVELS,
|
THINKING_LEVELS,
|
||||||
formatPlanningPlanMd,
|
formatPlanningPlanMd,
|
||||||
|
resolvePlanningSettingsModel,
|
||||||
summarizeTitle,
|
summarizeTitle,
|
||||||
type PromptOverrideMap,
|
type PromptOverrideMap,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
@@ -47,7 +48,7 @@ import {
|
|||||||
buildSessionSkillContextSync,
|
buildSessionSkillContextSync,
|
||||||
createChatTaskDocumentTools,
|
createChatTaskDocumentTools,
|
||||||
createChatTaskLogsReadTool,
|
createChatTaskLogsReadTool,
|
||||||
createFnAgent as engineCreateFnAgent,
|
createResolvedAgentSession,
|
||||||
createWorkflowAuthoringTools,
|
createWorkflowAuthoringTools,
|
||||||
resolveMcpServersForStore,
|
resolveMcpServersForStore,
|
||||||
} from "@fusion/engine";
|
} from "@fusion/engine";
|
||||||
@@ -81,8 +82,31 @@ type PlanningSessionOptions = {
|
|||||||
messageStore?: MessageStore;
|
messageStore?: MessageStore;
|
||||||
pluginRunner?: SkillPluginRunner;
|
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
|
// 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 {
|
function isThinkingLevel(value: unknown): value is ThinkingLevel {
|
||||||
return THINKING_LEVELS.includes(value as ThinkingLevel);
|
return THINKING_LEVELS.includes(value as ThinkingLevel);
|
||||||
@@ -1324,6 +1348,14 @@ async function runCreateSessionFirstTurn(
|
|||||||
|
|
||||||
const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, pluginRunner);
|
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:
|
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.
|
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),
|
...createChatTaskDocumentTools(store),
|
||||||
createChatTaskLogsReadTool(store),
|
createChatTaskLogsReadTool(store),
|
||||||
],
|
],
|
||||||
|
...(startProvider && startModelId
|
||||||
|
? { defaultProvider: startProvider, defaultModelId: startModelId }
|
||||||
|
: {}),
|
||||||
|
...(pluginRunner ? { pluginRunner } : {}),
|
||||||
onThinking: () => {
|
onThinking: () => {
|
||||||
// Non-streaming path ignores thinking output
|
// Non-streaming path ignores thinking output
|
||||||
},
|
},
|
||||||
@@ -2103,6 +2139,8 @@ async function createPlanningAgent(
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(thinkingLevel ? { defaultThinkingLevel: thinkingLevel } : {}),
|
...(thinkingLevel ? { defaultThinkingLevel: thinkingLevel } : {}),
|
||||||
|
// Runtime resolution needs the plugin runner to see plugin-provided runtimes.
|
||||||
|
...(pluginRunner ? { pluginRunner } : {}),
|
||||||
onThinking: (delta: string) => {
|
onThinking: (delta: string) => {
|
||||||
if (callbacksInvalidated()) return;
|
if (callbacksInvalidated()) return;
|
||||||
markPlanningGenerationProgress(session.id, delta);
|
markPlanningGenerationProgress(session.id, delta);
|
||||||
@@ -2144,6 +2182,50 @@ function buildHistoryReplayPrompt(
|
|||||||
].join("\n\n");
|
].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(
|
async function ensureSessionAgent(
|
||||||
session: Session,
|
session: Session,
|
||||||
rootDir: string | undefined,
|
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) {
|
if (historyForReplay.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user