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
This commit is contained in:
5
.changeset/fn-6605-chat-skill-slash-command.md
Normal file
5
.changeset/fn-6605-chat-skill-slash-command.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Load dashboard chat skills requested with `/skill:{name}` and strip the command token from model prompts.
|
||||
@@ -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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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<string>, 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<string>();
|
||||
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<string>();
|
||||
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<void> {
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user