From 21c4d3e5ca2ac268954028cc9a8d5e13e6810cef Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 16 Jun 2026 19:40:56 -0700 Subject: [PATCH] FN-6495: load chat session skills Enable dashboard chat sessions to request the same agent and plugin skills used by execution lanes. - Pass enabled plugin-contributed skills through the dashboard chat plugin runner contract. - Add skill selection for bound-agent chat, model-only QuickChat, and room responder sessions. - Cover regular chat, QuickChat, no-metadata fallback, legacy runner, and room responder skill requests. - Document dashboard chat skill behavior and add a minor changeset for the published CLI package. Files changed: .changeset/fn-6495-chat-skill-selection.md | 5 + docs/agents.md | 1 + .../dashboard/src/__tests__/chat-manager.test.ts | 125 +++++++++++++++++++++ .../dashboard/src/__tests__/chat.rooms.test.ts | 45 ++++++++ packages/dashboard/src/chat.ts | 35 ++++++ packages/dashboard/src/server.ts | 5 + packages/engine/src/index.ts | 6 + 7 files changed, 222 insertions(+) Fusion-Task-Id: FN-6495 Fusion-Task-Lineage: 0b8998e2-848c-4c79-b93a-16c0b3c3d8a3 --- .changeset/fn-6495-chat-skill-selection.md | 5 + docs/agents.md | 1 + .../src/__tests__/chat-manager.test.ts | 125 ++++++++++++++++++ .../src/__tests__/chat.rooms.test.ts | 45 +++++++ packages/dashboard/src/chat.ts | 35 +++++ packages/dashboard/src/server.ts | 5 + packages/engine/src/index.ts | 6 + 7 files changed, 222 insertions(+) create mode 100644 .changeset/fn-6495-chat-skill-selection.md diff --git a/.changeset/fn-6495-chat-skill-selection.md b/.changeset/fn-6495-chat-skill-selection.md new file mode 100644 index 0000000000..fb63ecbf80 --- /dev/null +++ b/.changeset/fn-6495-chat-skill-selection.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Dashboard agent chat sessions now load the same agent-declared and enabled plugin-contributed skills as task execution sessions, so plugin skills such as `ce-debug` are available in chat. diff --git a/docs/agents.md b/docs/agents.md index 7d91809dea..205cb51892 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -23,6 +23,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - `fn chat ` opens an interactive REPL. - 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. ### Flags diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index 05500acb8c..ae93609c84 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -600,6 +600,131 @@ describe("ChatManager.sendMessage", () => { expect(createOptions.tools).toBe("coding"); }); + it("requests bound agent and enabled plugin skills for regular chat", async () => { + let createOptions: any; + __setCreateResolvedAgentSession(async (options: any) => { + createOptions = options; + return { + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Skills ready" }] }, + }, + }; + }); + mockAgentStore.getAgent.mockResolvedValue({ + id: "agent-001", + name: "Avery", + role: "executor", + runtimeConfig: {}, + metadata: { skills: ["agent-debug", "ce-debug"] }, + }); + const pluginRunner = { + getPluginSkills: vi.fn(() => [ + { pluginId: "fusion-plugin-compound-engineering", skill: { name: "ce-debug", enabled: true } }, + { pluginId: "disabled-plugin", skill: { name: "disabled-debug", enabled: false } }, + ]), + }; + + const chatManager = createChatManager(pluginRunner); + await chatManager.sendMessage("chat-001", "Hello"); + + expect(pluginRunner.getPluginSkills).toHaveBeenCalledTimes(1); + expect(createOptions.skillSelection).toMatchObject({ + projectRootDir: "/tmp/test", + sessionPurpose: "executor", + }); + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["agent-debug", "ce-debug"]); + expect(createOptions.skillSelection.requestedSkillNames).not.toContain("disabled-debug"); + }); + + it("requests enabled plugin skills for model-only QuickChat sessions", async () => { + mockChatStore.getSession.mockReturnValue({ + id: "chat-001", + agentId: null, + status: "active", + }); + let createOptions: any; + __setCreateResolvedAgentSession(async (options: any) => { + createOptions = options; + return { + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Plugin skill ready" }] }, + }, + }; + }); + const pluginRunner = { + getPluginSkills: vi.fn(() => [ + { pluginId: "fusion-plugin-compound-engineering", skill: { name: "ce-debug" } }, + ]), + }; + + const chatManager = createChatManager(pluginRunner); + await chatManager.sendMessage("chat-001", "Hello"); + + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["fusion", "ce-debug"]); + expect(createOptions.skillSelection.sessionPurpose).toBe("executor"); + }); + + it("merges plugin skills when a bound chat agent has no metadata skills", async () => { + let createOptions: any; + __setCreateResolvedAgentSession(async (options: any) => { + createOptions = options; + return { + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Fallback skills ready" }] }, + }, + }; + }); + mockAgentStore.getAgent.mockResolvedValue({ + id: "agent-001", + name: "Avery", + role: "executor", + runtimeConfig: {}, + metadata: {}, + }); + const pluginRunner = { + getPluginSkills: vi.fn(() => [ + { pluginId: "fusion-plugin-compound-engineering", skill: { name: "ce-debug", enabled: true } }, + ]), + }; + + const chatManager = createChatManager(pluginRunner); + await chatManager.sendMessage("chat-001", "Hello"); + + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["fusion", "ce-debug"]); + }); + + it("keeps agent skills when the chat plugin runner lacks skill discovery", async () => { + let createOptions: any; + __setCreateResolvedAgentSession(async (options: any) => { + createOptions = options; + return { + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Agent skill ready" }] }, + }, + }; + }); + mockAgentStore.getAgent.mockResolvedValue({ + id: "agent-001", + name: "Avery", + role: "executor", + runtimeConfig: {}, + metadata: { skills: ["agent-debug"] }, + }); + + const chatManager = createChatManager({ getRuntimeById: vi.fn() }); + await chatManager.sendMessage("chat-001", "Hello"); + + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["agent-debug"]); + }); + it("accumulates thinking output separately from text", async () => { let onThinkingCb: ((delta: string) => void) | undefined; let onTextCb: ((delta: string) => void) | undefined; diff --git a/packages/dashboard/src/__tests__/chat.rooms.test.ts b/packages/dashboard/src/__tests__/chat.rooms.test.ts index 3a32ed6940..4b2cbd9636 100644 --- a/packages/dashboard/src/__tests__/chat.rooms.test.ts +++ b/packages/dashboard/src/__tests__/chat.rooms.test.ts @@ -95,6 +95,51 @@ describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => { expect(assistantWrite).toMatchObject({ role: "assistant", senderAgentId: "agent-a", content: "Room reply" }); }); + it("requests responder and enabled plugin skills for room responder sessions", async () => { + mockChatStore.listRoomMembers.mockReturnValue([ + { roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" }, + ]); + mockAgentStore.listAgents.mockResolvedValue([ + { + id: "agent-a", + name: "Alpha", + role: "executor", + runtimeConfig: {}, + metadata: { skills: ["room-agent-debug"] }, + }, + ]); + let createOptions: any; + __setCreateResolvedAgentSession(async (options: any) => { + createOptions = options; + return { + session: { + prompt: vi.fn(), + dispose: vi.fn(), + state: { + messages: [{ role: "assistant", content: "Room reply" }], + }, + }, + } as any; + }); + const pluginRunner = { + getPluginSkills: vi.fn(() => [ + { pluginId: "fusion-plugin-compound-engineering", skill: { name: "ce-debug", enabled: true } }, + { pluginId: "disabled-plugin", skill: { name: "disabled-debug", enabled: false } }, + ]), + }; + + const manager = new ChatManager(mockChatStore as any, "/tmp", mockAgentStore as any, pluginRunner as any); + await manager.sendRoomMessage("room-1", "hello @Alpha"); + + expect(pluginRunner.getPluginSkills).toHaveBeenCalledTimes(1); + expect(createOptions.skillSelection).toMatchObject({ + projectRootDir: "/tmp", + sessionPurpose: "heartbeat", + }); + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["room-agent-debug", "ce-debug"]); + expect(createOptions.skillSelection.requestedSkillNames).not.toContain("disabled-debug"); + }); + it("suppresses trimmed skip sentinel replies while persisting normal co-responder replies", async () => { mockChatStore.listRoomMembers.mockReturnValue([ { roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" }, diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 25e442b95f..e496925993 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -39,6 +39,7 @@ import { promptWithFallback as enginePromptWithFallback, extractRuntimeHint, extractRuntimeModel, + buildSessionSkillContextSync, createSendMessageTool, createReadMessagesTool, createWorkflowAuthoringTools, @@ -716,6 +717,11 @@ export class ChatManager { private pluginRunner?: { getRuntimeById?(runtimeId: string): unknown; createRuntimeContext?(pluginId: string): Promise; + /* + FNXC:ChatSkills 2026-06-16-19:10: + Agent chat receives the project plugin runner through this narrow structural type, so expose enabled plugin skill contributions here without requiring dashboard code to depend on the full engine runner class. + */ + getPluginSkills?(): Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }>; }, private getSettings?: () => Promise[3] { + return this.pluginRunner?.getPluginSkills + ? (this.pluginRunner as unknown as Parameters[3]) + : undefined; + } + /** * Runner for CLI-agent-backed chat sessions (CLI Agent Executor). When a chat * session selects a cli-agent executor (`cliExecutorAdapterId`), composer sends @@ -1308,10 +1320,22 @@ export class ChatManager { const allowFallback = !(input.modelProvider && input.modelId) && !(responderRuntimeModel.provider && responderRuntimeModel.modelId); + const roomSkillContext = buildSessionSkillContextSync( + input.responder, + "heartbeat", + this.rootDir, + this.getPluginRunnerForSkillSelection(), + ); + const resolvedSession = await createResolvedAgentSession({ sessionPurpose: "heartbeat", pluginRunner: this.pluginRunner, runtimeHint: extractRuntimeHint(input.responder.runtimeConfig), + /* + FNXC:ChatSkills 2026-06-16-19:13: + Chat-room responder sessions must request the responder agent skills plus enabled plugin skills so chat-only agent replies can use skills such as ce-debug just like heartbeat/executor lanes. + */ + ...(roomSkillContext.skillSelectionContext ? { skillSelection: roomSkillContext.skillSelectionContext } : {}), cwd: this.rootDir, systemPrompt, tools: "coding", @@ -1748,10 +1772,21 @@ export class ChatManager { // `cleanupSessionResources(sessionId)` tear-down across overlapping // sessions opened from the same CLI session file. const agentRuntimeHint = agent ? extractRuntimeHint(agent.runtimeConfig) : undefined; + const chatSkillContext = buildSessionSkillContextSync( + agent ?? null, + "executor", + this.rootDir, + this.getPluginRunnerForSkillSelection(), + ); agentResult = await createResolvedAgentSession({ sessionPurpose: "executor", ...(agentRuntimeHint ? { runtimeHint: agentRuntimeHint } : {}), pluginRunner: this.pluginRunner, + /* + FNXC:ChatSkills 2026-06-16-19:13: + Regular chat and QuickChat must request bound-agent skills plus enabled plugin skills so dashboard chat loads capabilities such as ce-debug instead of creating skill-less sessions. + */ + ...(chatSkillContext.skillSelectionContext ? { skillSelection: chatSkillContext.skillSelectionContext } : {}), ...sessionOptions, }); this.activeGenerations.set(sessionId, { abortController, agentResult, generationId }); diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 360a8eec8a..f94f613b2a 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -301,6 +301,11 @@ export interface ServerOptions { getPluginWorkflowStepTemplates?(): Array<{ pluginId: string; template: import("@fusion/core").WorkflowStepTemplate }>; getRuntimeById?(runtimeId: string): unknown; createRuntimeContext?(pluginId: string): Promise; + /* + FNXC:ChatSkills 2026-06-16-19:10: + The dashboard passes this structural runner into ChatManager, which needs optional plugin skill discovery so chat can load enabled plugin skills such as ce-debug. + */ + getPluginSkills?(): Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }>; reloadPlugin?(pluginId: string): Promise; checkPluginSetup?(pluginId: string): Promise; installPluginSetup?(pluginId: string): Promise; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 47ddc10d69..6765f8b3b2 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -366,6 +366,12 @@ export { type SkillSelectionResult, type SkillDiagnostic, } from "./skill-resolver.js"; +/* +FNXC:ChatSkills 2026-06-16-19:08: +Dashboard chat consumes the synchronous session skill helper so chat sessions request the same agent and enabled plugin skills as executor sessions. +Do not re-export the local SessionPurpose from session-skill-context here because runtime-resolution already owns the public SessionPurpose export. +*/ +export { buildSessionSkillContextSync, type SessionSkillContextResult } from "./session-skill-context.js"; export { AgentReflectionService, type AgentReflectionServiceOptions } from "./agent-reflection.js"; export { AgentSelfImproveService, type AgentSelfImproveServiceOptions } from "./agent-self-improve.js"; export {