From 821e036c9d7d3b236da93c64a59da98223c75973 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 16 Aug 2026 08:32:46 -0700 Subject: [PATCH] fix: route all AI lanes through runtime resolution so CLI-runtime models work everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createFnAgent now delegates to createResolvedAgentSession — deriving the CLI runtime hint (cursor/claude/grok/omp/hermes), mock/test-mode forcing, and session:runtime-resolved visibility — using a host-registered default PluginRunner keyed by project root (published by InProcessRuntime at plugin init). DefaultPiRuntime re-enters via a __rawPiSession marker so the seam's own pi bridge cannot recurse, and the raw constructor survives as createPiAgentSessionRaw for that bridge and pi-internal tests. Mission and milestone/slice interviews additionally pass their request-scoped pluginRunner through the seam and prompt via the engine promptWithFallback dispatcher (plugin CLI runtime sessions have no session.prompt()). This fixes "Configured model cursor-cli/auto ... was not found in the pi model registry" in mission planning while chat on the same model worked, and closes the same gap for every remaining bare createFnAgent lane. Also updates the pi skill-filtering test that still asserted pre-FN-9114 allow-list narrowing; requested skill names are ensure-present since FN-9114. Co-Authored-By: Claude Fable 5 --- .../mission-interview-cli-runtime-routing.md | 7 + .../interview-question-regeneration.test.ts | 8 +- .../src/__tests__/mcp-lane-forwarding.test.ts | 14 +- .../src/milestone-slice-interview.ts | 28 +++- packages/dashboard/src/mission-interview.ts | 28 +++- .../src/__tests__/pi-create-fn-agent.test.ts | 152 ++++++++++-------- .../src/agents/agent-session-helpers.ts | 26 +++ .../src/execution/runtime-resolution.ts | 4 +- packages/engine/src/pi.ts | 105 ++++++++++++ .../engine/src/runtimes/in-process-runtime.ts | 14 ++ 10 files changed, 302 insertions(+), 84 deletions(-) create mode 100644 .changeset/mission-interview-cli-runtime-routing.md 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"); }