fix: reconcile Fusion two-segment skill names with pi-coding-agent bare names

pi-coding-agent sets Skill.name to the parent directory (e.g. 'web-research'),
while Fusion uses two-segment names everywhere (e.g. 'web-research/SKILL.md')
from extractSkillName(), normalizeAgentSkills(), and toggleExecutionSkill().

The previous fix (f2afc7f0) correctly switched matching from skill.filePath to
skill.name, but that only works when both sides use the same format. Since they
don't, all pattern/requested-name comparisons still failed, producing the
spurious 'not found in discovered skills' warnings.

Fix: add bareSkillName() helper that strips the /SKILL.md suffix before
comparison. Applied to all five comparison points in skill-resolver.ts:
  - skillNameMatches() (pattern filtering)
  - requestedSkillNames set lookup (name filtering)
  - hasDiscoveredMatch() (configured-pattern diagnostic)
  - discoveredBareNamesLower (requested-name diagnostic)
  - excluded-path discovery check
This commit is contained in:
Harry Cordewener
2026-05-03 02:15:53 -05:00
parent f2afc7f0c4
commit 3afb62be29
3 changed files with 118 additions and 9 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix skill name matching between Fusion's two-segment names (e.g. `web-research/SKILL.md`) and pi-coding-agent's bare directory names (e.g. `web-research`). Patterns and requested skill names now strip the `/SKILL.md` suffix before comparison, eliminating spurious "not found in discovered skills" warnings.

View File

@@ -870,5 +870,83 @@ describe("createSkillsOverrideFromSelection", () => {
); );
expect(missingWarnings).toHaveLength(0); expect(missingWarnings).toHaveLength(0);
}); });
it("matches Fusion two-segment patterns against pi-coding-agent bare skill names", () => {
// Fusion's toggleExecutionSkill saves patterns like "+web-research/SKILL.md"
// and normalizeAgentSkills extracts "web-research/SKILL.md" from full IDs.
// But pi-coding-agent sets Skill.name to just the directory name: "web-research".
// This test verifies the cross-format matching works.
const dir = createMockProjectDir({
skills: ["+web-research/SKILL.md"],
});
const resolvedSkills = resolveSessionSkills({
projectRootDir: dir,
// Simulates what normalizeAgentSkills produces from "auto::skills/web-research/SKILL.md"
requestedSkillNames: ["web-research/SKILL.md"],
sessionPurpose: "triage",
});
const override = createSkillsOverrideFromSelection(resolvedSkills, {
requestedSkillNames: resolvedSkills.allowedSkillPaths.size > 0
? ["web-research/SKILL.md"]
: undefined,
sessionPurpose: "triage",
});
// pi-coding-agent discovers skills with bare directory names
const base = {
skills: [
{ name: "web-research", filePath: "/home/user/.pi/agent/skills/web-research/SKILL.md", description: "Web search", baseDir: "/home/user/.pi/agent/skills/web-research", sourceInfo: {} as any, disableModelInvocation: false },
{ name: "paperclip", filePath: "/home/user/.pi/agent/skills/paperclip/SKILL.md", description: "Paperclip", baseDir: "/home/user/.pi/agent/skills/paperclip", sourceInfo: {} as any, disableModelInvocation: false },
],
diagnostics: [],
};
const result = override(base);
// web-research should match despite the naming mismatch
expect(result.skills).toHaveLength(1);
expect(result.skills[0].name).toBe("web-research");
// No spurious "not found" warnings
const notFoundWarnings = result.diagnostics.filter(d =>
d.type === "warning" && d.message.includes("not found")
);
expect(notFoundWarnings).toHaveLength(0);
});
it("exclusion patterns with /SKILL.md suffix correctly exclude pi-discovered skills", () => {
const dir = createMockProjectDir({
skills: ["+web-research/SKILL.md", "-paperclip/SKILL.md"],
});
const resolvedSkills = resolveSessionSkills({
projectRootDir: dir,
});
const override = createSkillsOverrideFromSelection(resolvedSkills, {
sessionPurpose: "executor",
});
const base = {
skills: [
{ name: "web-research", filePath: "/home/user/.pi/agent/skills/web-research/SKILL.md", description: "Web search", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
{ name: "paperclip", filePath: "/home/user/.pi/agent/skills/paperclip/SKILL.md", description: "Paperclip", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
],
diagnostics: [],
};
const result = override(base);
expect(result.skills).toHaveLength(1);
expect(result.skills[0].name).toBe("web-research");
// Should have a "disabled" diagnostic for paperclip (exists but excluded)
const disabledWarning = result.diagnostics.find(d =>
d.message.includes("disabled") && d.message.includes("paperclip")
);
expect(disabledWarning).toBeDefined();
});
}); });
}); });

View File

@@ -142,6 +142,24 @@ function isExclusionPattern(pattern: string): boolean {
return pattern.startsWith("-"); return pattern.startsWith("-");
} }
/**
* Extract the bare skill name for matching purposes.
*
* Fusion conventions use two-segment names like "web-research/SKILL.md" (from
* extractSkillName and normalizeAgentSkills), but pi-coding-agent sets Skill.name
* to just the parent directory (e.g. "web-research"). This helper strips common
* suffixes so both sides can be compared:
*
* "web-research/SKILL.md" → "web-research"
* "skills/web-research/SKILL.md" → "web-research"
* "web-research" → "web-research"
* "/abs/path/skills/web-research/SKILL.md" → left unchanged (absolute paths
* are matched by filePath comparison, not by this helper)
*/
function bareSkillName(name: string): string {
return name.replace(/\/SKILL\.md$/i, "");
}
// ── Main Resolution Logic ──────────────────────────────────────────────────── // ── Main Resolution Logic ────────────────────────────────────────────────────
/** /**
@@ -331,8 +349,13 @@ export function createSkillsOverrideFromSelection(
// Settings patterns are relative (e.g. "web-research/SKILL.md") but // Settings patterns are relative (e.g. "web-research/SKILL.md") but
// skill.filePath is absolute. Match against skill.name instead so // skill.filePath is absolute. Match against skill.name instead so
// that patterns written by toggleExecutionSkill() actually resolve. // that patterns written by toggleExecutionSkill() actually resolve.
//
// pi-coding-agent sets Skill.name to the parent directory name
// (e.g. "web-research") while Fusion uses two-segment names
// (e.g. "web-research/SKILL.md"). bareSkillName() normalizes
// both sides so the comparison succeeds.
const skillNameMatches = (skill: Skill, pattern: string): boolean => const skillNameMatches = (skill: Skill, pattern: string): boolean =>
skill.name.toLowerCase() === pattern.toLowerCase() bareSkillName(skill.name).toLowerCase() === bareSkillName(pattern).toLowerCase()
|| skill.filePath === pattern; || skill.filePath === pattern;
const isExcluded = (skill: Skill): boolean => { const isExcluded = (skill: Skill): boolean => {
for (const ep of excludedSkillPaths) { for (const ep of excludedSkillPaths) {
@@ -348,10 +371,10 @@ export function createSkillsOverrideFromSelection(
}; };
if (hasRequestedNames) { if (hasRequestedNames) {
// Filter by requested names (case-insensitive match) // Filter by requested names (case-insensitive match, normalize away /SKILL.md suffix)
const requestedNamesLower = new Set(requestedSkillNames!.map((n) => n.toLowerCase())); const requestedBareNamesLower = new Set(requestedSkillNames!.map((n) => bareSkillName(n).toLowerCase()));
filteredSkills = base.skills.filter( filteredSkills = base.skills.filter(
(skill) => requestedNamesLower.has(skill.name.toLowerCase()) && !isExcluded(skill) (skill) => requestedBareNamesLower.has(bareSkillName(skill.name).toLowerCase()) && !isExcluded(skill)
); );
} else if (hasPatterns) { } else if (hasPatterns) {
// Filter by pattern (allowed AND not excluded) // Filter by pattern (allowed AND not excluded)
@@ -371,10 +394,13 @@ export function createSkillsOverrideFromSelection(
// Check for excluded paths that DO match a discovered skill (disabled) // Check for excluded paths that DO match a discovered skill (disabled)
const purpose = sessionPurpose ? ` [${sessionPurpose}]` : ""; const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";
const discoveredNames = new Set(base.skills.map((s) => s.name.toLowerCase())); const discoveredBareNames = new Set(base.skills.map((s) => bareSkillName(s.name).toLowerCase()));
const discoveredFilePaths = new Set(base.skills.map((s) => s.filePath));
const hasDiscoveredMatch = (pattern: string): boolean =>
discoveredBareNames.has(bareSkillName(pattern).toLowerCase()) || discoveredFilePaths.has(pattern);
for (const excludedPath of excludedSkillPaths) { for (const excludedPath of excludedSkillPaths) {
if (discoveredNames.has(excludedPath.toLowerCase())) { if (hasDiscoveredMatch(excludedPath)) {
newDiagnostics.push({ newDiagnostics.push({
type: "warning", type: "warning",
message: `Skill at '${excludedPath}' exists but is disabled by project execution settings${purpose}`, message: `Skill at '${excludedPath}' exists but is disabled by project execution settings${purpose}`,
@@ -385,7 +411,7 @@ export function createSkillsOverrideFromSelection(
// Check for configured patterns (allowed paths) that don't match any discovered skill // Check for configured patterns (allowed paths) that don't match any discovered skill
for (const allowedPath of allowedSkillPaths) { for (const allowedPath of allowedSkillPaths) {
if (!discoveredNames.has(allowedPath.toLowerCase())) { if (!hasDiscoveredMatch(allowedPath)) {
newDiagnostics.push({ newDiagnostics.push({
type: "warning", type: "warning",
message: `Configured skill pattern '${allowedPath}' not found in discovered skills${purpose}`, message: `Configured skill pattern '${allowedPath}' not found in discovered skills${purpose}`,
@@ -396,10 +422,10 @@ export function createSkillsOverrideFromSelection(
// Check for requested names that don't match any discovered skill // Check for requested names that don't match any discovered skill
if (requestedSkillNames) { if (requestedSkillNames) {
const discoveredNamesLower = new Set(base.skills.map((s) => s.name.toLowerCase())); const discoveredBareNamesLower = new Set(base.skills.map((s) => bareSkillName(s.name).toLowerCase()));
for (const requestedName of requestedSkillNames) { for (const requestedName of requestedSkillNames) {
if ( if (
!discoveredNamesLower.has(requestedName.toLowerCase()) !discoveredBareNamesLower.has(bareSkillName(requestedName).toLowerCase())
&& !isBuiltInFallbackRequest(requestedName) && !isBuiltInFallbackRequest(requestedName)
) { ) {
const purpose = sessionPurpose ? ` [${sessionPurpose}]` : ""; const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";