From cdadac1662d8f6066f6ead97300046587d9b5232 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 17 Jun 2026 22:21:34 -0700 Subject: [PATCH] FN-6622: wire dashboard interviews to session skills Dashboard interview lanes now request the same role-fallback and enabled plugin skills as agent-acting sessions. - Add skillSelection to agent onboarding and milestone/slice interview agent creation. - Thread the plugin runner through mission interview and onboarding routes. - Cover enabled and disabled plugin skill selection behavior in dashboard tests. - Document the newly covered dashboard interview lanes and add a minor changeset. Files changed: .../fn-6622-session-skill-interview-lanes.md | 5 ++ docs/agents.md | 2 +- .../src/__tests__/agent-onboarding.test.ts | 68 ++++++++++++++++++++++ .../__tests__/milestone-slice-interview.test.ts | 66 +++++++++++++++++++++ packages/dashboard/src/agent-onboarding.ts | 10 +++- .../dashboard/src/milestone-slice-interview.ts | 39 ++++++++++--- packages/dashboard/src/mission-routes.ts | 10 ++-- ...gister-agent-import-export-generation-routes.ts | 3 +- 8 files changed, 187 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-6622 Fusion-Task-Lineage: 5e3dded1-1866-4b76-8302-253e5b3867fa --- .../fn-6622-session-skill-interview-lanes.md | 5 ++ docs/agents.md | 2 +- .../src/__tests__/agent-onboarding.test.ts | 68 +++++++++++++++++++ .../milestone-slice-interview.test.ts | 66 ++++++++++++++++++ packages/dashboard/src/agent-onboarding.ts | 10 ++- .../src/milestone-slice-interview.ts | 39 ++++++++--- packages/dashboard/src/mission-routes.ts | 10 +-- ...r-agent-import-export-generation-routes.ts | 3 +- 8 files changed, 187 insertions(+), 16 deletions(-) create mode 100644 .changeset/fn-6622-session-skill-interview-lanes.md diff --git a/.changeset/fn-6622-session-skill-interview-lanes.md b/.changeset/fn-6622-session-skill-interview-lanes.md new file mode 100644 index 0000000000..ccdffad80f --- /dev/null +++ b/.changeset/fn-6622-session-skill-interview-lanes.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Load selected Fusion and enabled plugin skills in milestone/slice interview and agent-onboarding dashboard sessions. diff --git a/docs/agents.md b/docs/agents.md index 236dd336e6..765805cc58 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -24,7 +24,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - Each message is stored as a `user-to-agent` MessageStore message from `cli` with `metadata.wakeRecipient=true`. - Agent replies are polled from your inbox and printed as they arrive. - Dashboard-created agent chat sessions request the target agent's declared `metadata.skills` plus enabled plugin-contributed skills, so skills such as `ce-debug` are available in chat when the contributing plugin is enabled. Model-only QuickChat sessions request enabled plugin skills, and room responder sessions request the responder agent's skills. -- Agent-acting session lanes share the same skill-injection contract as executor sessions: executor, merger, triage, reviewer, heartbeat, step-session, dashboard chat/room responders, CLI agent execution, planning, mission interview, workflow design, memory dreams/insight extraction, and scheduled cron automation all request agent/fallback skills plus enabled plugin-contributed skills when a plugin runner is available. Utility-only lanes that only summarize/extract/generate JSON (title/PR summaries, memory compaction, subtask breakdown, text refinement, agent generation, PR metadata generation, evaluator/research synthesis, and similar one-shot helpers) intentionally stay exempt to avoid loading skills where no agent-style tool loop can use them. +- 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, milestone/slice interview, agent-onboarding interview, workflow design, memory dreams/insight extraction, and scheduled cron automation all request agent/fallback skills plus enabled plugin-contributed skills when a plugin runner is available. Utility-only lanes that only summarize/extract/generate JSON (title/PR summaries, memory compaction, subtask breakdown, text refinement, agent generation, PR metadata generation, evaluator/research synthesis, and similar one-shot helpers) intentionally stay exempt to avoid loading skills where no agent-style tool loop can use them. - In dashboard model-loop chat (main chat, QuickChat, and room responders), typing `/skill:{name}` requests that skill for the current AI session and strips the slash token from the prompt sent to the model. The requested skill is still subject to the normal enabled/disabled execution-skill filters; CLI-agent-backed PTY chat keeps raw terminal input semantics and does not interpret this command. ### Flags diff --git a/packages/dashboard/src/__tests__/agent-onboarding.test.ts b/packages/dashboard/src/__tests__/agent-onboarding.test.ts index 8e1cd2ce22..8c9e92522e 100644 --- a/packages/dashboard/src/__tests__/agent-onboarding.test.ts +++ b/packages/dashboard/src/__tests__/agent-onboarding.test.ts @@ -6,6 +6,21 @@ 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?.() ?? []) { + const name = contribution.skill.name.trim(); + if (contribution.skill.enabled === false || name.length === 0 || requestedSkillNames.includes(name)) { + continue; + } + requestedSkillNames.push(name); + } + return { + skillSelectionContext: { projectRootDir, requestedSkillNames, sessionPurpose }, + resolvedSkillNames: requestedSkillNames, + skillSource: "role-fallback" as const, + }; + }, createFnAgent: mockCreateFnAgent, })); @@ -47,6 +62,12 @@ async function waitFor(check: () => boolean, timeoutMs = 2000): Promise { } } +function createSkillPluginRunner(skills: Array<{ name: string; enabled?: boolean }>) { + return { + getPluginSkills: () => skills.map((skill) => ({ pluginId: "fusion-plugin-compound-engineering", skill })), + }; +} + describe("agent-onboarding", () => { beforeEach(() => { vi.clearAllMocks(); @@ -246,6 +267,53 @@ describe("agent-onboarding", () => { expect(prompt).toContain("messageResponseMode: immediate"); }); + it("requests role-fallback and enabled plugin skills for model-only onboarding agents", async () => { + mockCreateFnAgent.mockResolvedValueOnce( + createMockAgent([ + JSON.stringify({ + type: "question", + data: { id: "goal", type: "text", question: "What is the primary goal?" }, + }), + ]), + ); + + await startAgentOnboardingSession( + "127.0.0.1", + { intent: "skills", existingAgents: [], templates: [] }, + process.cwd(), + undefined, + undefined, + undefined, + createSkillPluginRunner([ + { name: "ce-debug" }, + { name: "disabled-skill", enabled: false }, + ]), + ); + + const options = mockCreateFnAgent.mock.calls.at(-1)?.[0] as { skillSelection?: { requestedSkillNames?: string[] } }; + expect(options.skillSelection?.requestedSkillNames).toEqual(["fusion", "ce-debug"]); + }); + + it("requests role-fallback skills when onboarding plugin runner is unavailable", async () => { + mockCreateFnAgent.mockResolvedValueOnce( + createMockAgent([ + JSON.stringify({ + type: "question", + data: { id: "goal", type: "text", question: "What is the primary goal?" }, + }), + ]), + ); + + await startAgentOnboardingSession( + "127.0.0.1", + { intent: "skills", existingAgents: [], templates: [] }, + process.cwd(), + ); + + const options = mockCreateFnAgent.mock.calls.at(-1)?.[0] as { skillSelection?: { requestedSkillNames?: string[] } }; + expect(options.skillSelection?.requestedSkillNames).toEqual(["fusion"]); + }); + it("progresses through start -> question -> response -> final summary", async () => { mockCreateFnAgent.mockResolvedValueOnce( createMockAgent([ diff --git a/packages/dashboard/src/__tests__/milestone-slice-interview.test.ts b/packages/dashboard/src/__tests__/milestone-slice-interview.test.ts index f83c302494..be8c41b089 100644 --- a/packages/dashboard/src/__tests__/milestone-slice-interview.test.ts +++ b/packages/dashboard/src/__tests__/milestone-slice-interview.test.ts @@ -8,6 +8,21 @@ 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?.() ?? []) { + const name = contribution.skill.name.trim(); + if (contribution.skill.enabled === false || name.length === 0 || requestedSkillNames.includes(name)) { + continue; + } + requestedSkillNames.push(name); + } + return { + skillSelectionContext: { projectRootDir, requestedSkillNames, sessionPurpose }, + resolvedSkillNames: requestedSkillNames, + skillSource: "role-fallback" as const, + }; + }, createFnAgent: mockCreateFnAgent, })); @@ -140,6 +155,23 @@ async function waitForCurrentQuestion(sessionId: string): Promise { throw new Error("Timed out waiting for currentQuestion"); } +async function waitForCreateFnAgentOptions(): Promise<{ skillSelection?: { requestedSkillNames?: string[] } }> { + for (let i = 0; i < 50; i++) { + const options = mockCreateFnAgent.mock.calls.at(-1)?.[0]; + if (options) { + return options; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("Timed out waiting for createFnAgent options"); +} + +function createSkillPluginRunner(skills: Array<{ name: string; enabled?: boolean }>) { + return { + getPluginSkills: () => skills.map((skill) => ({ pluginId: "fusion-plugin-compound-engineering", skill })), + }; +} + class MockAiSessionStore extends EventEmitter { rows = new Map(); @@ -235,6 +267,40 @@ describe("milestone-slice-interview module", () => { }); describe("session lifecycle", () => { + it("requests role-fallback and enabled plugin skills for model-only milestone/slice interview agents", async () => { + await createTargetInterviewSession( + "127.0.0.1", + "milestone", + "ms-skills", + "Skillful Milestone", + undefined, + "/tmp/project", + MOCK_TASK_STORE, + createSkillPluginRunner([ + { name: "ce-debug" }, + { name: "disabled-skill", enabled: false }, + ]), + ); + + const options = await waitForCreateFnAgentOptions(); + expect(options.skillSelection?.requestedSkillNames).toEqual(["fusion", "ce-debug"]); + }); + + it("requests role-fallback skills when milestone/slice plugin runner is unavailable", async () => { + await createTargetInterviewSession( + "127.0.0.1", + "slice", + "sl-skills", + "Fallback Slice", + undefined, + "/tmp/project", + MOCK_TASK_STORE, + ); + + const options = await waitForCreateFnAgentOptions(); + expect(options.skillSelection?.requestedSkillNames).toEqual(["fusion"]); + }); + it("creates, retrieves, and cleans up a milestone session", async () => { const sessionId = await createTargetInterviewSession( "127.0.0.1", diff --git a/packages/dashboard/src/agent-onboarding.ts b/packages/dashboard/src/agent-onboarding.ts index d8f762a2a9..e347f2ae9e 100644 --- a/packages/dashboard/src/agent-onboarding.ts +++ b/packages/dashboard/src/agent-onboarding.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import type { AgentCapability, PlanningQuestion } from "@fusion/core"; import { resolvePrompt, type PromptOverrideMap } from "@fusion/core"; -import { createFnAgent as engineCreateFnAgent } from "@fusion/engine"; +import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent } from "@fusion/engine"; import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js"; export interface AgentOnboardingSummary { @@ -61,6 +61,7 @@ export type AgentOnboardingStreamEvent = export type AgentOnboardingStreamCallback = (event: AgentOnboardingStreamEvent, eventId?: number) => void; const createFnAgent: typeof engineCreateFnAgent = engineCreateFnAgent; +type SkillSelectionPluginRunner = Parameters[3]; const SESSION_TTL_MS = 30 * 60 * 1000; const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; const GENERATION_TIMEOUT_MS = 120_000; @@ -281,6 +282,7 @@ export async function startAgentOnboardingSession( modelProvider?: string, modelId?: string, promptOverrides?: PromptOverrideMap, + pluginRunner?: SkillSelectionPluginRunner, ): Promise { const id = randomUUID(); const mode: OnboardingMode = initialContext.mode ?? "create"; @@ -303,11 +305,17 @@ export async function startAgentOnboardingSession( sessions.set(id, session); const systemPrompt = resolvePrompt("agent-onboarding-system", promptOverrides) || AGENT_ONBOARDING_SYSTEM_PROMPT; + const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, pluginRunner); session.agent = await createFnAgent({ cwd: rootDir, systemPrompt, tools: "readonly", ...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}), + /* + FNXC:InterviewSkills 2026-06-17-21:53: + Agent onboarding is a model-only dashboard interview lane, so it must request executor role-fallback skills plus enabled plugin skills such as ce-debug like other agent-acting sessions. + */ + ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), onThinking: (delta: string) => { session.thinkingOutput += delta; agentOnboardingStreamManager.broadcast(session.id, { type: "thinking", data: delta }); diff --git a/packages/dashboard/src/milestone-slice-interview.ts b/packages/dashboard/src/milestone-slice-interview.ts index 7584767b87..fce239de89 100644 --- a/packages/dashboard/src/milestone-slice-interview.ts +++ b/packages/dashboard/src/milestone-slice-interview.ts @@ -96,11 +96,12 @@ function parseTargetInterviewResponseImpl(text: string): TargetInterviewResponse // Export the parse function for tests export { parseTargetInterviewResponseImpl as parseTargetInterviewResponse }; -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 SkillSelectionPluginRunner = Parameters[3]; // eslint-disable-next-line @typescript-eslint/no-explicit-any const createFnAgent: any = engineCreateFnAgent; @@ -730,14 +731,21 @@ async function createTargetInterviewAgent( session: TargetInterviewSession, rootDir: string, store: TaskStore, + pluginRunner?: SkillSelectionPluginRunner, ): Promise { await ensureEngineReady(); + const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, pluginRunner); return createFnAgent({ cwd: rootDir, systemPrompt: getSystemPrompt(session.targetType), tools: "readonly", customTools: [...createPlanningBoardTools(store)], + /* + FNXC:InterviewSkills 2026-06-17-21:42: + Milestone and slice interview agents are model-only tool-loop sessions, so they must request executor role-fallback skills plus enabled plugin skills such as ce-debug instead of creating skill-less dashboard sessions. + */ + ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), onThinking: (delta: string) => { session.thinkingOutput += delta; persistThinking(session.id, session.thinkingOutput); @@ -787,6 +795,7 @@ async function ensureInterviewAgent( rootDir: string | undefined, store: TaskStore | undefined, historyForReplay: Array<{ question: PlanningQuestion; response: unknown }>, + pluginRunner?: SkillSelectionPluginRunner, ): Promise { if (session.agent) { return; @@ -804,7 +813,7 @@ async function ensureInterviewAgent( ); } - session.agent = await createTargetInterviewAgent(session, rootDir, store); + session.agent = await createTargetInterviewAgent(session, rootDir, store, pluginRunner); if (historyForReplay.length === 0) { return; @@ -841,9 +850,14 @@ async function ensureInterviewAgent( /** * Initialize the AI agent for a session and start the first turn. */ -async function initializeAgent(session: TargetInterviewSession, rootDir: string, store: TaskStore): Promise { +async function initializeAgent( + session: TargetInterviewSession, + rootDir: string, + store: TaskStore, + pluginRunner?: SkillSelectionPluginRunner, +): Promise { try { - session.agent = await createTargetInterviewAgent(session, rootDir, store); + session.agent = await createTargetInterviewAgent(session, rootDir, store, pluginRunner); session.updatedAt = new Date(); // Send initial message to get first question @@ -1025,6 +1039,7 @@ export async function createTargetInterviewSession( missionContext: string | undefined, rootDir: string, store: TaskStore, + pluginRunner?: SkillSelectionPluginRunner, ): Promise { if (!checkRateLimit(ip)) { const resetTime = getRateLimitResetTime(ip); @@ -1054,7 +1069,7 @@ export async function createTargetInterviewSession( persistSession(session, "generating"); // Initialize AI agent in background - initializeAgent(session, rootDir, store).catch((err) => { + initializeAgent(session, rootDir, store, 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"); milestoneSliceInterviewStreamManager.broadcast(sessionId, { @@ -1074,6 +1089,7 @@ export async function submitTargetInterviewResponse( responses: Record, rootDir?: string, store?: TaskStore, + pluginRunner?: SkillSelectionPluginRunner, ): Promise { const session = getTargetInterviewSession(sessionId); if (!session) { @@ -1095,7 +1111,7 @@ export async function submitTargetInterviewResponse( if (!session.agent) { const replayHistory = session.history.slice(0, -1); - await ensureInterviewAgent(session, rootDir, store, replayHistory); + await ensureInterviewAgent(session, rootDir, store, replayHistory, pluginRunner); } const message = formatResponseForAgent(session.currentQuestion, responses); @@ -1122,7 +1138,12 @@ export async function submitTargetInterviewResponse( /** * Retry a failed interview session. */ -export async function retryTargetInterviewSession(sessionId: string, rootDir: string, store?: TaskStore): Promise { +export async function retryTargetInterviewSession( + sessionId: string, + rootDir: string, + store?: TaskStore, + pluginRunner?: SkillSelectionPluginRunner, +): Promise { const session = getTargetInterviewSession(sessionId); if (!session) { throw new TargetSessionNotFoundError(`Interview session ${sessionId} not found or expired`); @@ -1149,7 +1170,7 @@ export async function retryTargetInterviewSession(sessionId: string, rootDir: st persistSession(session, "generating"); if (session.history.length === 0) { - await ensureInterviewAgent(session, rootDir, store, []); + await ensureInterviewAgent(session, rootDir, store, [], pluginRunner); await continueAgentConversation( session, `I want to refine the scope for this ${session.targetType}: "${session.targetTitle}".` + @@ -1162,7 +1183,7 @@ export async function retryTargetInterviewSession(sessionId: string, rootDir: st const replayHistory = session.history.slice(0, -1); const lastEntry = session.history[session.history.length - 1]; - await ensureInterviewAgent(session, rootDir, store, replayHistory); + await ensureInterviewAgent(session, rootDir, store, replayHistory, pluginRunner); const replayMessage = formatResponseForAgent( lastEntry.question, coerceResponseRecord(lastEntry.question, lastEntry.response), diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index a95026b7ad..10d38d4cfd 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -3294,6 +3294,7 @@ export function createMissionRouter( missionContext, rootDir, scopedStore, + pluginRunner, ); res.status(201).json({ sessionId }); } catch (err: unknown) { @@ -3347,7 +3348,7 @@ export function createMissionRouter( const { store: scopedStore } = await getProjectContext(req); const rootDir = scopedStore.getRootDir(); - const result = await submitTargetInterviewResponse(sessionId, responses, rootDir, scopedStore); + const result = await submitTargetInterviewResponse(sessionId, responses, rootDir, scopedStore, pluginRunner); res.json(result); } catch (err: unknown) { const errName = err instanceof Error ? err.name : ""; @@ -3511,7 +3512,7 @@ export function createMissionRouter( const { store: scopedStore } = await getProjectContext(req); const rootDir = scopedStore.getRootDir(); - await retryTargetInterviewSession(sessionId, rootDir, scopedStore); + await retryTargetInterviewSession(sessionId, rootDir, scopedStore, pluginRunner); res.json({ success: true, sessionId }); } catch (err: unknown) { const errName = err instanceof Error ? err.name : ""; @@ -3642,6 +3643,7 @@ export function createMissionRouter( missionContext, rootDir, scopedStore, + pluginRunner, ); res.status(201).json({ sessionId }); } catch (err: unknown) { @@ -3695,7 +3697,7 @@ export function createMissionRouter( const { store: scopedStore } = await getProjectContext(req); const rootDir = scopedStore.getRootDir(); - const result = await submitTargetInterviewResponse(sessionId, responses, rootDir, scopedStore); + const result = await submitTargetInterviewResponse(sessionId, responses, rootDir, scopedStore, pluginRunner); res.json(result); } catch (err: unknown) { const errName = err instanceof Error ? err.name : ""; @@ -3859,7 +3861,7 @@ export function createMissionRouter( const { store: scopedStore } = await getProjectContext(req); const rootDir = scopedStore.getRootDir(); - await retryTargetInterviewSession(sessionId, rootDir, scopedStore); + await retryTargetInterviewSession(sessionId, rootDir, scopedStore, pluginRunner); res.json({ success: true, sessionId }); } catch (err: unknown) { const errName = err instanceof Error ? err.name : ""; diff --git a/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts b/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts index ccec071bf4..c8cbed0b2f 100644 --- a/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts +++ b/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts @@ -875,7 +875,7 @@ async function persistImportedSkills( } export function registerAgentGenerationRoutes(ctx: ApiRoutesContext): void { - const { router, getProjectContext, rethrowAsApiError } = ctx; + const { router, getProjectContext, rethrowAsApiError, options } = ctx; const agentGenerationDiagnostics = createSessionDiagnostics("agent-generation"); router.post("/agents/onboarding/start-streaming", async (req, res) => { @@ -922,6 +922,7 @@ export function registerAgentGenerationRoutes(ctx: ApiRoutesContext): void { planningModelProvider, planningModelId, settings.promptOverrides, + options?.pluginRunner as Parameters[3], ); res.status(201).json({ sessionId });