fix: route all AI lanes through runtime resolution so CLI-runtime models work everywhere

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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-16 08:32:46 -07:00
parent 385059f5ca
commit 821e036c9d
10 changed files with 302 additions and 84 deletions

View File

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

View File

@@ -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<typeof import("@fusion/engine")>();
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 }> } }) {

View File

@@ -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: [] }));
});
});

View File

@@ -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<typeof buildSessionSkillContextSync>[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<AgentResult> => {
const { pluginRunner, runtimeHint, settings, ...runtimeOptions } = options ?? {};
return createResolvedAgentSession({
sessionPurpose: "executor",
...(runtimeHint ? { runtimeHint } : {}),
...(pluginRunner ? { pluginRunner } : {}),
...(settings ? { settings } : {}),
...runtimeOptions,
});
};
function ensureEngineReady(): Promise<void> {
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":"..."}}' +

View File

@@ -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<typeof buildSessionSkillContextSync>[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<AgentResult> => {
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":[...]}}. ' +

View File

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

View File

@@ -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<string> {
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<string, unknown>) => Promise<import("../pi.js").AgentResult>) => void })
.registerRoutedAgentSessionFactory?.(
createResolvedAgentSession as unknown as (options: Record<string, unknown>) => Promise<import("../pi.js").AgentResult>,
);
} catch {
// Mocked pi.js without the export: leave createFnAgent on its raw fallback.
}

View File

@@ -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<typeof createFnAgent>[0]);
/*
FNXC:TriagePlanningRetry 2026-08-03-01:10:
Pi dispatches fallback notification work before its prompt resolves, so it has a known finite

View File

@@ -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<string, unknown>) => Promise<AgentResult>;
/*
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<string, unknown> | undefined;
function agentPluginRunnerRegistry(): Map<string, unknown> {
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<AgentResult> {
/*
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<AgentResult> {
/*
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.

View File

@@ -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");
}