FN-8443: fix chat plugin skill loading

Forward enabled plugin skill body paths into chat sessions.

- Pass resolved plugin skill directories through dashboard chat, QuickChat, and room responder sessions.
- Preserve additional skill paths in the runtime session factory and cover both forwarding layers.
- Document the chat skill contract and add a patch changeset.

Files changed:
 .changeset/fn-8443-chat-plugin-skill-paths.md      |  7 +++
 docs/agents.md                                     |  2 +-
 packages/dashboard/src/__tests__/chat-manager.test.ts   | 60 ++++++++++++++++++++--
 packages/dashboard/src/chat.ts                     | 12 +++--
 packages/engine/src/__tests__/agent-session-helpers.test.ts | 37 +++++++++++++
 packages/engine/src/agent-session-helpers.ts       |  5 ++
 6 files changed, 114 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-8443

Fusion-Task-Lineage: 92c4e32c-8c94-4e8e-ad62-a5dae2fb9dc3

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-20 12:32:01 -07:00
parent 8c01cc7c7a
commit df488f25ea
6 changed files with 114 additions and 9 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Deliver enabled plugin skills in dashboard chat the same way task sessions do (include skill body paths).
category: fix
dev: Chat and room-responder sessions now forward buildSessionSkillContextSync.additionalSkillPaths into createResolvedAgentSession so the pi loader can discover plugin SKILL.md bodies (GitHub #2364 / FN-8443; completes chat half of #2017).

View File

@@ -37,7 +37,7 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>] [--
- On a direct-message reply, agents must pass `reply_to_message_id` and either set `to_id` to the exact `[from: type:id]` value reported by `fn_read_messages` (including `cli`) or omit it to use the safe parent-sender default. Parent-derived routing is allowed only when the parent was addressed to the replying agent; an explicit `to_id` remains available for intentional forwarding.
- The default conversation ID is `cli-chat:cli:<agent-id>`; use `--conversation-id <id>` to name or share a different mailbox thread.
- One-shot replies have a deadline independent of `--poll-ms`; polling sleeps are capped at the remaining deadline. The interactive REPL maintains one pending deadline per outbound message, reports and clears an unanswered request, then continues to receive later replies.
- 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 for the requesting project. Model-only QuickChat sessions request enabled plugin skills, and room responder sessions request the responder agent's skills.
- Dashboard-created agent chat sessions request the target agent's declared `metadata.skills` plus enabled plugin-contributed skills, forwarding both requested skill names and resolved plugin body directories so skills such as `ce-debug` are available in chat when the contributing plugin is enabled for the requesting project. 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, 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. Slash and catalog-style names such as `/skill:review/pr`, `/skill:review/pr/SKILL.md`, and `source::skills/review/pr/SKILL.md` resolve to the matching discovered bare skill token across chat and agent session lanes. 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.
- Dashboard chat and planning sessions with a scoped task store expose `fn_task_document_write`, `fn_task_document_read`, and `fn_task_logs_read`; because neither lane has an ambient task, each tool requires an explicit `task_id`. `fn_task_logs_read` pages the persisted full agent log for failure analysis.

View File

@@ -9,7 +9,7 @@ FN-6444 confirmed this ChatManager API-path suite is deterministic under dashboa
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import {
ChatManager,
@@ -1200,10 +1200,12 @@ describe("ChatManager.sendMessage", () => {
runtimeConfig: {},
metadata: { skills: ["agent-debug", "ce-debug"] },
});
const pluginRoot = "/tmp/plugin-chat-skills";
const pluginSkillDir = join(pluginRoot, "skills", "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 } },
{ pluginId: "fusion-plugin-compound-engineering", pluginRoot, skill: { name: "ce-debug", enabled: true } },
{ pluginId: "disabled-plugin", pluginRoot, skill: { name: "disabled-debug", enabled: false } },
]),
};
@@ -1217,6 +1219,7 @@ describe("ChatManager.sendMessage", () => {
});
expect(createOptions.skillSelection.requestedSkillNames).toEqual(["agent-debug", "ce-debug"]);
expect(createOptions.skillSelection.requestedSkillNames).not.toContain("disabled-debug");
expect(createOptions.additionalSkillPaths).toEqual([pluginSkillDir, dirname(pluginSkillDir)]);
});
it("requests enabled plugin skills for model-only QuickChat sessions", async () => {
@@ -1236,9 +1239,11 @@ describe("ChatManager.sendMessage", () => {
},
};
});
const pluginRoot = "/tmp/plugin-quick-chat-skills";
const pluginSkillDir = join(pluginRoot, "skills", "ce-debug");
const pluginRunner = {
getPluginSkills: vi.fn(() => [
{ pluginId: "fusion-plugin-compound-engineering", skill: { name: "ce-debug" } },
{ pluginId: "fusion-plugin-compound-engineering", pluginRoot, skill: { name: "ce-debug" } },
]),
};
@@ -1247,6 +1252,7 @@ describe("ChatManager.sendMessage", () => {
expect(createOptions.skillSelection.requestedSkillNames).toEqual(["fusion", "ce-debug"]);
expect(createOptions.skillSelection.sessionPurpose).toBe("executor");
expect(createOptions.additionalSkillPaths).toEqual([pluginSkillDir, dirname(pluginSkillDir)]);
});
it("merges plugin skills when a bound chat agent has no metadata skills", async () => {
@@ -1278,6 +1284,7 @@ describe("ChatManager.sendMessage", () => {
await chatManager.sendMessage("chat-001", "Hello");
expect(createOptions.skillSelection.requestedSkillNames).toEqual(["fusion", "ce-debug"]);
expect(createOptions).not.toHaveProperty("additionalSkillPaths");
});
it("loads a single-segment /skill command and strips it from the chat prompt", async () => {
@@ -3724,6 +3731,51 @@ describe("ChatManager generation isolation", () => {
expect(new Set(names).size).toBe(names.length);
});
it("sendRoomMessage forwards enabled plugin skill names and body paths to responders", async () => {
(mockChatStore as any).getRoom = vi.fn().mockReturnValue({ id: "room-1", name: "team" });
(mockChatStore as any).listRoomMembers = vi.fn().mockReturnValue([
{ roomId: "room-1", agentId: "agent-001", role: "member", addedAt: "2026-01-01" },
]);
(mockChatStore as any).addRoomMessage = vi.fn().mockImplementation((_roomId: string, input: any) => ({
id: "room-msg",
roomId: "room-1",
...input,
}));
mockAgentStore.listAgents.mockResolvedValue([{ id: "agent-001", name: "Avery", role: "executor", state: "idle" }]);
mockAgentStore.getAgent.mockResolvedValue({
id: "agent-001",
name: "Avery",
role: "executor",
state: "idle",
metadata: { skills: ["agent-debug"] },
});
let createOptions: any;
__setCreateResolvedAgentSession(async (options: any) => {
createOptions = options;
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: { messages: [{ role: "assistant", content: "Room answer" }] },
},
};
});
const pluginRoot = "/tmp/plugin-room-chat-skills";
const pluginSkillDir = join(pluginRoot, "skills", "ce-debug");
const pluginRunner = {
getPluginSkills: vi.fn(() => [
{ pluginId: "fusion-plugin-compound-engineering", pluginRoot, skill: { name: "ce-debug", enabled: true } },
]),
};
await createChatManager(pluginRunner).sendRoomMessage("room-1", "hello @Avery");
expect(createOptions.skillSelection.requestedSkillNames).toEqual(["ce-debug"]);
expect(createOptions.skillSelection.sessionPurpose).toBe("heartbeat");
expect(createOptions.additionalSkillPaths).toEqual([pluginSkillDir, dirname(pluginSkillDir)]);
});
it("sendRoomMessage persists assistant room replies", async () => {
(mockChatStore as any).getRoom = vi.fn().mockReturnValue({ id: "room-1", name: "team" });
(mockChatStore as any).listRoomMembers = vi.fn().mockReturnValue([

View File

@@ -2033,13 +2033,15 @@ export class ChatManager {
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.
FNXC:ChatSkills 2026-07-20-10:30:
Chat-room responder sessions must request responder and enabled plugin skill names plus forward additionalSkillPaths from buildSessionSkillContextSync.
The pi loader cannot discover plugin SKILL.md bodies from names alone, so preserve both #2017 contract halves (FN-8443 / #2364).
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.
*/
...(mergedRoomSkillSelection ? { skillSelection: mergedRoomSkillSelection } : {}),
...(roomSkillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: roomSkillContext.additionalSkillPaths } : {}),
cwd: this.rootDir,
systemPrompt,
tools: CHAT_CODING_TOOLS,
@@ -2704,10 +2706,12 @@ export class ChatManager {
...(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.
FNXC:ChatSkills 2026-07-20-10:30:
Regular chat and QuickChat must request bound-agent and enabled plugin skill names plus forward additionalSkillPaths from buildSessionSkillContextSync.
The pi loader cannot discover plugin SKILL.md bodies from names alone, so preserve both #2017 contract halves (FN-8443 / #2364).
*/
...(mergedChatSkillSelection ? { skillSelection: mergedChatSkillSelection } : {}),
...(chatSkillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: chatSkillContext.additionalSkillPaths } : {}),
// FNXC:McpConfig 2026-06-25-22:36: Dashboard chat/QuickChat reuses the scoped task store when available to resolve trusted MCP servers at session creation without persisting materialized secrets.
...(this.taskStore ? { mcpServers: (await resolveMcpServersForStore(this.taskStore, { agentId: agent?.id })).servers } : {}),
...sessionOptions,

View File

@@ -620,6 +620,43 @@ describe("createResolvedAgentSession", () => {
);
});
it("forwards plugin skill names and body paths to runtime session factory", async () => {
const createSessionMock = vi.fn().mockResolvedValue({
session: { prompt: vi.fn() },
sessionFile: "session.json",
});
resolveRuntimeMock.mockResolvedValue({
runtime: {
id: "pi",
name: "Default PI Runtime",
createSession: createSessionMock,
promptWithFallback: vi.fn(),
describeModel: vi.fn(() => "mock/model"),
},
runtimeId: "pi",
wasConfigured: false,
});
const { createResolvedAgentSession } = await import("../agent-session-helpers.js");
const additionalSkillPaths = ["/tmp/plugin-skills/foo", "/tmp/plugin-skills"];
await createResolvedAgentSession({
sessionPurpose: "executor",
cwd: "/tmp/project",
systemPrompt: "system",
skillSelection: {
projectRootDir: "/tmp/project",
sessionPurpose: "executor",
requestedSkillNames: ["plugin-foo"],
},
additionalSkillPaths,
});
expect(createSessionMock).toHaveBeenCalledWith(expect.objectContaining({
skills: ["plugin-foo"],
additionalSkillPaths,
}));
});
it("forwards sessionPurpose into runtime.createSession for host-extension policy", async () => {
/*
FNXC:MergeQueue 2026-07-15-11:20:

View File

@@ -702,6 +702,11 @@ export async function createResolvedAgentSession(
? runtimeOptionsRaw.skills
: skillNamesFromSelection;
/*
FNXC:ChatSkills 2026-07-20-10:30:
createResolvedAgentSession must preserve additionalSkillPaths into runtime.createSession.
Plugin skill names alone never deliver bodies; chat, step, and cron lanes require both halves of the #2017 contract (FN-8443 / #2364).
*/
const runtimeOptions: AgentRuntimeOptions = {
...runtimeOptionsRaw,
...(mergedSkillNames.length > 0 ? { skills: mergedSkillNames } : {}),