diff --git a/.changeset/mission-interview-cli-runtime-routing.md b/.changeset/mission-interview-cli-runtime-routing.md new file mode 100644 index 0000000000..445625e8e1 --- /dev/null +++ b/.changeset/mission-interview-cli-runtime-routing.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Route every AI lane through runtime resolution so CLI-runtime models (e.g. Cursor CLI) work everywhere chat does. +category: fix +dev: `createFnAgent` now delegates to `createResolvedAgentSession` (CLI runtime hint derivation, mock forcing, runtime-resolved visibility) with a host-registered default PluginRunner per project root; `DefaultPiRuntime` re-enters via a `__rawPiSession` marker into `createPiAgentSessionRaw`. Mission and milestone/slice interviews also pass their request-scoped pluginRunner and prompt via the engine `promptWithFallback` dispatcher, fixing "cursor-cli/auto ... not found in the pi model registry" in mission planning. diff --git a/packages/dashboard/src/__tests__/interview-question-regeneration.test.ts b/packages/dashboard/src/__tests__/interview-question-regeneration.test.ts index e3b4534821..f1796b388b 100644 --- a/packages/dashboard/src/__tests__/interview-question-regeneration.test.ts +++ b/packages/dashboard/src/__tests__/interview-question-regeneration.test.ts @@ -10,13 +10,19 @@ fresh question instead of throwing "No active question in session". A completed import { beforeEach, describe, expect, it, vi } from "vitest"; +/* +FNXC:MissionInterviewRuntimeResolution 2026-08-16-14:37: +Mission and milestone/slice interviews now build sessions through the shared +`createResolvedAgentSession` seam (so CLI-runtime model selections route to their +runtime plugins); mock that seam with the same scripted session factory. +*/ vi.mock("@fusion/engine", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, buildSessionSkillContextSync: vi.fn(() => ({ skillSelectionContext: undefined })), resolveMcpServersForStore: vi.fn(async () => ({ servers: [] })), - createFnAgent: vi.fn(async () => ({ + createResolvedAgentSession: vi.fn(async () => ({ session: { state: { messages: [] as Array<{ role: string; content: string }> }, prompt: vi.fn(async function (this: { state: { messages: Array<{ role: string; content: string }> } }) { diff --git a/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts b/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts index d1c82b071e..e8d994050f 100644 --- a/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts +++ b/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts @@ -49,8 +49,10 @@ const { createFnAgentMock, createResolvedAgentSessionMock, resolveMcpServersForS 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. + contract for the planning lanes is asserted on that seam. + FNXC:MissionInterviewRuntimeResolution 2026-08-16-14:37: the mission and milestone/slice + interview lanes now build through the same seam so CLI-runtime model selections + (cursor-cli etc.) route to their runtime plugins; their assertions target it too. */ createResolvedAgentSessionMock: vi.fn(makeScriptedAgent), resolveMcpServersForStoreMock: vi.fn(async () => ({ @@ -184,7 +186,7 @@ describe("dashboard MCP lane forwarding", () => { await createMissionInterviewAgent(session, "/tmp/fusion-dashboard-test", store); expect(resolveMcpServersForStoreMock).toHaveBeenCalledWith(store); - expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ + expect(createResolvedAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/tmp/fusion-dashboard-test", tools: "readonly", allowMcpToolsInReadonly: true, @@ -197,7 +199,7 @@ describe("dashboard MCP lane forwarding", () => { await createMissionInterviewAgent({ id: "mission-empty", thinkingOutput: "" } as never, "/tmp/fusion-dashboard-test", {} as never); - expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); + expect(createResolvedAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); }); it("forwards materialized MCP servers to milestone and slice interview agents", async () => { @@ -207,7 +209,7 @@ describe("dashboard MCP lane forwarding", () => { await createTargetInterviewAgent(session, "/tmp/fusion-dashboard-test", store); expect(resolveMcpServersForStoreMock).toHaveBeenCalledWith(store); - expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ + expect(createResolvedAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/tmp/fusion-dashboard-test", tools: "readonly", allowMcpToolsInReadonly: true, @@ -220,6 +222,6 @@ describe("dashboard MCP lane forwarding", () => { await createTargetInterviewAgent({ id: "target-empty", targetType: "slice", thinkingOutput: "" } as never, "/tmp/fusion-dashboard-test", {} as never); - expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); + expect(createResolvedAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); }); }); diff --git a/packages/dashboard/src/milestone-slice-interview.ts b/packages/dashboard/src/milestone-slice-interview.ts index 40238635db..e7053c5370 100644 --- a/packages/dashboard/src/milestone-slice-interview.ts +++ b/packages/dashboard/src/milestone-slice-interview.ts @@ -104,15 +104,28 @@ function parseTargetInterviewResponseImpl(text: string): TargetInterviewResponse // Export the parse function for tests export { parseTargetInterviewResponseImpl as parseTargetInterviewResponse }; -import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine"; +import { buildSessionSkillContextSync, createResolvedAgentSession, promptWithFallback as enginePromptWithFallback, resolveMcpServersForStore } from "@fusion/engine"; import { createPlanningBoardTools } from "./planning-board-tools.js"; import { laneModelOptions, resolveLaneSessionModel } from "./lane-session-model.js"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AgentResult = any; type SkillSelectionPluginRunner = Parameters[3]; +/* +FNXC:MissionInterviewRuntimeResolution 2026-08-16-14:37: +Milestone/slice interviews must use the shared `createResolvedAgentSession` seam (like chat, Planning Mode, and the mission interview) instead of bare `createFnAgent`, which pins the session to the default pi runtime. Without the seam, a CLI-runtime model selection (cursor-cli, claude-cli, hermes, omp-cli, no-key grok-cli) failed with "not found in the pi model registry" while chat on the same model worked. +*/ // eslint-disable-next-line @typescript-eslint/no-explicit-any -const createFnAgent: any = engineCreateFnAgent; +const createFnAgent: any = async (options: any): Promise => { + const { pluginRunner, runtimeHint, settings, ...runtimeOptions } = options ?? {}; + return createResolvedAgentSession({ + sessionPurpose: "executor", + ...(runtimeHint ? { runtimeHint } : {}), + ...(pluginRunner ? { pluginRunner } : {}), + ...(settings ? { settings } : {}), + ...runtimeOptions, + }); +}; function ensureEngineReady(): Promise { return Promise.resolve(); @@ -848,6 +861,8 @@ export async function createTargetInterviewAgent( mcpServers, ...laneModelOptions(model), allowMcpToolsInReadonly: true, + // FNXC:MissionInterviewRuntimeResolution 2026-08-16-14:37: forward the plugin runner so the shared seam can route CLI-runtime model selections to their runtime plugins. + ...(pluginRunner ? { pluginRunner } : {}), customTools: [...createPlanningBoardTools(store)], /* FNXC:InterviewSkills 2026-06-17-21:42: @@ -990,7 +1005,9 @@ async function ensureInterviewAgent( if (abortSignal.aborted) { throw createAbortError(); } - await session.agent!.session.prompt( + // FNXC:MissionInterviewRuntimeResolution 2026-08-16-14:37: prompt through the engine dispatcher — plugin CLI runtime sessions (cursor/grok/droid) have no session.prompt(); the shared seam bound the runtime's promptWithFallback onto the session and this delegates to it. + await enginePromptWithFallback( + session.agent!.session, [ "Previous conversation summary:", historySummary, @@ -1074,7 +1091,7 @@ async function continueAgentConversation(session: TargetInterviewSession, messag if (abortSignal.aborted) { throw createAbortError(); } - await agent.session.prompt(message, { signal: abortSignal }); + await enginePromptWithFallback(agent.session, message, { signal: abortSignal }); if (abortSignal.aborted) { throw createAbortError(); } @@ -1121,7 +1138,8 @@ async function continueAgentConversation(session: TargetInterviewSession, messag if (abortSignal.aborted) { throw createAbortError(); } - await agent.session.prompt( + await enginePromptWithFallback( + agent.session, "Your previous response could not be parsed as JSON. " + 'Please respond with ONLY a valid JSON object: either {"type":"question","data":{...}} ' + 'or {"type":"complete","data":{"title":"...","description":"...","planningNotes":"...","verification":"..."}}' + diff --git a/packages/dashboard/src/mission-interview.ts b/packages/dashboard/src/mission-interview.ts index fed1e770c2..baffd56e5c 100644 --- a/packages/dashboard/src/mission-interview.ts +++ b/packages/dashboard/src/mission-interview.ts @@ -29,15 +29,28 @@ import { } from "./ai-session-diagnostics.js"; import { createAbortError, GenerationGuard, isAbortError } from "./ai-session-timeout.js"; -import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine"; +import { buildSessionSkillContextSync, createResolvedAgentSession, promptWithFallback as enginePromptWithFallback, resolveMcpServersForStore } from "@fusion/engine"; import { createPlanningBoardTools } from "./planning-board-tools.js"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AgentResult = any; type SkillPluginRunner = Parameters[3]; const MISSION_INTERVIEW_BUILTIN_WEB_TOOLS = ["WebSearch", "WebFetch"] as const; +/* +FNXC:MissionInterviewRuntimeResolution 2026-08-16-14:37: +Mission interview sessions must go through the shared `createResolvedAgentSession` seam — the same one chat, Planning Mode (createPlanningRuntimeSession), executor, and reviewer use — NOT a bare `createFnAgent` call. Bare `createFnAgent` pins the session to the default pi runtime, so a CLI-runtime model selection (cursor-cli, claude-cli, hermes, omp-cli, no-key grok-cli) could never route to its runtime plugin and died with "Configured model cursor-cli/auto ... was not found in the pi model registry" while chat on the same model worked. The seam derives the CLI runtime hint from the selected provider and emits `session:runtime-resolved` run-audit visibility. +*/ // eslint-disable-next-line @typescript-eslint/no-explicit-any -const createFnAgent: any = engineCreateFnAgent; +const createFnAgent: any = async (options: any): Promise => { + const { pluginRunner, runtimeHint, settings, ...runtimeOptions } = options ?? {}; + return createResolvedAgentSession({ + sessionPurpose: "executor", + ...(runtimeHint ? { runtimeHint } : {}), + ...(pluginRunner ? { pluginRunner } : {}), + ...(settings ? { settings } : {}), + ...runtimeOptions, + }); +}; /** * Shared diagnostics helper for the mission-interview module. @@ -942,6 +955,8 @@ export async function createMissionInterviewAgent( tools: "readonly", mcpServers, allowMcpToolsInReadonly: true, + // FNXC:MissionInterviewRuntimeResolution 2026-08-16-14:37: forward the plugin runner so the shared seam can route CLI-runtime model selections (cursor/claude/grok/omp/hermes) to their runtime plugins. + ...(pluginRunner ? { pluginRunner } : {}), ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), builtinToolsAllowlist: [...MISSION_INTERVIEW_BUILTIN_WEB_TOOLS], customTools: [...createPlanningBoardTools(store)], @@ -1094,7 +1109,9 @@ async function ensureMissionInterviewAgent( if (abortSignal.aborted) { throw createAbortError(); } - await session.agent!.session.prompt( + // FNXC:MissionInterviewRuntimeResolution 2026-08-16-14:37: prompt through the engine dispatcher — plugin CLI runtime sessions (cursor/grok/droid) have no session.prompt(); the shared seam bound the runtime's promptWithFallback onto the session and this delegates to it. + await enginePromptWithFallback( + session.agent!.session, [ "Previous conversation summary:", historySummary, @@ -1167,7 +1184,7 @@ async function continueAgentConversation(session: MissionInterviewSession, messa if (abortSignal.aborted) { throw createAbortError(); } - await agent.session.prompt(message, { signal: abortSignal }); + await enginePromptWithFallback(agent.session, message, { signal: abortSignal }); if (abortSignal.aborted) { throw createAbortError(); } @@ -1214,7 +1231,8 @@ async function continueAgentConversation(session: MissionInterviewSession, messa if (abortSignal.aborted) { throw createAbortError(); } - await agent.session.prompt( + await enginePromptWithFallback( + agent.session, "Your previous response could not be parsed as JSON. " + 'Please respond with ONLY a valid JSON object: either {"type":"question","data":{...}} ' + 'or {"type":"complete","data":{"missionTitle":"...","missionDescription":"...","milestones":[...]}}. ' + diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index 45cd360749..15c0746e47 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -1333,6 +1333,15 @@ describe("wrapToolsWithActionGate", () => { }); }); +/* +FNXC:CliRuntimeRouting 2026-08-16-14:37: +This file pins the RAW pi session constructor's behavior (auth, principals, +skills, registry resolution, tool wrapping). The public `createFnAgent` now +routes through createResolvedAgentSession, so every direct call here aliases +`createPiAgentSessionRaw` — the function DefaultPiRuntime bridges into — to +keep asserting pi internals rather than the routing seam (covered by +agent-session-helpers/runtime-resolution tests). +*/ describe("createFnAgent", () => { beforeEach(() => { vi.clearAllMocks(); @@ -1370,7 +1379,7 @@ describe("createFnAgent", () => { }); it("binds a durable chat principal to the host-tool prompt invocation when pi omits agentId", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); const { __clearFusionSessionIdentityRegistryForTests, resolveFusionSessionPrincipal, @@ -1430,7 +1439,7 @@ describe("createFnAgent", () => { }); it("reaches the pi host-tool identity wrapper from a durable dashboard chat session", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); const { __clearFusionSessionIdentityRegistryForTests, resolveFusionSessionPrincipal, @@ -1514,7 +1523,7 @@ describe("createFnAgent", () => { }); it("assigns distinct fail-closed principals to concurrent anonymous engine sessions", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); const { __clearFusionSessionIdentityRegistryForTests, resolveFusionSessionPrincipal, @@ -1558,7 +1567,7 @@ describe("createFnAgent", () => { FN-7956 hung AI merge review on extension fn_task_show (second TaskStore boot, no tool timeout). Merger sessions must not receive host @runfusion/fusion extension paths even with tools:coding. */ - const { createFnAgent, setHostExtensionPaths } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent, setHostExtensionPaths } = await import("../pi.js"); setHostExtensionPaths(["/mock/fusion-extension"]); await createFnAgent({ @@ -1578,7 +1587,7 @@ describe("createFnAgent", () => { }); it("still injects host extensions for coding non-merger sessions", async () => { - const { createFnAgent, setHostExtensionPaths } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent, setHostExtensionPaths } = await import("../pi.js"); setHostExtensionPaths(["/mock/fusion-extension"]); await createFnAgent({ @@ -1597,7 +1606,7 @@ describe("createFnAgent", () => { }); it("passes task-scoped env into bash spawn hook when provided", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/project", @@ -1636,7 +1645,7 @@ describe("createFnAgent", () => { }); it("keeps bash tool default behavior when taskEnv is not provided", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/project", @@ -1648,7 +1657,7 @@ describe("createFnAgent", () => { }); it("keeps spawned env unchanged when taskEnv is empty", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/project", @@ -1665,7 +1674,7 @@ describe("createFnAgent", () => { }); it("adds new task env keys absent from spawned env", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/project", @@ -1681,7 +1690,7 @@ describe("createFnAgent", () => { }); it("preserves undefined task env values explicitly in merged env", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/project", @@ -1697,7 +1706,7 @@ describe("createFnAgent", () => { }); it("injects PATH from task env when spawned env has no PATH", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/project", @@ -1725,7 +1734,7 @@ describe("createFnAgent", () => { return "worktree /project\nHEAD abc123\nbranch refs/heads/main\n"; }); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await expect(createFnAgent({ cwd: "/project/.worktrees/fn-001", @@ -1752,7 +1761,7 @@ describe("createFnAgent", () => { "worktree /project/.worktrees/fn-001\nHEAD def456\nbranch refs/heads/fusion/fn-001\n"; }); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/project/.worktrees/fn-001", @@ -1779,7 +1788,7 @@ describe("createFnAgent", () => { "worktree /project/.worktrees/task-branch\nHEAD def456\nbranch refs/heads/fusion/fn-001\n"; }); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); // Pass skills parameter with a worktree cwd. // getProjectRootFromWorktree extracts /project from the .worktrees path, @@ -1807,7 +1816,7 @@ describe("createFnAgent", () => { return value === "/project/.fusion"; }); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/project/src/components", @@ -1826,7 +1835,7 @@ describe("createFnAgent", () => { // No .fusion directory exists anywhere above cwd. existsSyncMock.mockImplementation(() => false); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/unrelated/directory", @@ -1850,7 +1859,7 @@ describe("createFnAgent", () => { }); it("passes a project-trusted settings view through package-manager discovery", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -1882,7 +1891,7 @@ describe("createFnAgent", () => { errors: [], }); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -1939,7 +1948,7 @@ describe("createFnAgent", () => { }, ] as any); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -2034,7 +2043,7 @@ describe("createFnAgent", () => { }, ] as any); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -2069,7 +2078,7 @@ describe("createFnAgent", () => { }); it("avoids lock-based SettingsManager.create when loading extension providers", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -2090,7 +2099,7 @@ describe("createFnAgent", () => { provider === "zai" && modelId === "glm-5.1" ? undefined : { provider, id: modelId } )); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await expect(createFnAgent({ cwd: "/tmp", @@ -2116,7 +2125,7 @@ describe("createFnAgent", () => { return { provider, id: modelId }; }); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -2139,7 +2148,7 @@ describe("createFnAgent", () => { provider === "openai-codex" && modelId === "missing-model" ? undefined : { provider, id: modelId } )); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await expect(createFnAgent({ cwd: "/tmp", @@ -2170,7 +2179,7 @@ describe("createFnAgent", () => { )); getAllMock.mockReturnValue([]); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await expect(createFnAgent({ cwd: "/tmp", @@ -2209,7 +2218,7 @@ describe("createFnAgent", () => { )); getAllMock.mockReturnValue([{ provider: "grok-cli", id: "grok-4.5", name: "Grok 4.5" }]); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -2226,7 +2235,7 @@ describe("createFnAgent", () => { }); it("creates a session when configured models resolve successfully", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -2249,7 +2258,7 @@ describe("createFnAgent", () => { getAllMock.mockReturnValue([]); findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -2282,7 +2291,7 @@ describe("createFnAgent", () => { ]); findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -2301,7 +2310,7 @@ describe("createFnAgent", () => { getAllMock.mockReturnValue([]); findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -2336,7 +2345,7 @@ describe("createFnAgent", () => { getAllMock.mockReturnValue([existingLunaRow]); findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -2372,7 +2381,7 @@ describe("createFnAgent", () => { getAllMock.mockReturnValue([{ provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8" }]); findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -2400,7 +2409,7 @@ describe("createFnAgent", () => { ? { provider, id: modelId } : undefined); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await expect(createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -2419,7 +2428,7 @@ describe("createFnAgent", () => { authStorageGetApiKeyMock.mockResolvedValue(undefined); findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -2439,7 +2448,7 @@ describe("createFnAgent", () => { provider === "anthropic" ? "sk-ant-api03-direct" : undefined )); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -2470,7 +2479,7 @@ describe("createFnAgent", () => { }); getAllMock.mockReturnValue([{ provider: "anthropic", id: "claude-opus-4-8" }]); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -2493,7 +2502,7 @@ describe("createFnAgent", () => { }; createAgentSessionMock.mockResolvedValueOnce({ session }); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); const result = await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -2522,7 +2531,7 @@ describe("createFnAgent", () => { execute: vi.fn(), }; - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -2543,7 +2552,7 @@ describe("createFnAgent", () => { execute: vi.fn(), }; - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -2556,7 +2565,7 @@ describe("createFnAgent", () => { }); it("does not allow extra builtin tools in readonly sessions by default", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -2569,7 +2578,7 @@ describe("createFnAgent", () => { }); it("intersects readonly builtin allowlist with readonly policy", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -2590,7 +2599,7 @@ describe("createFnAgent", () => { }); it("filters coding tools with a case-insensitive toolsAllowlist", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -2605,7 +2614,7 @@ describe("createFnAgent", () => { }); it("keeps all coding tools when toolsAllowlist is undefined", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -2619,7 +2628,7 @@ describe("createFnAgent", () => { }); it("exposes no coding tools when toolsAllowlist is empty", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -2643,7 +2652,7 @@ describe("createFnAgent", () => { execute: vi.fn(), }; - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -2659,7 +2668,7 @@ describe("createFnAgent", () => { const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] }); const permanentCreateApproval = vi.fn(); const markApprovalCompleted = vi.fn(); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -2700,7 +2709,7 @@ describe("createFnAgent", () => { }); it("exposes connected MCP tools in readonly sessions only with the explicit opt-in", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); const close = vi.fn(async () => undefined); const mcpClient = { connect: vi.fn(async () => undefined), @@ -2764,7 +2773,7 @@ describe("createFnAgent", () => { clients.push(client); return client; }); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); try { await expect(createFnAgent({ @@ -2794,7 +2803,7 @@ describe("createFnAgent", () => { }); it("keeps MCP tools out of readonly sessions without the explicit opt-in", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); const mcpClient = { connect: vi.fn(async () => undefined), listTools: vi.fn(async () => ({ tools: [{ name: "lookup" }] })), @@ -2821,7 +2830,7 @@ describe("createFnAgent", () => { const { piLog } = await import("../logger.js"); // FNXC:EngineDiagnostics 2026-07-26-09:55: entry chatter is debug-gated. const debugSpy = vi.spyOn(piLog, "debug").mockImplementation(() => {}); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp/private-worktree", @@ -2874,7 +2883,7 @@ describe("createFnAgent", () => { }, }); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); const { __clearFusionSessionIdentityRegistryForTests } = await import("@fusion/core"); __clearFusionSessionIdentityRegistryForTests(); @@ -2942,7 +2951,7 @@ describe("createFnAgent", () => { }, }); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); const { session } = await createFnAgent({ cwd: "/tmp", @@ -2965,7 +2974,7 @@ describe("createFnAgent", () => { }); it("enables auto-compaction to prevent context-window overflow", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -2982,7 +2991,7 @@ describe("createFnAgent", () => { }); it("passes compaction enabled alongside retry settings", async () => { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", @@ -3013,7 +3022,7 @@ describe("createFnAgent", () => { }; createAgentSessionMock.mockResolvedValueOnce({ session }); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); const { session: guardedSession } = await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -3044,7 +3053,7 @@ describe("createFnAgent", () => { _rewriteFile: rewriteFile, }; - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -3090,7 +3099,7 @@ describe("createFnAgent", () => { }; createAgentSessionMock.mockResolvedValueOnce({ session }); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -3134,7 +3143,7 @@ describe("createFnAgent", () => { }, }); - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await expect(createFnAgent({ cwd: "/tmp", @@ -3164,7 +3173,7 @@ describe("createFnAgent", () => { const anyModel = { provider: "anthropic", id: "claude" } as never; async function createAndCaptureRuntime(overrides: Record = {}) { - const { createFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js"); await createFnAgent({ cwd: "/tmp", systemPrompt: "test", @@ -3283,7 +3292,7 @@ describe("createFnAgent", () => { }, })); - const { createFnAgent: freshCreateFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: freshCreateFnAgent } = await import("../pi.js"); await freshCreateFnAgent({ cwd: "/tmp", @@ -3374,7 +3383,7 @@ describe("createFnAgent", () => { }, })); - const { createFnAgent: freshCreateFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: freshCreateFnAgent } = await import("../pi.js"); await freshCreateFnAgent({ cwd: "/tmp", @@ -3396,7 +3405,15 @@ describe("createFnAgent", () => { } }); - it("with skillSelection (specific requested names) activates skill filtering", async () => { + /* + FNXC:SkillResolution 2026-08-16-15:12: + FN-9114 changed requested skill names from an allow-list to ensure-present: + all enabled discovered skills stay available, and requested names only + guarantee inclusion (agent metadata is no longer an availability filter). + This test previously asserted the pre-FN-9114 allow-list narrowing and was + the one call-site-level assertion missed by that commit's test sweep. + */ + it("with skillSelection (specific requested names) keeps all discovered skills and ensures requested ones are present", async () => { let capturedResourceLoaderOptions: any; vi.doMock("@earendil-works/pi-coding-agent", () => ({ LegacyCredentialStorage: { @@ -3462,7 +3479,7 @@ describe("createFnAgent", () => { }, })); - const { createFnAgent: freshCreateFnAgent } = await import("../pi.js"); + const { createPiAgentSessionRaw: freshCreateFnAgent } = await import("../pi.js"); await freshCreateFnAgent({ cwd: "/tmp", @@ -3487,9 +3504,12 @@ describe("createFnAgent", () => { diagnostics: [], }); - // Only paperclip should pass through (matching requested name) - expect(result.skills).toHaveLength(1); - expect(result.skills[0].name).toBe("paperclip"); + // FN-9114: requested names are ensure-present, not an allow-list — every + // discovered skill stays available and the requested one is included. + expect(result.skills).toHaveLength(2); + expect(result.skills.map((skill: { name: string }) => skill.name)).toEqual( + expect.arrayContaining(["paperclip", "lint"]), + ); }); it("diagnostics are logged via structured logger with [skills] context", async () => { diff --git a/packages/engine/src/agents/agent-session-helpers.ts b/packages/engine/src/agents/agent-session-helpers.ts index 57ff4461ac..eb6b4d3b8d 100644 --- a/packages/engine/src/agents/agent-session-helpers.ts +++ b/packages/engine/src/agents/agent-session-helpers.ts @@ -48,6 +48,7 @@ import { wrapToolsWithRtkRewrite, type FallbackModelUsedPayload, } from "../pi.js"; +import * as piModuleForRegistration from "../pi.js"; import type { RunAuditor } from "../util/run-audit.js"; import { createFusionAuthStorage, resolveCredentialInstanceRef, type FusionAuthStorage } from "../auth/auth-storage.js"; import { MockAgentRuntime } from "../providers/mock-provider.js"; @@ -1180,3 +1181,28 @@ export async function promptWithAutoRetry( export async function describeAgentModel(session: AgentSession): Promise { return describeModel(session); } + +/* +FNXC:CliRuntimeRouting 2026-08-16-14:37: +Register this module's createResolvedAgentSession as pi.ts's routed session +factory so bare `createFnAgent` callers get CLI runtime routing (cursor/claude/ +grok/omp/hermes), mock forcing, and runtime-resolved visibility without every +lane importing the seam. Late-binding registration (mirrors core's +setCreateFnAgent) instead of a static pi.ts -> agent-session-helpers import, +which would create an eval-order-sensitive cycle (this module and +runtime-resolution both import pi.ts). Runs once at module load; any host that +loads @fusion/engine's index (all real hosts) gets it. + +Namespace access + try/catch on purpose: dozens of engine tests replace +"../pi.js" with pure vi.mock factories that predate this export, and vitest's +mock proxy THROWS on access to a missing export. Under such mocks the +registration is skipped and their mocked createFnAgent behaves as before. +*/ +try { + (piModuleForRegistration as { registerRoutedAgentSessionFactory?: (factory: (options: Record) => Promise) => void }) + .registerRoutedAgentSessionFactory?.( + createResolvedAgentSession as unknown as (options: Record) => Promise, + ); +} catch { + // Mocked pi.js without the export: leave createFnAgent on its raw fallback. +} diff --git a/packages/engine/src/execution/runtime-resolution.ts b/packages/engine/src/execution/runtime-resolution.ts index b85ed365c1..0ccd872ae9 100644 --- a/packages/engine/src/execution/runtime-resolution.ts +++ b/packages/engine/src/execution/runtime-resolution.ts @@ -108,11 +108,13 @@ export class DefaultPiRuntime implements AgentRuntime { Forward the resolved session budget explicitly across the default-pi bridge. A project setting of 0 becomes null before this point and must reach createFnAgent so the pi wrapper skips clamping just like plugin runtimes. */ const { toolOutputMaxChars, mcpServers, ...agentOptions } = options; + // FNXC:CliRuntimeRouting 2026-08-16-14:37: `__rawPiSession` short-circuits createFnAgent's routed path — the seam resolved to THIS runtime, so routing the bridge again would recurse. createFnAgent stays the bridge target so tests mocking it keep intercepting pi-session construction. const result = await createFnAgent({ ...agentOptions, toolOutputMaxChars, mcpServers: normalizeAgentRuntimeMcpServers(mcpServers), - }); + __rawPiSession: true, + } as Parameters[0]); /* FNXC:TriagePlanningRetry 2026-08-03-01:10: Pi dispatches fallback notification work before its prompt resolves, so it has a known finite diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 693c05ef7f..4ac6168127 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -2313,7 +2313,112 @@ function withMcpPromptOptions(promptOptions: unknown, mcpServers: ResolvedMcpSer return { mcpServers }; } +/* +FNXC:CliRuntimeRouting 2026-08-16-14:37: +`createFnAgent` is now a ROUTED entry point: it delegates to the shared +`createResolvedAgentSession` seam (registered below via +`registerRoutedAgentSessionFactory` to avoid a static import cycle) so every +caller — not just chat/executor/planning — gets CLI runtime routing +(cursor-cli/claude-cli/hermes/omp-cli/no-key grok-cli), mock/test-mode forcing, +and `session:runtime-resolved` visibility. Bare `createFnAgent` calls used to +pin sessions to the default pi runtime, so a CLI-runtime model selection died +with "not found in the pi model registry" in any lane that had not been +individually migrated to the seam (mission interview was the third such +incident after planning's 401 and this cursor one). + +The raw pi implementation lives on as `createPiAgentSessionRaw`, which is what +`DefaultPiRuntime` (execution/runtime-resolution.ts) must call — the seam +resolves to that runtime, so routing the runtime's own bridge through the seam +again would recurse forever. When no routed factory is registered (isolated +unit tests importing only pi.ts), `createFnAgent` falls back to the raw path, +preserving pre-existing test behavior. + +Callers without an explicit `pluginRunner` resolve one from the host-registered +default registry (`registerDefaultAgentPluginRunner`, populated by +InProcessRuntime at plugin-system init), keyed by project root and matched by +session cwd prefix so multi-project hosts route each session against its own +project's plugin set. +*/ +type RoutedAgentSessionFactory = (options: Record) => Promise; +/* +FNXC:CliRuntimeRouting 2026-08-16-14:37: +`var` + lazy-init on purpose: agent-session-helpers registers the factory at +its own module load, and under the pi.ts <-> helpers import cycle that call can +run while pi.ts is still mid-evaluation. A `let`/`const` here sits in its +temporal dead zone during that window and made every hoisted createFnAgent +call throw "Cannot access before initialization"; `var` is hoisted-initialized +and the Map is created on first touch. +*/ +// eslint-disable-next-line no-var +var routedAgentSessionFactory: RoutedAgentSessionFactory | undefined; +// eslint-disable-next-line no-var +var defaultAgentPluginRunners: Map | undefined; + +function agentPluginRunnerRegistry(): Map { + return (defaultAgentPluginRunners ??= new Map()); +} + +/** Late-binding registration seam (mirrors core's `setCreateFnAgent`): agent-session-helpers registers `createResolvedAgentSession` at module load. */ +export function registerRoutedAgentSessionFactory(factory: RoutedAgentSessionFactory): void { + routedAgentSessionFactory = factory; +} + +/** Register a host PluginRunner as the ambient default for bare `createFnAgent` callers in the project rooted at `rootDir`. */ +export function registerDefaultAgentPluginRunner(rootDir: string, pluginRunner: unknown): void { + agentPluginRunnerRegistry().set(rootDir, pluginRunner); +} + +export function unregisterDefaultAgentPluginRunner(rootDir: string): void { + agentPluginRunnerRegistry().delete(rootDir); +} + +/** Resolve the ambient PluginRunner for a session cwd: longest registered project-root prefix wins; a sole registered runner matches any cwd. */ +function resolveDefaultAgentPluginRunner(cwd: string | undefined): unknown { + const registry = agentPluginRunnerRegistry(); + if (registry.size === 0) return undefined; + if (cwd) { + let best: { rootDir: string; runner: unknown } | undefined; + for (const [rootDir, runner] of registry) { + if ((cwd === rootDir || cwd.startsWith(`${rootDir}/`)) && (!best || rootDir.length > best.rootDir.length)) { + best = { rootDir, runner }; + } + } + if (best) return best.runner; + } + if (registry.size === 1) { + return registry.values().next().value; + } + return undefined; +} + export async function createFnAgent(options: AgentOptions): Promise { + /* + FNXC:CliRuntimeRouting 2026-08-16-14:37: + `__rawPiSession` is DefaultPiRuntime's re-entry marker: the seam resolves to + that runtime, whose bridge calls back into createFnAgent (kept as the bridge + target so existing tests that mock createFnAgent still intercept pi-session + construction). The marker short-circuits to the raw constructor instead of + routing again, which would recurse forever. + */ + const { __rawPiSession, ...routableOptions } = options as AgentOptions & { __rawPiSession?: boolean }; + if (!__rawPiSession && routedAgentSessionFactory) { + const optionsWithRouting = routableOptions as AgentOptions & { pluginRunner?: unknown; runtimeHint?: string }; + const pluginRunner = optionsWithRouting.pluginRunner ?? resolveDefaultAgentPluginRunner(routableOptions.cwd); + return routedAgentSessionFactory({ + ...routableOptions, + sessionPurpose: routableOptions.sessionPurpose ?? "executor", + ...(pluginRunner ? { pluginRunner } : {}), + }); + } + return createPiAgentSessionRaw(routableOptions as AgentOptions); +} + +/** + * Raw pi-runtime session construction. Internal to the runtime layer: only + * `DefaultPiRuntime` (and the unregistered-factory fallback above) may call + * this — every product lane goes through `createFnAgent`'s routed path. + */ +export async function createPiAgentSessionRaw(options: AgentOptions): Promise { /* FNXC:EngineDiagnostics 2026-07-26-09:55: createFnAgent is invoked on every agent/session start (executor, triage, chat, heartbeat, etc.). The entry log is steady-state bookkeeping — debug-only (FUSION_DEBUG=pi). Failures and auth issues stay on warn/error. diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index c647bf92be..5f4701234b 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -29,6 +29,7 @@ import { resolveTaskLifecycleColumns, } from "@fusion/core"; import { Scheduler } from "../scheduler.js"; +import { registerDefaultAgentPluginRunner, unregisterDefaultAgentPluginRunner } from "../pi.js"; import type { PrMonitor, PrComment } from "../merge/pr-monitor.js"; import type { PrInfo } from "@fusion/core"; import { TaskExecutor, type TaskExecutorOptions } from "../executor.js"; @@ -1123,6 +1124,17 @@ export class InProcessRuntime rootDir: this.config.workingDirectory, }); await this.pluginRunner.init(); + /* + FNXC:CliRuntimeRouting 2026-08-16-14:37: + Publish this project's PluginRunner as the ambient default for bare + `createFnAgent` callers (research providers, cron, evaluator, reflection, + core DI lanes, dashboard side-lanes) so their sessions route CLI-runtime + model selections (cursor-cli etc.) through the plugin runtime instead of + dying in pi's model registry. Keyed by project root; createFnAgent matches + the session cwd against registered roots so multi-project hosts stay + project-scoped. + */ + registerDefaultAgentPluginRunner(this.config.workingDirectory, this.pluginRunner); /* * FNXC:PluginMcpServers 2026-07-22-12:00: * FN-8491 installs the sole session-facing provider on the project store. @@ -2343,6 +2355,8 @@ export class InProcessRuntime // 8. Shutdown plugin runner if (this.pluginRunner) { + // FNXC:CliRuntimeRouting 2026-08-16-14:37: retract the ambient default runner published at init so bare createFnAgent callers never resolve a shut-down runner. + unregisterDefaultAgentPluginRunner(this.config.workingDirectory); await this.pluginRunner.shutdown(); runtimeLog.log("PluginRunner shutdown complete"); }