diff --git a/.changeset/fn-6613-session-skill-lanes.md b/.changeset/fn-6613-session-skill-lanes.md new file mode 100644 index 0000000000..fab34fafff --- /dev/null +++ b/.changeset/fn-6613-session-skill-lanes.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Request agent and enabled plugin skills across planning, mission interview, workflow design, memory insight, and scheduled automation agent sessions. diff --git a/docs/agents.md b/docs/agents.md index e82a05a640..236dd336e6 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -24,6 +24,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - Each message is stored as a `user-to-agent` MessageStore message from `cli` with `metadata.wakeRecipient=true`. - Agent replies are polled from your inbox and printed as they arrive. - Dashboard-created agent chat sessions request the target agent's declared `metadata.skills` plus enabled plugin-contributed skills, so skills such as `ce-debug` are available in chat when the contributing plugin is enabled. Model-only QuickChat sessions request enabled plugin skills, and room responder sessions request the responder agent's skills. +- Agent-acting session lanes share the same skill-injection contract as executor sessions: executor, merger, triage, reviewer, heartbeat, step-session, dashboard chat/room responders, CLI agent execution, planning, mission interview, workflow design, memory dreams/insight extraction, and scheduled cron automation all request agent/fallback skills plus enabled plugin-contributed skills when a plugin runner is available. Utility-only lanes that only summarize/extract/generate JSON (title/PR summaries, memory compaction, subtask breakdown, text refinement, agent generation, PR metadata generation, evaluator/research synthesis, and similar one-shot helpers) intentionally stay exempt to avoid loading skills where no agent-style tool loop can use them. - In dashboard model-loop chat (main chat, QuickChat, and room responders), typing `/skill:{name}` requests that skill for the current AI session and strips the slash token from the prompt sent to the model. The requested skill is still subject to the normal enabled/disabled execution-skill filters; CLI-agent-backed PTY chat keeps raw terminal input semantics and does not interpret this command. ### Flags diff --git a/packages/dashboard/src/__tests__/mission-interview.test.ts b/packages/dashboard/src/__tests__/mission-interview.test.ts index fdfe81d1ac..108ca135f5 100644 --- a/packages/dashboard/src/__tests__/mission-interview.test.ts +++ b/packages/dashboard/src/__tests__/mission-interview.test.ts @@ -8,6 +8,20 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({ vi.mock("@fusion/engine", () => ({ listCliAdapterDescriptors: () => [], + buildSessionSkillContextSync: (_agent: unknown, sessionPurpose: string, projectRootDir: string, pluginRunner?: { getPluginSkills?: () => Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }> }) => { + const requestedSkillNames = ["fusion"]; + for (const contribution of pluginRunner?.getPluginSkills?.() ?? []) { + if (contribution.skill.enabled === false) continue; + if (contribution.skill.name.trim() && !requestedSkillNames.includes(contribution.skill.name.trim())) { + requestedSkillNames.push(contribution.skill.name.trim()); + } + } + return { + skillSelectionContext: { projectRootDir, requestedSkillNames, sessionPurpose }, + resolvedSkillNames: requestedSkillNames, + skillSource: "role-fallback" as const, + }; + }, createFnAgent: mockCreateFnAgent, })); @@ -204,6 +218,47 @@ describe("mission-interview module", () => { }); describe("session lifecycle", () => { + it("passes executor fallback and enabled plugin skills to mission interview sessions", async () => { + const runner = { + getPluginSkills: vi.fn(() => [ + { pluginId: "fusion-plugin-compound-engineering", skill: { name: "ce-debug" } }, + { pluginId: "disabled-plugin", skill: { name: "disabled-skill", enabled: false } }, + ]), + }; + let capturedOptions: any; + mockCreateFnAgent.mockImplementationOnce(async (options: any) => { + capturedOptions = options; + return createMockAgent([createQuestionJson("q-skills")]); + }); + + const sessionId = await createMissionInterviewSession("127.0.0.77", "Launch platform", "/tmp/project", MOCK_TASK_STORE, undefined, undefined, undefined, undefined, runner as any); + await waitForCurrentQuestion(sessionId); + + expect(runner.getPluginSkills).toHaveBeenCalledTimes(1); + expect(capturedOptions.skillSelection).toMatchObject({ + projectRootDir: "/tmp/project", + sessionPurpose: "executor", + }); + expect(capturedOptions.skillSelection.requestedSkillNames).toEqual(["fusion", "ce-debug"]); + }); + + it("uses executor fallback skills when mission interview has no plugin runner", async () => { + let capturedOptions: any; + mockCreateFnAgent.mockImplementationOnce(async (options: any) => { + capturedOptions = options; + return createMockAgent([createQuestionJson("q-degraded")]); + }); + + const sessionId = await createMissionInterviewSession("127.0.0.78", "Launch platform", "/tmp/project", MOCK_TASK_STORE); + await waitForCurrentQuestion(sessionId); + + expect(capturedOptions.skillSelection).toMatchObject({ + projectRootDir: "/tmp/project", + sessionPurpose: "executor", + }); + expect(capturedOptions.skillSelection.requestedSkillNames).toEqual(["fusion"]); + }); + it("creates, retrieves, and cleans up a session", async () => { const sessionId = await createMissionInterviewSession("127.0.0.1", "Launch platform", "/tmp/project", MOCK_TASK_STORE); diff --git a/packages/dashboard/src/__tests__/planning-skill-selection.test.ts b/packages/dashboard/src/__tests__/planning-skill-selection.test.ts new file mode 100644 index 0000000000..2594de892c --- /dev/null +++ b/packages/dashboard/src/__tests__/planning-skill-selection.test.ts @@ -0,0 +1,130 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TaskStore } from "@fusion/core"; +import { __resetPlanningState, __setCreateFnAgent, createSession, createSessionWithAgent, planningStreamManager } from "../planning.js"; + +function createQuestionJson(): string { + return JSON.stringify({ + type: "question", + data: { id: "q-1", type: "text", question: "What is the scope?" }, + }); +} + +function createMockAgent(response = createQuestionJson()) { + const messages: Array<{ role: string; content: string }> = []; + return { + session: { + state: { messages }, + prompt: vi.fn(async () => { + messages.push({ role: "assistant", content: response }); + }), + dispose: vi.fn(), + }, + }; +} + +async function waitFor(condition: () => boolean): Promise { + for (let i = 0; i < 50; i++) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("Timed out waiting for condition"); +} + +function pluginRunner() { + return { + getPluginSkills: vi.fn(() => [ + { pluginId: "fusion-plugin-compound-engineering", skill: { name: "ce-debug" } }, + { pluginId: "disabled-plugin", skill: { name: "disabled-skill", enabled: false } }, + ]), + }; +} + +describe("planning skill selection", () => { + let rootDir: string; + let globalDir: string; + let store: TaskStore; + + beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "planning-skills-root-")); + globalDir = mkdtempSync(join(tmpdir(), "planning-skills-global-")); + store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); + await store.init(); + __resetPlanningState(); + }); + + afterEach(() => { + __resetPlanningState(); + store.close(); + rmSync(rootDir, { recursive: true, force: true }); + rmSync(globalDir, { recursive: true, force: true }); + }); + + it("passes executor fallback and enabled plugin skills to non-streaming planning sessions", async () => { + const runner = pluginRunner(); + let capturedOptions: any; + __setCreateFnAgent(async (options: any) => { + capturedOptions = options; + return createMockAgent(); + }); + + await createSession("127.0.0.201", "Plan skill coverage", store, rootDir, undefined, undefined, undefined, runner as any); + + expect(runner.getPluginSkills).toHaveBeenCalledTimes(1); + expect(capturedOptions.skillSelection).toMatchObject({ + projectRootDir: rootDir, + sessionPurpose: "executor", + }); + expect(capturedOptions.skillSelection.requestedSkillNames).toEqual(["fusion", "ce-debug"]); + }); + + it("passes enabled plugin skills to streaming planning sessions", async () => { + const runner = pluginRunner(); + let capturedOptions: any; + __setCreateFnAgent(async (options: any) => { + capturedOptions = options; + return createMockAgent(); + }); + + const sessionId = await createSessionWithAgent( + "127.0.0.203", + "Plan streaming skill coverage", + rootDir, + store, + undefined, + undefined, + undefined, + { pluginRunner: runner as any }, + ); + const unsubscribe = planningStreamManager.subscribe(sessionId, () => undefined); + try { + planningStreamManager.consumeInitialTurn(sessionId)?.(); + await waitFor(() => Boolean(capturedOptions)); + } finally { + unsubscribe(); + } + + expect(runner.getPluginSkills).toHaveBeenCalledTimes(1); + expect(capturedOptions.skillSelection).toMatchObject({ + projectRootDir: rootDir, + sessionPurpose: "executor", + }); + expect(capturedOptions.skillSelection.requestedSkillNames).toEqual(["fusion", "ce-debug"]); + }); + + it("uses executor fallback without throwing when no plugin runner is available", async () => { + let capturedOptions: any; + __setCreateFnAgent(async (options: any) => { + capturedOptions = options; + return createMockAgent(); + }); + + await createSession("127.0.0.202", "Plan degraded coverage", store, rootDir); + + expect(capturedOptions.skillSelection.requestedSkillNames).toEqual(["fusion"]); + }); +}); diff --git a/packages/dashboard/src/__tests__/session-error-recovery.test.ts b/packages/dashboard/src/__tests__/session-error-recovery.test.ts index afa7eded87..28628a0f91 100644 --- a/packages/dashboard/src/__tests__/session-error-recovery.test.ts +++ b/packages/dashboard/src/__tests__/session-error-recovery.test.ts @@ -51,6 +51,12 @@ vi.mock("@fusion/engine", () => ({ listCliAdapterDescriptors: () => [], // FNXC:DashboardSessionTests 2026-06-14-09:06: planning.ts spreads createWorkflowAuthoringTools into agent customTools; this focused engine mock must export it to keep AI-session tests aligned with production planning setup. createWorkflowAuthoringTools: vi.fn(() => []), + // FNXC:DashboardSessionTests 2026-06-17-19:33: planning and mission-interview sessions now request skills through the shared helper; focused engine mocks must return the shaped helper result so lifecycle tests do not crash before createFnAgent is captured. + buildSessionSkillContextSync: vi.fn(() => ({ + skillSelectionContext: undefined, + resolvedSkillNames: [], + skillSource: "none" as const, + })), createFnAgent: mockCreateFnAgent, })); diff --git a/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts b/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts index 8d3dc3f886..a1a12ee6b0 100644 --- a/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts +++ b/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts @@ -41,6 +41,12 @@ vi.mock("@fusion/engine", () => ({ listCliAdapterDescriptors: () => [], // FNXC:DashboardSessionTests 2026-06-14-09:06: planning.ts spreads createWorkflowAuthoringTools into agent customTools; this focused engine mock must export it to keep AI-session tests aligned with production planning setup. createWorkflowAuthoringTools: vi.fn(() => []), + // FNXC:DashboardSessionTests 2026-06-17-19:33: planning and mission-interview sessions now request skills through the shared helper; focused engine mocks must return the shaped helper result so lifecycle tests do not crash before createFnAgent is captured. + buildSessionSkillContextSync: vi.fn(() => ({ + skillSelectionContext: undefined, + resolvedSkillNames: [], + skillSource: "none" as const, + })), createFnAgent: mockCreateFnAgent, })); diff --git a/packages/dashboard/src/__tests__/session-reconnect.test.ts b/packages/dashboard/src/__tests__/session-reconnect.test.ts index 6aaeb9fc1e..db5bb5dceb 100644 --- a/packages/dashboard/src/__tests__/session-reconnect.test.ts +++ b/packages/dashboard/src/__tests__/session-reconnect.test.ts @@ -44,6 +44,12 @@ vi.mock("@fusion/engine", () => ({ listCliAdapterDescriptors: () => [], // FNXC:DashboardSessionTests 2026-06-14-09:06: planning.ts spreads createWorkflowAuthoringTools into agent customTools; this focused engine mock must export it to keep AI-session tests aligned with production planning setup. createWorkflowAuthoringTools: vi.fn(() => []), + // FNXC:DashboardSessionTests 2026-06-17-19:33: planning and mission-interview sessions now request skills through the shared helper; focused engine mocks must return the shaped helper result so lifecycle tests do not crash before createFnAgent is captured. + buildSessionSkillContextSync: vi.fn(() => ({ + skillSelectionContext: undefined, + resolvedSkillNames: [], + skillSource: "none" as const, + })), createFnAgent: mockCreateFnAgent, createResolvedAgentSession: vi.fn(async () => ({ session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() }, diff --git a/packages/dashboard/src/__tests__/session-resume-history.test.ts b/packages/dashboard/src/__tests__/session-resume-history.test.ts index 31e9caaa0d..7c4c4b9a36 100644 --- a/packages/dashboard/src/__tests__/session-resume-history.test.ts +++ b/packages/dashboard/src/__tests__/session-resume-history.test.ts @@ -40,6 +40,12 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({ vi.mock("@fusion/engine", () => ({ listCliAdapterDescriptors: () => [], createWorkflowAuthoringTools: vi.fn(() => []), + // FNXC:DashboardSessionTests 2026-06-17-19:33: planning and mission-interview sessions now request skills through the shared helper; focused engine mocks must return the shaped helper result so lifecycle tests do not crash before createFnAgent is captured. + buildSessionSkillContextSync: vi.fn(() => ({ + skillSelectionContext: undefined, + resolvedSkillNames: [], + skillSource: "none" as const, + })), createFnAgent: mockCreateFnAgent, })); diff --git a/packages/dashboard/src/mission-interview.ts b/packages/dashboard/src/mission-interview.ts index 15b376479b..f2cffd22d6 100644 --- a/packages/dashboard/src/mission-interview.ts +++ b/packages/dashboard/src/mission-interview.ts @@ -29,11 +29,12 @@ import { } from "./ai-session-diagnostics.js"; import { GenerationGuard, isAbortError } from "./ai-session-timeout.js"; -import { createFnAgent as engineCreateFnAgent } from "@fusion/engine"; +import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent } 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; // eslint-disable-next-line @typescript-eslint/no-explicit-any const createFnAgent: any = engineCreateFnAgent; @@ -271,6 +272,8 @@ interface MissionInterviewSession { */ store?: TaskStore; rootDir?: string; + /** Plugin runner captured while the server is alive so rebuilt mission interview agents keep plugin-contributed skills. */ + pluginRunner?: SkillPluginRunner; createdAt: Date; updatedAt: Date; } @@ -813,9 +816,10 @@ async function initializeAgent( rootDir: string, store: TaskStore, promptOverrides?: PromptOverrideMap, + pluginRunner?: SkillPluginRunner, ): Promise { try { - session.agent = await createMissionInterviewAgent(session, rootDir, store, promptOverrides); + session.agent = await createMissionInterviewAgent(session, rootDir, store, promptOverrides, pluginRunner); session.updatedAt = new Date(); // Send initial message to get first question @@ -841,15 +845,22 @@ async function createMissionInterviewAgent( rootDir: string, store: TaskStore, promptOverrides?: PromptOverrideMap, + pluginRunner?: SkillPluginRunner, ): Promise { await ensureEngineReady(); const effectivePrompt = resolvePrompt("mission-interview-system", promptOverrides); + const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, pluginRunner); + /* + FNXC:MissionInterviewSkills 2026-06-17-19:33: + Mission interview sessions are agent-acting planning lanes, so they request executor role fallback skills plus enabled plugin skills to keep ce-debug-style skills available outside task execution. + */ return createFnAgent({ cwd: rootDir, systemPrompt: effectivePrompt, tools: "readonly", + ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), builtinToolsAllowlist: [...MISSION_INTERVIEW_BUILTIN_WEB_TOOLS], customTools: [...createPlanningBoardTools(store)], ...(session.modelProvider && session.modelId @@ -931,7 +942,7 @@ async function ensureMissionInterviewAgent( ); } - session.agent = await createMissionInterviewAgent(session, effectiveRootDir, effectiveStore, promptOverrides); + session.agent = await createMissionInterviewAgent(session, effectiveRootDir, effectiveStore, promptOverrides, session.pluginRunner); if (historyForReplay.length === 0) { return; @@ -1147,6 +1158,7 @@ export async function createMissionInterviewSession( modelProvider?: string, modelId?: string, projectId?: string | null, + pluginRunner?: SkillPluginRunner, ): Promise { if (!checkRateLimit(ip)) { const resetTime = getRateLimitResetTime(ip); @@ -1171,6 +1183,7 @@ export async function createMissionInterviewSession( modelId, store, rootDir, + pluginRunner, createdAt: new Date(), updatedAt: new Date(), }; @@ -1179,7 +1192,7 @@ export async function createMissionInterviewSession( persistMissionSession(session, "generating"); // Initialize AI agent in background - initializeAgent(session, rootDir, store, promptOverrides).catch((err) => { + initializeAgent(session, rootDir, store, promptOverrides, pluginRunner).catch((err) => { diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" }); persistMissionSession(session, "error", err.message || "Failed to initialize AI agent"); missionInterviewStreamManager.broadcast(sessionId, { @@ -1254,6 +1267,7 @@ export async function retryMissionInterviewSession( rootDir: string, store?: TaskStore, promptOverrides?: PromptOverrideMap, + pluginRunner?: SkillPluginRunner, ): Promise { const session = getMissionInterviewSession(sessionId); if (!session) { @@ -1262,6 +1276,7 @@ export async function retryMissionInterviewSession( if (store && !session.store) session.store = store; if (rootDir && !session.rootDir) session.rootDir = rootDir; + session.pluginRunner = pluginRunner ?? session.pluginRunner; const persisted = _aiSessionStore?.get(sessionId); if (persisted && persisted.type !== "mission_interview") { diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index e424e5f487..a95026b7ad 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -279,6 +279,7 @@ export function createMissionRouter( isRunning(): boolean; }, engineManager?: import("@fusion/engine").ProjectEngineManager, + pluginRunner?: Parameters[3], ): Router { const router = Router(); const requestContext = new AsyncLocalStorage(); @@ -557,6 +558,7 @@ export function createMissionRouter( resolvedProvider, resolvedModelId, projectId ?? null, + pluginRunner, ); res.status(201).json({ sessionId }); } catch (err: unknown) { @@ -665,7 +667,7 @@ export function createMissionRouter( const { retryMissionInterviewSession } = await import("./mission-interview.js"); - await retryMissionInterviewSession(sessionId, rootDir, scopedStore, settings.promptOverrides); + await retryMissionInterviewSession(sessionId, rootDir, scopedStore, settings.promptOverrides, pluginRunner); res.json({ success: true, sessionId }); } catch (err: unknown) { const errName = err instanceof Error ? err.name : ""; diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 5c7fa068b1..90c257e0be 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -33,6 +33,7 @@ import { nonfatal, } from "./ai-session-diagnostics.js"; import { + buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, createWorkflowAuthoringTools, } from "@fusion/engine"; @@ -45,6 +46,7 @@ const PLANNING_NO_AMBIENT_TASK_ID = ""; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AgentResult = any; +type SkillPluginRunner = Parameters[3]; const PLANNING_BUILTIN_WEB_TOOLS = ["WebSearch", "WebFetch"] as const; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -336,6 +338,8 @@ interface Session { store?: TaskStore; /** Project root captured at session creation; mirrors `store` for agent rebuild. */ rootDir?: string; + /** Plugin runner captured while the server is alive so rebuilt planning agents keep plugin-contributed skills. */ + pluginRunner?: SkillPluginRunner; /** Callback for streaming events to SSE clients */ streamCallback?: PlanningStreamCallback; /** Accumulated thinking output for display */ @@ -795,6 +799,7 @@ export async function createSession( promptOverrides?: PromptOverrideMap, planningDepth?: PlanningDepth, customQuestionCount?: number, + pluginRunner?: SkillPluginRunner, ): Promise<{ sessionId: string; firstQuestion: PlanningQuestion }> { // Check rate limit if (!checkRateLimit(ip)) { @@ -826,6 +831,7 @@ export async function createSession( updatedAt: new Date(), store, rootDir, + pluginRunner, }; sessions.set(sessionId, session); @@ -842,10 +848,17 @@ export async function createSession( await ensureEngineReady(); } + const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, pluginRunner); + + /* + FNXC:PlanningSkills 2026-06-17-19:33: + Planning sessions are agent-acting lanes with planning and workflow tools, so they must request the same executor role fallback plus enabled plugin skills (for example ce-debug) as task execution sessions. + */ const agentResult = await createFnAgent({ cwd: rootDir, systemPrompt, tools: "readonly", + ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), builtinToolsAllowlist: [...PLANNING_BUILTIN_WEB_TOOLS], customTools: [ ...createPlanningBoardTools(store), @@ -1148,6 +1161,7 @@ export async function startExistingSession( modelProvider?: string, modelId?: string, promptOverrides?: PromptOverrideMap, + pluginRunner?: SkillPluginRunner, ): Promise { let session = sessions.get(sessionId); @@ -1230,7 +1244,8 @@ export async function startExistingSession( persistSession(session, "generating"); planningStreamManager.registerInitialTurn(sessionId, () => { - initializeAgent(session, rootDir, store, modelProvider, modelId, promptOverrides).catch((err) => { + session.pluginRunner = pluginRunner; + initializeAgent(session, rootDir, store, modelProvider, modelId, promptOverrides, undefined, undefined, pluginRunner).catch((err) => { diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" }); persistSession(session, "error", err.message || "Failed to initialize AI agent"); planningStreamManager.broadcast(sessionId, { @@ -1266,6 +1281,7 @@ export async function createSessionWithAgent( ntfyConfig?: PlanningNtfyConfig; planningDepth?: PlanningDepth; customQuestionCount?: number; + pluginRunner?: SkillPluginRunner; }, ): Promise { // Check rate limit @@ -1299,6 +1315,7 @@ export async function createSessionWithAgent( lastGeneratedThinking: "", createdAt: new Date(), updatedAt: new Date(), + pluginRunner: options?.pluginRunner, }; sessions.set(sessionId, session); @@ -1314,6 +1331,7 @@ export async function createSessionWithAgent( promptOverrides, options?.planningDepth, options?.customQuestionCount, + options?.pluginRunner, ).catch((err) => { diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" }); persistSession(session, "error", err.message || "Failed to initialize AI agent"); @@ -1339,6 +1357,7 @@ async function initializeAgent( promptOverrides?: PromptOverrideMap, planningDepth?: PlanningDepth, customQuestionCount?: number, + pluginRunner?: SkillPluginRunner, ): Promise { try { await runGenerationWithTimeout(session, async (abortSignal) => { @@ -1355,6 +1374,7 @@ async function initializeAgent( promptOverrides, planningDepth, customQuestionCount, + pluginRunner, ); void agentPromise.then((lateAgent) => { @@ -1410,6 +1430,7 @@ async function createPlanningAgent( promptOverrides?: PromptOverrideMap, planningDepth?: PlanningDepth, customQuestionCount?: number, + pluginRunner?: SkillPluginRunner, ): Promise { // Ensure engine is loaded before using createFnAgent await ensureEngineReady(); @@ -1419,10 +1440,17 @@ async function createPlanningAgent( const depthPromptSuffix = buildDepthPromptSuffix(planningDepth, customQuestionCount); const systemPrompt = depthPromptSuffix ? `${baseSystemPrompt}\n\n${depthPromptSuffix}` : baseSystemPrompt; + const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, pluginRunner); + + /* + FNXC:PlanningSkills 2026-06-17-19:33: + Streaming planning sessions share the executor skill contract because custom planning/workflow tools can benefit from agent-declared skills and enabled plugin skills exactly like task execution. + */ return createFnAgent({ cwd: rootDir, systemPrompt, tools: "readonly", + ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), builtinToolsAllowlist: [...PLANNING_BUILTIN_WEB_TOOLS], customTools: [ ...createPlanningBoardTools(store), @@ -1499,7 +1527,7 @@ async function ensureSessionAgent( ); } - session.agent = await createPlanningAgent(session, effectiveRootDir, effectiveStore, undefined, undefined, promptOverrides); + session.agent = await createPlanningAgent(session, effectiveRootDir, effectiveStore, undefined, undefined, promptOverrides, undefined, undefined, session.pluginRunner); if (historyForReplay.length === 0) { return; diff --git a/packages/dashboard/src/routes/__tests__/register-settings-memory-worktrunk.test.ts b/packages/dashboard/src/routes/__tests__/register-settings-memory-worktrunk.test.ts index 5dd44633ff..c8a6910266 100644 --- a/packages/dashboard/src/routes/__tests__/register-settings-memory-worktrunk.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-settings-memory-worktrunk.test.ts @@ -2,14 +2,53 @@ import express from "express"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { registerSettingsMemoryRoutes } from "../register-settings-memory-routes.js"; +import { + __resetCreateFnAgentForInsights, + __setCreateFnAgentForInsights, + registerSettingsMemoryRoutes, +} from "../register-settings-memory-routes.js"; import { request as performRequest } from "../../test-request.js"; -const { resolveWorktrunkBinaryMock, probeWorktrunkMock } = vi.hoisted(() => ({ +const { + resolveWorktrunkBinaryMock, + probeWorktrunkMock, + readMemoryMock, + readInsightsMemoryMock, + buildInsightExtractionPromptMock, + processAndAuditInsightExtractionMock, + processMemoryDreamsMock, + processAgentMemoryDreamsMock, + resolvePlanningSettingsModelMock, +} = vi.hoisted(() => ({ resolveWorktrunkBinaryMock: vi.fn(), probeWorktrunkMock: vi.fn(), + readMemoryMock: vi.fn(), + readInsightsMemoryMock: vi.fn(), + buildInsightExtractionPromptMock: vi.fn(), + processAndAuditInsightExtractionMock: vi.fn(), + processMemoryDreamsMock: vi.fn(), + processAgentMemoryDreamsMock: vi.fn(), + resolvePlanningSettingsModelMock: vi.fn(), })); +vi.mock("@fusion/core", async () => { + const actual = await vi.importActual("@fusion/core"); + return { + ...actual, + readMemory: readMemoryMock, + readInsightsMemory: readInsightsMemoryMock, + buildInsightExtractionPrompt: buildInsightExtractionPromptMock, + processAndAuditInsightExtraction: processAndAuditInsightExtractionMock, + processMemoryDreams: processMemoryDreamsMock, + processAgentMemoryDreams: processAgentMemoryDreamsMock, + AgentStore: class { + async init() {} + async listAgents() { return []; } + }, + resolvePlanningSettingsModel: resolvePlanningSettingsModelMock, + }; +}); + vi.mock("@fusion/engine", async () => { const actual = await vi.importActual("@fusion/engine"); return { @@ -19,17 +58,19 @@ vi.mock("@fusion/engine", async () => { }; }); -function createApp() { +function createApp(pluginRunner?: Record) { const router = express.Router(); const scopedStore = { - getSettings: vi.fn(async () => ({ worktrunk: { enabled: false } })), + getSettings: vi.fn(async () => ({ worktrunk: { enabled: false }, memoryDreamsEnabled: true })), + getRootDir: vi.fn(() => "/tmp/project"), + getFusionDir: vi.fn(() => "/tmp/project/.fusion"), updateSettings: vi.fn(async (patch: Record) => patch), }; registerSettingsMemoryRoutes( { router, - options: {}, + options: { pluginRunner }, store: {} as any, runtimeLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as any, getProjectContext: vi.fn(async () => ({ store: scopedStore, projectId: "p1" })), @@ -69,6 +110,14 @@ describe("register-settings-memory-routes worktrunk gate", () => { beforeEach(() => { resolveWorktrunkBinaryMock.mockReset(); probeWorktrunkMock.mockReset(); + readMemoryMock.mockReset(); + readInsightsMemoryMock.mockReset(); + buildInsightExtractionPromptMock.mockReset(); + processAndAuditInsightExtractionMock.mockReset(); + processMemoryDreamsMock.mockReset(); + processAgentMemoryDreamsMock.mockReset(); + resolvePlanningSettingsModelMock.mockReset(); + __resetCreateFnAgentForInsights(); }); it("rejects worktrunk.enabled=true when binary is unavailable", async () => { @@ -115,4 +164,148 @@ describe("register-settings-memory-routes worktrunk gate", () => { expect(probeWorktrunkMock).not.toHaveBeenCalled(); expect(scopedStore.updateSettings).toHaveBeenCalledTimes(1); }); + + it("passes enabled plugin skills to memory dream processing", async () => { + const pluginRunner = { + getPluginSkills: vi.fn(() => [ + { pluginId: "fusion-plugin-compound-engineering", skill: { name: "ce-debug" } }, + { pluginId: "disabled-plugin", skill: { name: "disabled-skill", enabled: false } }, + ]), + }; + const { app } = createApp(pluginRunner); + let capturedOptions: any; + __setCreateFnAgentForInsights(async (options: any) => { + capturedOptions = options; + return { + session: { + prompt: vi.fn(async () => "dream result"), + state: { messages: [{ role: "assistant", content: "dream result" }] }, + dispose: vi.fn(), + }, + }; + }); + resolvePlanningSettingsModelMock.mockReturnValue({ provider: "mock", modelId: "model" }); + processMemoryDreamsMock.mockImplementation(async (_rootDir: string, executePrompt: (prompt: string) => Promise) => { + await executePrompt("dream prompt"); + return { dreams: true, longTermUpdates: false }; + }); + processAgentMemoryDreamsMock.mockResolvedValue([]); + + const res = await performRequest(app, "POST", "/api/memory/dream", "{}", { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(pluginRunner.getPluginSkills).toHaveBeenCalledTimes(1); + expect(capturedOptions.skillSelection).toMatchObject({ + projectRootDir: "/tmp/project", + sessionPurpose: "executor", + }); + expect(capturedOptions.skillSelection.requestedSkillNames).toEqual(["fusion", "ce-debug"]); + }); + + it("uses executor fallback skills when memory dream processing has no plugin runner", async () => { + const { app } = createApp(); + let capturedOptions: any; + __setCreateFnAgentForInsights(async (options: any) => { + capturedOptions = options; + return { + session: { + prompt: vi.fn(async () => "dream result"), + state: { messages: [{ role: "assistant", content: "dream result" }] }, + dispose: vi.fn(), + }, + }; + }); + resolvePlanningSettingsModelMock.mockReturnValue({ provider: "mock", modelId: "model" }); + processMemoryDreamsMock.mockImplementation(async (_rootDir: string, executePrompt: (prompt: string) => Promise) => { + await executePrompt("dream prompt"); + return { dreams: false, longTermUpdates: false }; + }); + processAgentMemoryDreamsMock.mockResolvedValue([]); + + const res = await performRequest(app, "POST", "/api/memory/dream", "{}", { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(capturedOptions.skillSelection).toMatchObject({ + projectRootDir: "/tmp/project", + sessionPurpose: "executor", + }); + expect(capturedOptions.skillSelection.requestedSkillNames).toEqual(["fusion"]); + }); + + it("passes enabled plugin skills to manual insight extraction", async () => { + const pluginRunner = { + getPluginSkills: vi.fn(() => [ + { pluginId: "fusion-plugin-compound-engineering", skill: { name: "ce-debug" } }, + { pluginId: "disabled-plugin", skill: { name: "disabled-skill", enabled: false } }, + ]), + }; + const { app } = createApp(pluginRunner); + let capturedOptions: any; + __setCreateFnAgentForInsights(async (options: any) => { + capturedOptions = options; + return { + session: { + prompt: vi.fn(async () => "{\"insights\":[]}"), + dispose: vi.fn(), + }, + }; + }); + resolvePlanningSettingsModelMock.mockReturnValue({ provider: "mock", modelId: "model" }); + readMemoryMock.mockResolvedValue({ content: "working notes" }); + readInsightsMemoryMock.mockResolvedValue(null); + buildInsightExtractionPromptMock.mockReturnValue("extract insights"); + processAndAuditInsightExtractionMock.mockResolvedValue({ + extraction: { summary: "Extracted", insightCount: 0 }, + pruning: { applied: false }, + }); + + const res = await performRequest(app, "POST", "/api/memory/extract", "{}", { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(pluginRunner.getPluginSkills).toHaveBeenCalledTimes(1); + expect(capturedOptions.skillSelection).toMatchObject({ + projectRootDir: "/tmp/project", + sessionPurpose: "executor", + }); + expect(capturedOptions.skillSelection.requestedSkillNames).toEqual(["fusion", "ce-debug"]); + }); + + it("uses executor fallback skills when manual insight extraction has no plugin runner", async () => { + const { app } = createApp(); + let capturedOptions: any; + __setCreateFnAgentForInsights(async (options: any) => { + capturedOptions = options; + return { + session: { + prompt: vi.fn(async () => "{\"insights\":[]}"), + dispose: vi.fn(), + }, + }; + }); + resolvePlanningSettingsModelMock.mockReturnValue({ provider: "mock", modelId: "model" }); + readMemoryMock.mockResolvedValue({ content: "working notes" }); + readInsightsMemoryMock.mockResolvedValue(null); + buildInsightExtractionPromptMock.mockReturnValue("extract insights"); + processAndAuditInsightExtractionMock.mockResolvedValue({ + extraction: { summary: "Extracted", insightCount: 0 }, + pruning: { applied: false }, + }); + + const res = await performRequest(app, "POST", "/api/memory/extract", "{}", { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(capturedOptions.skillSelection).toMatchObject({ + projectRootDir: "/tmp/project", + sessionPurpose: "executor", + }); + expect(capturedOptions.skillSelection.requestedSkillNames).toEqual(["fusion"]); + }); }); diff --git a/packages/dashboard/src/routes/__tests__/workflow-design-route.test.ts b/packages/dashboard/src/routes/__tests__/workflow-design-route.test.ts index 5fb0b13699..668e61203c 100644 --- a/packages/dashboard/src/routes/__tests__/workflow-design-route.test.ts +++ b/packages/dashboard/src/routes/__tests__/workflow-design-route.test.ts @@ -25,8 +25,9 @@ import { request } from "../../test-request.js"; /** Captures the prompt the route fed the agent and returns canned `text`. */ function makeFakeAgent(text: string) { - const captured: { systemPrompt?: string; userPrompt?: string } = {}; + const captured: { systemPrompt?: string; userPrompt?: string; options?: any } = {}; const factory: any = async (opts: any) => { + captured.options = opts; captured.systemPrompt = opts.systemPrompt; let textListener: ((delta: string) => void) | undefined; const session = { @@ -142,6 +143,14 @@ describe("POST /api/workflows/design (U7/R11/KTD-6)", () => { rethrowAsApiError: (err: unknown) => { throw err instanceof ApiError ? err : new ApiError(500, err instanceof Error ? err.message : String(err)); }, + options: { + pluginRunner: { + getPluginSkills: () => [ + { pluginId: "fusion-plugin-compound-engineering", skill: { name: "ce-debug" } }, + { pluginId: "disabled-plugin", skill: { name: "disabled-skill", enabled: false } }, + ], + }, + }, } as unknown as Parameters[0]); app.use("/api", router); app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { @@ -158,15 +167,34 @@ describe("POST /api/workflows/design (U7/R11/KTD-6)", () => { rmSync(globalDir, { recursive: true, force: true }); }); - const postJson = (path: string, body: unknown) => - request(app, "POST", path, JSON.stringify(body), { "Content-Type": "application/json" }); + const postJson = (path: string, body: unknown, targetApp = app) => + request(targetApp, "POST", path, JSON.stringify(body), { "Content-Type": "application/json" }); + + function createAppWithoutPluginRunner() { + const degradedApp = express(); + degradedApp.use(express.json()); + const router = express.Router(); + registerWorkflowRoutes({ + router, + getProjectContext: async () => ({ store, engine: undefined, projectId: undefined }), + rethrowAsApiError: (err: unknown) => { + throw err instanceof ApiError ? err : new ApiError(500, err instanceof Error ? err.message : String(err)); + }, + } as unknown as Parameters[0]); + degradedApp.use("/api", router); + degradedApp.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + if (err instanceof ApiError) sendErrorResponse(res, err.statusCode, err.message, { details: err.details }); + else sendErrorResponse(res, 500, err instanceof Error ? err.message : String(err)); + }); + return degradedApp; + } async function userDefCount() { return (await store.listWorkflowDefinitions()).filter((w) => !isBuiltinWorkflowId(w.id)).length; } it("valid linear IR → 200 {ir, interpreterOnly:false} with layout", async () => { - const { factory } = makeFakeAgent(JSON.stringify(linearIr())); + const { factory, captured } = makeFakeAgent(JSON.stringify(linearIr())); __setCreateFnAgentForDesign(factory); const res = await postJson("/api/workflows/design", { prompt: "a coding flow" }); @@ -176,6 +204,26 @@ describe("POST /api/workflows/design (U7/R11/KTD-6)", () => { expect(res.body.layout).toBeTruthy(); expect(Object.keys(res.body.layout).length).toBeGreaterThan(0); expect(res.body.strippedApprovalFlags).toBe(false); + expect(captured.options?.skillSelection).toMatchObject({ + projectRootDir: rootDir, + sessionPurpose: "executor", + }); + expect(captured.options?.skillSelection?.requestedSkillNames).toEqual(["fusion", "ce-debug"]); + }); + + it("uses executor fallback skills when workflow design has no plugin runner", async () => { + const degradedApp = createAppWithoutPluginRunner(); + const { factory, captured } = makeFakeAgent(JSON.stringify(linearIr())); + __setCreateFnAgentForDesign(factory); + + const res = await postJson("/api/workflows/design", { prompt: "a coding flow" }, degradedApp); + + expect(res.status).toBe(200); + expect(captured.options?.skillSelection).toMatchObject({ + projectRootDir: rootDir, + sessionPurpose: "executor", + }); + expect(captured.options?.skillSelection?.requestedSkillNames).toEqual(["fusion"]); }); it("non-streaming agent session without .on() → reads last assistant message", async () => { diff --git a/packages/dashboard/src/routes/register-integrated-routers.ts b/packages/dashboard/src/routes/register-integrated-routers.ts index 561e99f8d6..4f3fa71593 100644 --- a/packages/dashboard/src/routes/register-integrated-routers.ts +++ b/packages/dashboard/src/routes/register-integrated-routers.ts @@ -38,7 +38,7 @@ export function registerIntegratedRouters({ }: IntegratedRoutersOptions): void { router.use( "/missions", - createMissionRouter(store, options?.missionAutopilot, aiSessionStore, options?.missionExecutionLoop, options?.engineManager), + createMissionRouter(store, options?.missionAutopilot, aiSessionStore, options?.missionExecutionLoop, options?.engineManager, options?.pluginRunner as Parameters[3]), ); router.use("/insights", createInsightsRouter(store)); diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index 74cdf7d5c2..9e9483e6f8 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -12,6 +12,8 @@ import type { AiSessionStore } from "../ai-session-store.js"; import type { ApiRoutesContext } from "./types.js"; import { resolveBranchAssignmentContext, resolveBranchSelection, resolveEntryPointBranchAssignment } from "./branch-selection.js"; +type SkillPluginRunner = Parameters[3]; + interface PlanningSubtaskRouteDeps { store: TaskStore; aiSessionStore?: AiSessionStore; @@ -490,6 +492,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann settings.promptOverrides, planningDepth, customQuestionCount, + ctx.options?.pluginRunner as SkillPluginRunner, ); res.status(201).json(result); } catch (err: unknown) { @@ -660,6 +663,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann resolvedPlanningProvider, resolvedPlanningModelId, settings.promptOverrides, + ctx.options?.pluginRunner as SkillPluginRunner, ); res.status(201).json({ sessionId: existingSessionId }); return; @@ -684,6 +688,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann }, planningDepth, customQuestionCount, + pluginRunner: ctx.options?.pluginRunner as SkillPluginRunner, }, ); res.status(201).json({ sessionId }); diff --git a/packages/dashboard/src/routes/register-settings-memory-routes.ts b/packages/dashboard/src/routes/register-settings-memory-routes.ts index 9790bcbd53..59850ccca4 100644 --- a/packages/dashboard/src/routes/register-settings-memory-routes.ts +++ b/packages/dashboard/src/routes/register-settings-memory-routes.ts @@ -45,6 +45,7 @@ import { updatePiExtensionDisabledIds, } from "@fusion/core"; import { + buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, getActiveNotificationService, probeWorktrunk, @@ -63,6 +64,21 @@ import { generateRemoteToken, issueRemoteAuthToken, maskRemoteToken } from "../r import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js"; import type { ApiRoutesContext } from "./types.js"; +type SkillPluginRunner = Parameters[3]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let createFnAgentForInsights: any = engineCreateFnAgent; + +/** @internal Inject a mock createFnAgent for memory insight route tests. */ +export function __setCreateFnAgentForInsights(mock: typeof createFnAgentForInsights): void { + createFnAgentForInsights = mock; +} + +/** @internal Reset the memory insight route createFnAgent binding. */ +export function __resetCreateFnAgentForInsights(): void { + createFnAgentForInsights = engineCreateFnAgent; +} + interface SettingsMemoryRouteDeps { githubToken?: string; validateModelPresets: (input: unknown) => ModelPreset[] | undefined; @@ -1551,9 +1567,16 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin throw new ApiError(503, "AI service unavailable for dream processing"); } + const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, options?.pluginRunner as SkillPluginRunner); + + /* + FNXC:MemoryInsightsSkills 2026-06-17-19:33: + Memory dream processing uses an agent session to synthesize durable insights, so enabled plugin skills must be requested the same way executor sessions request them. + */ const agentResult = await createFnAgentForInsights({ cwd: rootDir, tools: "readonly", + ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), defaultProvider: resolvedProvider, defaultModelId: resolvedModelId, systemPrompt: "You are a helpful AI assistant that synthesizes memory into durable insights.", @@ -1609,9 +1632,6 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin // ── Memory Insights Routes ─────────────────────────────────────────── - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const createFnAgentForInsights: any = engineCreateFnAgent; - /** * GET /api/memory/insights * Returns the insights memory file content. @@ -1705,10 +1725,17 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin const { provider: resolvedProvider, modelId: resolvedModelId } = resolvePlanningSettingsModel(settings); + const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, options?.pluginRunner as SkillPluginRunner); + + /* + FNXC:MemoryInsightsSkills 2026-06-17-19:33: + Manual insight extraction is an agent-acting lane, so it must request executor fallback and enabled plugin skills before prompting the insight agent. + */ // Create AI agent session for extraction const agentResult = await createFnAgentForInsights({ cwd: rootDir, tools: "readonly", + ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), defaultProvider: resolvedProvider, defaultModelId: resolvedModelId, systemPrompt: "You are a helpful AI assistant that extracts insights from working memory.", diff --git a/packages/dashboard/src/routes/register-workflow-routes.ts b/packages/dashboard/src/routes/register-workflow-routes.ts index eb0704793c..b38ba242ee 100644 --- a/packages/dashboard/src/routes/register-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-workflow-routes.ts @@ -1,10 +1,12 @@ import type { WorkflowDefinition, WorkflowDefinitionKind, WorkflowIr, WorkflowIrNode, WorkflowSettingDefinition, TaskStore } from "@fusion/core"; import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, ColumnAgentBindingError, WorkflowSettingRejectionError, SCHEMA_VERSION, assertColumnTraitsValid, compileWorkflowToSteps, layoutForIr, listTraits, listStepParsers, parseWorkflowIr, resolvePlanningSettingsModel, stripApprovalBypassFlags, resolveWorkflowIrById, resolveEffectiveSettingValues, findOrphanedSettingValues, isBuiltinWorkflowId, BUILTIN_WORKFLOW_SETTINGS, AgentStore, validateColumnAgentBindings, resolveWorkflowOptionalSteps } from "@fusion/core"; -import { createFnAgent as engineCreateFnAgent, validateCodeNodeSources } from "@fusion/engine"; +import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, validateCodeNodeSources } from "@fusion/engine"; import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js"; import { emitWorkflowSseEvent } from "../sse.js"; import type { ApiRoutesContext } from "./types.js"; +type SkillPluginRunner = Parameters[3]; + // ── AI design route DI seam + rate limiter (U7/R11/KTD-6) ───────────────────── // // Test-injectable createFnAgent factory, co-located with the route per KTD-6's @@ -131,7 +133,7 @@ at this boundary regardless.`; * through @fusion/core's TaskStore; none touch the engine's scheduler/executor. */ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { - const { router, getProjectContext, rethrowAsApiError } = ctx; + const { router, getProjectContext, rethrowAsApiError, options } = ctx; function requireIr(body: unknown): WorkflowIr { const ir = (body as { ir?: unknown })?.ir; @@ -839,10 +841,18 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { // One-shot, tool-less design turn on the planning lane. const settings = await store.getSettings(); const planningModel = resolvePlanningSettingsModel(settings); + const rootDir = store.getRootDir(); + const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, options?.pluginRunner as SkillPluginRunner); + + /* + FNXC:WorkflowDesignSkills 2026-06-17-19:33: + Workflow design is an agent-acting planning lane, so it requests executor fallback skills plus enabled plugin skills when creating the design session. + */ const { session } = await createFnAgentForDesign({ - cwd: store.getRootDir(), + cwd: rootDir, systemPrompt: WORKFLOW_DESIGN_SYSTEM_PROMPT, tools: "readonly", + ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), defaultProvider: planningModel.provider, defaultModelId: planningModel.modelId, defaultThinkingLevel: settings.defaultThinkingLevel, diff --git a/packages/dashboard/src/test/mockCoreEngine.ts b/packages/dashboard/src/test/mockCoreEngine.ts index ff39ad45dd..fba654edf7 100644 --- a/packages/dashboard/src/test/mockCoreEngine.ts +++ b/packages/dashboard/src/test/mockCoreEngine.ts @@ -47,6 +47,15 @@ export function createEngineMock(overrides: AnyModule = {}): AnyModule { return withFallbackFunctions(actual, { createFnAgent: vi.fn(), promptWithFallback: vi.fn(), + /* + FNXC:TestSkills 2026-06-17-19:33: + Dashboard route tests mock @fusion/engine wholesale, so skill-aware planning lanes need a shaped session-skill helper result instead of the fallback vi.fn() returning undefined. + */ + buildSessionSkillContextSync: vi.fn(() => ({ + skillSelectionContext: undefined, + resolvedSkillNames: [], + skillSource: "none" as const, + })), // Returns an iterable tool list; dashboard code spreads its result // (`...createWorkflowAuthoringTools(...)`), so it must not be undefined. createWorkflowAuthoringTools: vi.fn(() => []), diff --git a/packages/engine/src/__tests__/cron-runner.test.ts b/packages/engine/src/__tests__/cron-runner.test.ts index 65f904f67c..cf87725ac5 100644 --- a/packages/engine/src/__tests__/cron-runner.test.ts +++ b/packages/engine/src/__tests__/cron-runner.test.ts @@ -166,6 +166,23 @@ describe("CronRunner", () => { }); describe("createAiPromptExecutor", () => { + it("passes executor fallback skill selection to scheduled AI prompt sessions", async () => { + let capturedOptions: any; + piModuleMocks.createFnAgent.mockImplementation(async (options: any) => { + capturedOptions = options; + return { session: { dispose: vi.fn() } }; + }); + + const executor = await createAiPromptExecutor("/test/project"); + await executor("Summarize this"); + + expect(capturedOptions.skillSelection).toMatchObject({ + projectRootDir: "/test/project", + sessionPurpose: "executor", + requestedSkillNames: ["fusion"], + }); + }); + it("returns response text even when session disposal throws", async () => { piModuleMocks.createFnAgent.mockImplementation(async (options: { onText?: (delta: string) => void }) => { options.onText?.("hello "); diff --git a/packages/engine/src/cron-runner.ts b/packages/engine/src/cron-runner.ts index afc0ee8d91..1a6b8c4186 100644 --- a/packages/engine/src/cron-runner.ts +++ b/packages/engine/src/cron-runner.ts @@ -19,6 +19,7 @@ import { createLogger } from "./logger.js"; import { defaultShell } from "./shell-utils.js"; import { createFnAgent, promptWithFallback } from "./pi.js"; import { HybridEvaluatorService } from "./evaluator.js"; +import { buildSessionSkillContextSync } from "./session-skill-context.js"; const log = createLogger("cron-runner"); @@ -1004,11 +1005,17 @@ export async function createAiPromptExecutor(cwd: string): Promise => { let responseText = ""; + const skillContext = buildSessionSkillContextSync(null, "executor", cwd, undefined); + /* + FNXC:CronAutomationSkills 2026-06-17-19:33: + Scheduled AI automation is an agent-acting lane; even without a plugin runner in this seam, it must request executor fallback skills and tolerate the degraded no-plugin path. + */ const { session } = await createFnAgent({ cwd, systemPrompt: AI_AUTOMATION_SYSTEM_PROMPT, tools: "readonly", + ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), defaultProvider: modelProvider, defaultModelId: modelId, onText: (delta: string) => {