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:
7
.changeset/fn-8443-chat-plugin-skill-paths.md
Normal file
7
.changeset/fn-8443-chat-plugin-skill-paths.md
Normal 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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user