From df488f25ea34336549ae47640b05e03cd4a8c87a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 12:32:01 -0700 Subject: [PATCH] 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) --- .changeset/fn-8443-chat-plugin-skill-paths.md | 7 +++ docs/agents.md | 2 +- .../src/__tests__/chat-manager.test.ts | 60 +++++++++++++++++-- packages/dashboard/src/chat.ts | 12 ++-- .../__tests__/agent-session-helpers.test.ts | 37 ++++++++++++ packages/engine/src/agent-session-helpers.ts | 5 ++ 6 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 .changeset/fn-8443-chat-plugin-skill-paths.md diff --git a/.changeset/fn-8443-chat-plugin-skill-paths.md b/.changeset/fn-8443-chat-plugin-skill-paths.md new file mode 100644 index 0000000000..77c03592ad --- /dev/null +++ b/.changeset/fn-8443-chat-plugin-skill-paths.md @@ -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). diff --git a/docs/agents.md b/docs/agents.md index 6f1f7d6b1b..60b6ed0b1c 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -37,7 +37,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] [-- - 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:`; use `--conversation-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. diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index 4e5428d825..77d8dd129e 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -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([ diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 3c737acd4b..9c1cd89668 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -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, diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index 1ba93f6e17..cef741878d 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -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: diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index c28bb93339..64bbb00e9d 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -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 } : {}),