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:
5
.changeset/fn-6613-session-skill-lanes.md
Normal file
5
.changeset/fn-6613-session-skill-lanes.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 : "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => []),
|
||||
|
||||
@@ -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 ");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user