FN-6613: inject skills into agent session lanes

Ensure every agent-acting session lane requests available agent and plugin skills.\n\n- Add skill selection context to planning, mission interview, workflow design, memory insight, and cron automation sessions.\n- Thread plugin runners through dashboard session creation, retry, reconnect, and route registration paths.\n- Cover skill injection behavior with dashboard and cron runner regression tests.\n- Document which agent session lanes request skills and which utility-only lanes remain exempt.\n- Add a minor changeset for the published CLI package.\n\nFiles changed:\n .changeset/fn-6613-session-skill-lanes.md          |   5 +\n docs/agents.md                                     |   1 +\n .../src/__tests__/mission-interview.test.ts        |  55 ++++++\n .../src/__tests__/planning-skill-selection.test.ts | 130 +++++++++++++\n .../src/__tests__/session-error-recovery.test.ts   |   6 +\n .../session-persistence-roundtrip.test.ts          |   6 +\n .../src/__tests__/session-reconnect.test.ts        |   6 +\n .../src/__tests__/session-resume-history.test.ts   |   6 +\n packages/dashboard/src/mission-interview.ts        |  23 ++-\n packages/dashboard/src/mission-routes.ts           |   4 +-\n packages/dashboard/src/planning.ts                 |  32 +++-\n .../register-settings-memory-worktrunk.test.ts     | 203 ++++++++++++++++++++-\n .../routes/__tests__/workflow-design-route.test.ts |  56 +++++-\n .../src/routes/register-integrated-routers.ts      |   2 +-\n .../src/routes/register-planning-subtask-routes.ts |   5 +\n .../src/routes/register-settings-memory-routes.ts  |  33 +++-\n .../src/routes/register-workflow-routes.ts         |  16 +-\n packages/dashboard/src/test/mockCoreEngine.ts      |   9 +\n packages/engine/src/__tests__/cron-runner.test.ts  |  17 ++\n packages/engine/src/cron-runner.ts                 |   7 +\n 20 files changed, 599 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-6613

Fusion-Task-Lineage: 985fa9fb-0381-4abf-930f-b547a475c4ed
This commit is contained in:
gsxdsm
2026-06-17 21:12:53 -07:00
parent 0767d1bf81
commit 0453a65bf1
20 changed files with 599 additions and 23 deletions

View File

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

View File

@@ -24,6 +24,7 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
- 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() },

View File

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

View File

@@ -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<typeof buildSessionSkillContextSync>[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<void> {
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<AgentResult> {
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<string> {
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<void> {
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") {

View File

@@ -279,6 +279,7 @@ export function createMissionRouter(
isRunning(): boolean;
},
engineManager?: import("@fusion/engine").ProjectEngineManager,
pluginRunner?: Parameters<typeof import("@fusion/engine").buildSessionSkillContextSync>[3],
): Router {
const router = Router();
const requestContext = new AsyncLocalStorage<TaskStore>();
@@ -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 : "";

View File

@@ -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<typeof buildSessionSkillContextSync>[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<void> {
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<string> {
// 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<void> {
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<AgentResult> {
// 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;

View File

@@ -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<typeof import("@fusion/core")>("@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<typeof import("@fusion/engine")>("@fusion/engine");
return {
@@ -19,17 +58,19 @@ vi.mock("@fusion/engine", async () => {
};
});
function createApp() {
function createApp(pluginRunner?: Record<string, unknown>) {
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<string, unknown>) => 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<string>) => {
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<string>) => {
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"]);
});
});

View File

@@ -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<typeof registerWorkflowRoutes>[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<typeof registerWorkflowRoutes>[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 () => {

View File

@@ -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<typeof import("@fusion/engine").buildSessionSkillContextSync>[3]),
);
router.use("/insights", createInsightsRouter(store));

View File

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

View File

@@ -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<typeof buildSessionSkillContextSync>[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.",

View File

@@ -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<typeof buildSessionSkillContextSync>[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,

View File

@@ -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(() => []),

View File

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

View File

@@ -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<AiPromptExecu
return async (prompt: string, modelProvider?: string, modelId?: string): Promise<string> => {
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) => {