diff --git a/.changeset/fn-7054-skill-slash-names.md b/.changeset/fn-7054-skill-slash-names.md new file mode 100644 index 0000000000..ec3bd785dc --- /dev/null +++ b/.changeset/fn-7054-skill-slash-names.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix slash/namespaced skill commands not loading in chat and agent sessions. +category: fix +dev: skill-resolver requested-name matching now reduces a/b, a/b/SKILL.md, and source::a/b forms to the bare token like the dashboard bareSkillName, scoped to requested-name matching (allow/exclude path matching unchanged). diff --git a/docs/agents.md b/docs/agents.md index db1e945470..699724e8be 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -30,7 +30,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - 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. - 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. 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. +- 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` and `fn_task_document_read`; because neither lane has an ambient task, both tools require an explicit `task_id`. - Agent workflow-routing tools follow an intent boundary: agents may select or change a task workflow only when the user explicitly requested that workflow or when the agent created the task. Executors must not call `fn_workflow_select` to reroute the task they are executing unless the task instructions or a user steering comment explicitly asks for the workflow change. - Executor, heartbeat, and dashboard chat sessions expose artifact registry tools so agents can publish and inspect multi-type deliverables without relying on the dashboard gallery. Planning sessions intentionally exclude artifact tools until they can thread the existing `MessageStore` dependency. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index d1a3113c29..6518f72658 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -388,7 +388,7 @@ Quick Chat is an optional fast, project-scoped assistant surface for conversatio - 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 +- Supports `/skill:{name}` in model-loop chat to request a specific enabled skill for that session; slash/catalog forms 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, and 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) diff --git a/packages/engine/src/__tests__/agent-skills-flow.test.ts b/packages/engine/src/__tests__/agent-skills-flow.test.ts index 2b33aec835..0016a21121 100644 --- a/packages/engine/src/__tests__/agent-skills-flow.test.ts +++ b/packages/engine/src/__tests__/agent-skills-flow.test.ts @@ -216,6 +216,48 @@ describe("agent skills flow - full integration", () => { expect(disabledWarnings).toHaveLength(0); }); + it("flow with slash and namespaced requested names resolves through shared override", async () => { + const projectRootDir = createMockProjectDir({}); + const mockAgent: Agent = { + id: "agent-001", + name: "Slash Skill Agent", + role: "executor", + state: "idle", + metadata: { skills: ["review/pr", "source::skills/gamma/SKILL.md"] }, + } as unknown as Agent; + const mockAgentStore = { + getAgent: vi.fn().mockResolvedValue(mockAgent), + } as unknown as AgentStore; + + const sessionResult = await buildSessionSkillContext({ + agentStore: mockAgentStore, + task: { assignedAgentId: "agent-001" }, + sessionPurpose: "executor", + projectRootDir, + }); + + expect(sessionResult.skillSource).toBe("assigned-agent"); + expect(sessionResult.skillSelectionContext?.requestedSkillNames).toEqual(["review/pr", "gamma/SKILL.md"]); + + const resolvedSkills = resolveSessionSkills(sessionResult.skillSelectionContext!); + const override = createSkillsOverrideFromSelection(resolvedSkills, { + requestedSkillNames: sessionResult.skillSelectionContext?.requestedSkillNames, + sessionPurpose: sessionResult.skillSelectionContext?.sessionPurpose, + }); + + const result = override({ + skills: [ + { name: "pr", filePath: "skills/review/pr/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false }, + { name: "gamma", filePath: "skills/gamma/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false }, + { name: "lint", filePath: "skills/lint/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false }, + ], + diagnostics: [], + }); + + expect(result.skills.map((skill) => skill.name)).toEqual(["pr", "gamma"]); + expect(result.diagnostics.some((diagnostic) => diagnostic.message.includes("not found"))).toBe(false); + }); + it("flow with role fallback when assigned agent has no skills", async () => { // Step 1: Set up mock filesystem const projectRootDir = createMockProjectDir({ diff --git a/packages/engine/src/__tests__/skill-resolver.test.ts b/packages/engine/src/__tests__/skill-resolver.test.ts index 3e97273a67..9e84a2e578 100644 --- a/packages/engine/src/__tests__/skill-resolver.test.ts +++ b/packages/engine/src/__tests__/skill-resolver.test.ts @@ -605,6 +605,107 @@ describe("createSkillsOverrideFromSelection", () => { expect(result.diagnostics[0].message).toContain("CustomSkill"); }); + it.each([ + ["single token", "pr"], + ["slash form", "review/pr"], + ["skill file path form", "review/pr/SKILL.md"], + ["catalog id form", "source::skills/review/pr/SKILL.md"], + ])("loads requested skills by %s", (_label, requestedName) => { + const selection: SkillSelectionResult = { + allowedSkillPaths: new Set(), + excludedSkillPaths: new Set(), + diagnostics: [], + filterActive: true, + }; + + const override = createSkillsOverrideFromSelection(selection, { + requestedSkillNames: [requestedName], + sessionPurpose: "test", + }); + + const result = override({ + skills: [ + { name: "pr", filePath: "/skills/review/pr/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false }, + { name: "gamma", filePath: "/skills/gamma/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false }, + ], + diagnostics: [], + }); + + expect(result.skills.map((skill) => skill.name)).toEqual(["pr"]); + expect(result.diagnostics.some((diagnostic) => diagnostic.message.includes("not found"))).toBe(false); + }); + + it("emits the requested-skill-not-found diagnostic for genuinely absent requested names", () => { + const selection: SkillSelectionResult = { + allowedSkillPaths: new Set(), + excludedSkillPaths: new Set(), + diagnostics: [], + filterActive: true, + }; + + const override = createSkillsOverrideFromSelection(selection, { + requestedSkillNames: ["review/absent"], + sessionPurpose: "chat", + }); + + const result = override({ + skills: [ + { name: "pr", filePath: "/skills/review/pr/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false }, + ], + diagnostics: [], + }); + + expect(result.skills).toHaveLength(0); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].type).toBe("info"); + expect(result.diagnostics[0].message).toContain("Requested skill 'review/absent' not found"); + }); + + it("does not force-load requested skills disabled by execution settings", () => { + const selection: SkillSelectionResult = { + allowedSkillPaths: new Set(), + excludedSkillPaths: new Set(["/skills/review/pr/SKILL.md"]), + diagnostics: [], + filterActive: true, + }; + + const override = createSkillsOverrideFromSelection(selection, { + requestedSkillNames: ["review/pr"], + sessionPurpose: "chat", + }); + + const result = override({ + skills: [ + { name: "pr", filePath: "/skills/review/pr/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false }, + ], + diagnostics: [], + }); + + expect(result.skills).toHaveLength(0); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].type).toBe("warning"); + expect(result.diagnostics[0].message).toContain("disabled by project execution settings"); + expect(result.diagnostics.some((diagnostic) => diagnostic.message.includes("not found"))).toBe(false); + }); + + it("leaves no-requested-names inactive filtering behavior unchanged", () => { + const selection: SkillSelectionResult = { + allowedSkillPaths: new Set(), + excludedSkillPaths: new Set(), + diagnostics: [], + filterActive: false, + }; + const base = { + skills: [ + { name: "pr", filePath: "/skills/review/pr/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false }, + { name: "gamma", filePath: "/skills/gamma/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false }, + ], + diagnostics: [], + }; + + expect(createSkillsOverrideFromSelection(selection)(base)).toBe(base); + }); + it("preserves base diagnostics alongside new diagnostics", () => { const selection: SkillSelectionResult = { allowedSkillPaths: new Set(["/path/foo"]), diff --git a/packages/engine/src/skill-resolver.ts b/packages/engine/src/skill-resolver.ts index a47f7bb7a7..47fb897156 100644 --- a/packages/engine/src/skill-resolver.ts +++ b/packages/engine/src/skill-resolver.ts @@ -190,6 +190,19 @@ function bareSkillName(name: string): string { return name.replace(/\/SKILL\.md$/i, ""); } +/** + * FNXC:SkillResolution 2026-06-26-00:00: + * Requested skills can arrive from chat and agent metadata as `gamma`, `review/pr`, `review/pr/SKILL.md`, or `source::skills/review/pr/SKILL.md` while discovered Skill.name entries are keyed by the bare token. + * Reduce only requested-name comparisons to the dashboard bareSkillName convention so slash/namespaced requests load without changing allow/exclude path matching, which still depends on bareSkillName plus filePath equality. + */ +function requestedSkillMatchKey(name: string): string { + if (!name) return ""; + const withoutSkillMd = name.replace(/\/SKILL\.md$/i, ""); + const lastPathSegment = withoutSkillMd.split("/").pop() ?? withoutSkillMd; + const afterNamespace = lastPathSegment.split(":").pop() ?? lastPathSegment; + return afterNamespace.toLowerCase(); +} + // ── Main Resolution Logic ──────────────────────────────────────────────────── /** @@ -406,10 +419,10 @@ export function createSkillsOverrideFromSelection( }; if (hasRequestedNames) { - // Filter by requested names (case-insensitive match, normalize away /SKILL.md suffix) - const requestedBareNamesLower = new Set(requestedSkillNames!.map((n) => bareSkillName(n).toLowerCase())); + // Filter by requested names using the chat/dashboard bare-token convention only for requested-name matching. + const requestedMatchKeys = new Set(requestedSkillNames!.map(requestedSkillMatchKey)); filteredSkills = base.skills.filter( - (skill) => requestedBareNamesLower.has(bareSkillName(skill.name).toLowerCase()) && !isExcluded(skill) + (skill) => requestedMatchKeys.has(requestedSkillMatchKey(skill.name)) && !isExcluded(skill) ); } else if (hasPatterns) { // Filter by pattern (allowed AND not excluded) @@ -457,10 +470,10 @@ export function createSkillsOverrideFromSelection( // Check for requested names that don't match any discovered skill if (requestedSkillNames) { - const discoveredBareNamesLower = new Set(base.skills.map((s) => bareSkillName(s.name).toLowerCase())); + const discoveredRequestedMatchKeys = new Set(base.skills.map((s) => requestedSkillMatchKey(s.name))); for (const requestedName of requestedSkillNames) { if ( - !discoveredBareNamesLower.has(bareSkillName(requestedName).toLowerCase()) + !discoveredRequestedMatchKeys.has(requestedSkillMatchKey(requestedName)) && !isBuiltInFallbackRequest(requestedName) ) { const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";