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
This commit is contained in:
gsxdsm
2026-06-16 19:40:56 -07:00
parent bb25eb927d
commit 21c4d3e5ca
7 changed files with 222 additions and 0 deletions

View File

@@ -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.

View File

@@ -23,6 +23,7 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
- `fn chat <agent-id>` 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

View File

@@ -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;

View File

@@ -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" },

View File

@@ -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<unknown>;
/*
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<Pick<Settings,
| "fallbackProvider"
@@ -741,6 +747,12 @@ export class ChatManager {
private taskStore?: TaskStore,
) {}
private getPluginRunnerForSkillSelection(): Parameters<typeof buildSessionSkillContextSync>[3] {
return this.pluginRunner?.getPluginSkills
? (this.pluginRunner as unknown as Parameters<typeof buildSessionSkillContextSync>[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 });

View File

@@ -301,6 +301,11 @@ export interface ServerOptions {
getPluginWorkflowStepTemplates?(): Array<{ pluginId: string; template: import("@fusion/core").WorkflowStepTemplate }>;
getRuntimeById?(runtimeId: string): unknown;
createRuntimeContext?(pluginId: string): Promise<unknown>;
/*
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<unknown>;
checkPluginSetup?(pluginId: string): Promise<import("@fusion/core").PluginSetupCheckResult>;
installPluginSetup?(pluginId: string): Promise<void | { success: boolean; error?: string }>;

View File

@@ -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 {