FN-7054: fix slash skill request matching

Normalize requested skill identifiers so slash/catalog forms load the discovered bare skill across chat and agent sessions.

- Add requested-name matching keys for slash paths, SKILL.md paths, and source-qualified skill identifiers.
- Preserve execution-skill disable/exclude behavior and not-found diagnostics while applying the new matching only to requested names.
- Cover chat/agent requested skill paths with resolver and full flow tests and document supported /skill forms.

Files changed:
 .changeset/fn-7054-skill-slash-names.md            |   7 ++
 docs/agents.md                                     |   2 +-
 docs/dashboard-guide.md                            |   2 +-
 .../engine/src/__tests__/agent-skills-flow.test.ts |  42 +++++++++
 .../engine/src/__tests__/skill-resolver.test.ts    | 101 +++++++++++++++++++++
 packages/engine/src/skill-resolver.ts              |  23 ++++-
 6 files changed, 170 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-7054
Fusion-Task-Lineage: 8d0f5c7d-9d8b-4b16-b24e-db6d2f6a464b
This commit is contained in:
gsxdsm
2026-06-26 01:38:57 -07:00
parent dbd613a529
commit 59fc94bc0f
6 changed files with 170 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string>(),
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<string>(),
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<string>(["/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<string>(),
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"]),

View File

@@ -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}]` : "";