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:
7
.changeset/fn-7054-skill-slash-names.md
Normal file
7
.changeset/fn-7054-skill-slash-names.md
Normal 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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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}]` : "";
|
||||
|
||||
Reference in New Issue
Block a user