From 19aac38c5171cccce14ed59fe4d55c5059967e23 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 17 Jun 2026 18:37:01 -0700 Subject: [PATCH] FN-6605: load chat skill slash commands Load dashboard chat slash-command skill requests into model-loop sessions. - Parse /skill:{name} tokens in main chat, QuickChat, and room responders. - Merge typed skill requests with existing agent and plugin skill selection while preserving normal filters. - Strip slash-command tokens from model prompts without mutating persisted chat history. - Cover slash-command parsing, deduplication, prompt stripping, and room responder skill selection. - Document dashboard chat skill command behavior and add a published package changeset. Files changed: .changeset/fn-6605-chat-skill-slash-command.md | 5 + docs/agents.md | 1 + docs/dashboard-guide.md | 1 + .../dashboard/src/__tests__/chat-manager.test.ts | 158 +++++++++++++++++++++ .../dashboard/src/__tests__/chat.rooms.test.ts | 46 ++++++ packages/dashboard/src/chat.ts | 100 ++++++++++++- 6 files changed, 307 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-6605 Fusion-Task-Lineage: 4ad2e015-30a5-4608-b69c-e42e46166f0e --- .../fn-6605-chat-skill-slash-command.md | 5 + docs/agents.md | 1 + docs/dashboard-guide.md | 1 + .../src/__tests__/chat-manager.test.ts | 158 ++++++++++++++++++ .../src/__tests__/chat.rooms.test.ts | 46 +++++ packages/dashboard/src/chat.ts | 100 ++++++++++- 6 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-6605-chat-skill-slash-command.md diff --git a/.changeset/fn-6605-chat-skill-slash-command.md b/.changeset/fn-6605-chat-skill-slash-command.md new file mode 100644 index 0000000000..0a6aef9db7 --- /dev/null +++ b/.changeset/fn-6605-chat-skill-slash-command.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Load dashboard chat skills requested with `/skill:{name}` and strip the command token from model prompts. diff --git a/docs/agents.md b/docs/agents.md index 205cb51892..590a38b01a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -24,6 +24,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - Each message is stored as a `user-to-agent` MessageStore message from `cli` with `metadata.wakeRecipient=true`. - Agent replies are polled from your inbox and printed as they arrive. - Dashboard-created agent chat sessions request the target agent's declared `metadata.skills` plus enabled plugin-contributed skills, so skills such as `ce-debug` are available in chat when the contributing plugin is enabled. Model-only QuickChat sessions request enabled plugin skills, and room responder sessions request the responder agent's skills. +- 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/docs/dashboard-guide.md b/docs/dashboard-guide.md index e02092d650..894c5972b4 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -290,6 +290,7 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv - Controlled by the project setting `showQuickChatFAB` - Supports agent mentions (`@agent`) and shared `#` task/file mentions +- Supports `/skill:{name}` in model-loop chat to request a specific enabled skill for that session; the slash token is removed from the model prompt while the original user message remains in chat history - Uses the same model/provider infrastructure as full Chat view - On small screens, compact tool-call summaries in the floating panel intentionally stay single-line (count + tool names + status) to preserve message density - The panel header uses a session-first flow: the main dropdown lists persisted sessions (preferring `session.title`, then falling back to deterministic `Session N` labels) diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index 4bdc369f79..d1ba758386 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -706,6 +706,164 @@ describe("ChatManager.sendMessage", () => { expect(createOptions.skillSelection.requestedSkillNames).toEqual(["fusion", "ce-debug"]); }); + it("loads a single-segment /skill command and strips it from the chat prompt", async () => { + const promptSpy = vi.fn().mockResolvedValue(undefined); + let createOptions: any; + __setCreateResolvedAgentSession(async (options: any) => { + createOptions = options; + return { + session: { + prompt: promptSpy, + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Skill command ready" }] }, + }, + }; + }); + mockAgentStore.getAgent.mockResolvedValue({ + id: "agent-001", + name: "Avery", + role: "executor", + runtimeConfig: {}, + metadata: { skills: ["agent-debug"] }, + }); + + const chatManager = createChatManager(); + await chatManager.sendMessage("chat-001", "/skill:ce-debug please debug this"); + + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["agent-debug", "ce-debug"]); + expect(promptSpy).toHaveBeenCalledTimes(1); + const promptContent = promptSpy.mock.calls[0]?.[0] as string; + expect(promptContent).toBe("please debug this"); + expect(promptContent).not.toContain("/skill:"); + expect(mockChatStore.addMessage).toHaveBeenCalledWith("chat-001", expect.objectContaining({ + role: "user", + content: "/skill:ce-debug please debug this", + })); + }); + + it("loads two-segment and multiple /skill commands in typed order", async () => { + const promptSpy = vi.fn().mockResolvedValue(undefined); + let createOptions: any; + __setCreateResolvedAgentSession(async (options: any) => { + createOptions = options; + return { + session: { + prompt: promptSpy, + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Multiple skills ready" }] }, + }, + }; + }); + mockAgentStore.getAgent.mockResolvedValue({ + id: "agent-001", + name: "Avery", + role: "executor", + runtimeConfig: {}, + metadata: { skills: [] }, + }); + + const chatManager = createChatManager(); + await chatManager.sendMessage("chat-001", "start /skill:review/pr please /skill:gamma now"); + + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["fusion", "review/pr", "gamma"]); + const promptContent = promptSpy.mock.calls[0]?.[0] as string; + expect(promptContent).toBe("start please now"); + expect(promptContent).not.toContain("/skill:"); + }); + + it("dedupes typed /skill commands against agent and plugin skills case-insensitively", async () => { + const promptSpy = vi.fn().mockResolvedValue(undefined); + let createOptions: any; + __setCreateResolvedAgentSession(async (options: any) => { + createOptions = options; + return { + session: { + prompt: promptSpy, + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Deduped" }] }, + }, + }; + }); + mockAgentStore.getAgent.mockResolvedValue({ + id: "agent-001", + name: "Avery", + role: "executor", + runtimeConfig: {}, + metadata: { skills: ["ce-debug"] }, + }); + const pluginRunner = { + getPluginSkills: vi.fn(() => [ + { pluginId: "fusion-plugin-compound-engineering", skill: { name: "review/pr", enabled: true } }, + ]), + }; + + const chatManager = createChatManager(pluginRunner); + await chatManager.sendMessage("chat-001", "/skill:CE-DEBUG /skill:review/pr/SKILL.md use both"); + + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["ce-debug", "review/pr"]); + const names = createOptions.skillSelection.requestedSkillNames.filter((name: string) => name.toLowerCase() === "ce-debug"); + expect(names).toHaveLength(1); + expect(promptSpy.mock.calls[0]?.[0]).toBe("use both"); + }); + + it("creates skill selection for model-only QuickChat when /skill is typed without plugin skills", async () => { + mockChatStore.getSession.mockReturnValue({ + id: "chat-001", + agentId: null, + status: "active", + }); + const promptSpy = vi.fn().mockResolvedValue(undefined); + let createOptions: any; + __setCreateResolvedAgentSession(async (options: any) => { + createOptions = options; + return { + session: { + prompt: promptSpy, + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Model-only skill ready" }] }, + }, + }; + }); + + const chatManager = createChatManager(); + await chatManager.sendMessage("chat-001", "/skill:foo answer directly"); + + expect(createOptions.skillSelection).toMatchObject({ + projectRootDir: "/tmp/test", + sessionPurpose: "executor", + }); + expect(createOptions.skillSelection.requestedSkillNames).toContain("foo"); + expect(promptSpy.mock.calls[0]?.[0]).toBe("answer directly"); + }); + + it("leaves skill selection and prompt content unchanged when no /skill command is present", async () => { + const promptSpy = vi.fn().mockResolvedValue(undefined); + let createOptions: any; + __setCreateResolvedAgentSession(async (options: any) => { + createOptions = options; + return { + session: { + prompt: promptSpy, + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Plain reply" }] }, + }, + }; + }); + mockAgentStore.getAgent.mockResolvedValue({ + id: "agent-001", + name: "Avery", + role: "executor", + runtimeConfig: {}, + metadata: { skills: ["agent-debug"] }, + }); + + const chatManager = createChatManager(); + await chatManager.sendMessage("chat-001", "plain hello"); + + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["agent-debug"]); + expect(promptSpy.mock.calls[0]?.[0]).toBe("plain hello"); + }); + it("keeps agent skills when the chat plugin runner lacks skill discovery", async () => { let createOptions: any; __setCreateResolvedAgentSession(async (options: any) => { diff --git a/packages/dashboard/src/__tests__/chat.rooms.test.ts b/packages/dashboard/src/__tests__/chat.rooms.test.ts index 4b2cbd9636..ec4ae1a945 100644 --- a/packages/dashboard/src/__tests__/chat.rooms.test.ts +++ b/packages/dashboard/src/__tests__/chat.rooms.test.ts @@ -140,6 +140,52 @@ describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => { expect(createOptions.skillSelection.requestedSkillNames).not.toContain("disabled-debug"); }); + it("loads typed /skill commands for room responders and strips them from the room prompt", 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"] }, + }, + ]); + const promptSpy = vi.fn().mockResolvedValue(undefined); + let createOptions: any; + __setCreateResolvedAgentSession(async (options: any) => { + createOptions = options; + return { + session: { + prompt: promptSpy, + dispose: vi.fn(), + state: { + messages: [{ role: "assistant", content: "Room reply" }], + }, + }, + } as any; + }); + + const manager = new ChatManager(mockChatStore as any, "/tmp", mockAgentStore as any); + await manager.sendRoomMessage("room-1", "/skill:ce-debug hello @Alpha"); + + expect(createOptions.skillSelection).toMatchObject({ + projectRootDir: "/tmp", + sessionPurpose: "heartbeat", + }); + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["room-agent-debug", "ce-debug"]); + expect(promptSpy).toHaveBeenCalledTimes(1); + const roomPrompt = promptSpy.mock.calls[0]?.[0] as string; + expect(roomPrompt).toContain("Latest user message to answer:\n\nhello @Alpha"); + expect(roomPrompt).not.toContain("/skill:"); + expect(mockChatStore.addRoomMessage.mock.calls[0]?.[1]).toMatchObject({ + role: "user", + content: "/skill:ce-debug hello @Alpha", + }); + }); + 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 08b8cf27f9..ca2736e86e 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -26,6 +26,7 @@ import type { Settings, TaskStore, } from "@fusion/core"; +import type { SkillSelectionContext } from "@fusion/engine"; import { summarizeTitle } from "@fusion/core"; import { EventEmitter } from "node:events"; import { existsSync } from "node:fs"; @@ -117,6 +118,79 @@ const diagnostics: DiagnosticsLogger = { }, }; +const SKILL_COMMAND_PATTERN = /(^|\s)\/skill:([^\s]+)/gi; + +function bareChatSkillCommandName(name: string): string { + return name + .replace(/\/SKILL\.md$/i, "") + .replace(/[.,;!?)]*$/g, "") + .trim(); +} + +function pushDedupedSkillName(names: string[], seen: Set, name: string): void { + const bareName = bareChatSkillCommandName(name); + if (!bareName) { + return; + } + const key = bareName.toLowerCase(); + if (seen.has(key)) { + return; + } + seen.add(key); + names.push(bareName); +} + +function parseSkillCommands(content: string): { requestedSkillNames: string[]; strippedContent: string } { + const requestedSkillNames: string[] = []; + const seen = new Set(); + let foundCommand = false; + + const strippedContent = content.replace(SKILL_COMMAND_PATTERN, (match, leadingWhitespace: string, rawName: string) => { + foundCommand = true; + pushDedupedSkillName(requestedSkillNames, seen, rawName); + return leadingWhitespace ? " " : ""; + }); + + if (!foundCommand) { + return { requestedSkillNames, strippedContent: content }; + } + + return { + requestedSkillNames, + strippedContent: strippedContent.replace(/\s+/g, " ").trim(), + }; +} + +function mergeTypedSkillCommands( + baseSkillSelection: SkillSelectionContext | undefined, + typedSkillNames: string[], + projectRootDir: string, + sessionPurpose: string, +): SkillSelectionContext | undefined { + if (typedSkillNames.length === 0) { + return baseSkillSelection; + } + + const requestedSkillNames: string[] = []; + const seen = new Set(); + for (const name of baseSkillSelection?.requestedSkillNames ?? []) { + pushDedupedSkillName(requestedSkillNames, seen, name); + } + for (const name of typedSkillNames) { + pushDedupedSkillName(requestedSkillNames, seen, name); + } + + /* + FNXC:ChatSkills 2026-06-17-18:16: + The advertised chat `/skill:{name}` command must request that skill for the model-loop session while keeping execution settings authoritative; this merge only adds requested names to the existing skill-selection context so the resolver still filters disabled or excluded skills. + */ + return { + projectRootDir: baseSkillSelection?.projectRootDir ?? projectRootDir, + requestedSkillNames, + sessionPurpose: baseSkillSelection?.sessionPurpose ?? sessionPurpose, + }; +} + async function ensureEngineReady(): Promise { if (buildAgentChatPromptFn) { return; @@ -1318,6 +1392,7 @@ export class ChatManager { diagnostics, ); const attachmentContentBlock = formatChatAttachmentContents(attachmentContents); + const parsedSkillCommands = parseSkillCommands(input.content); const roomPromptParts = [ `You are replying as ${input.responder.name} in room #${input.roomName}.`, "Reply to the latest user room message in the context of this shared room thread.", @@ -1327,7 +1402,7 @@ export class ChatManager { summaryMaxChars: roomCompactionSettings.summaryMaxChars, }), "Latest user message to answer:", - input.content, + parsedSkillCommands.strippedContent, ]; if (attachmentContentBlock) { roomPromptParts.push(attachmentContentBlock); @@ -1347,6 +1422,12 @@ export class ChatManager { this.rootDir, this.getPluginRunnerForSkillSelection(), ); + const mergedRoomSkillSelection = mergeTypedSkillCommands( + roomSkillContext.skillSelectionContext, + parsedSkillCommands.requestedSkillNames, + this.rootDir, + "heartbeat", + ); const resolvedSession = await createResolvedAgentSession({ sessionPurpose: "heartbeat", @@ -1355,8 +1436,11 @@ export class ChatManager { /* 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. + + FNXC:ChatSkills 2026-06-17-18:16: + Room responders share the chat slash-command contract: `/skill:{name}` is removed from the prompt text and merged into heartbeat skill selection without changing persisted room-message text. */ - ...(roomSkillContext.skillSelectionContext ? { skillSelection: roomSkillContext.skillSelectionContext } : {}), + ...(mergedRoomSkillSelection ? { skillSelection: mergedRoomSkillSelection } : {}), cwd: this.rootDir, systemPrompt, tools: "coding", @@ -1559,6 +1643,8 @@ export class ChatManager { updatedAt: new Date().toISOString(), }); + const parsedSkillCommands = parseSkillCommands(content); + const hasMentionCandidates = /@[\w-]+/.test(content); const mentionAgents = hasMentionCandidates ? await this.listAgentsForMentions() : []; const mentions = hasMentionCandidates ? await this.parseMentions(content, mentionAgents) : []; @@ -1670,7 +1756,7 @@ export class ChatManager { } // Resolve #file references in the current message before sending to AI - const resolvedContent = await resolveFileReferences(content, this.rootDir); + const resolvedContent = await resolveFileReferences(parsedSkillCommands.strippedContent, this.rootDir); const attachmentSummary = attachments && attachments.length > 0 ? `[User attached: ${attachments @@ -1815,6 +1901,12 @@ export class ChatManager { this.rootDir, this.getPluginRunnerForSkillSelection(), ); + const mergedChatSkillSelection = mergeTypedSkillCommands( + chatSkillContext.skillSelectionContext, + parsedSkillCommands.requestedSkillNames, + this.rootDir, + "executor", + ); agentResult = await createResolvedAgentSession({ sessionPurpose: "executor", ...(agentRuntimeHint ? { runtimeHint: agentRuntimeHint } : {}), @@ -1823,7 +1915,7 @@ export class ChatManager { 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 } : {}), + ...(mergedChatSkillSelection ? { skillSelection: mergedChatSkillSelection } : {}), ...sessionOptions, }); this.activeGenerations.set(sessionId, { abortController, agentResult, generationId });