fix: skill discovery path doubling and pattern/name matching

Three related bugs that prevent agent skills from loading:

1. **Doubled "skills/" prefix in discovery** (skills-adapter.ts)
   `discoverSkills()` unconditionally prepends "skills/" to the relative
   path, but `baseDir` points to the parent of the skills directory
   (e.g. `~/.fusion/agent`), so `relative()` already returns a path
   starting with "skills/". Result: IDs like
   `auto::skills/skills/web-research/SKILL.md` instead of
   `auto::skills/web-research/SKILL.md`.

   Fix: only prepend "skills/" when the relative path does not already
   start with it.

2. **normalizeAgentSkills does not extract name from full ID**
   (session-skill-context.ts)
   The dashboard saves full skill IDs (e.g.
   `"auto::skills/web-research/SKILL.md"`) into agent
   `metadata.skills`. The runtime matches these against
   `skill.name` (e.g. `"web-research/SKILL.md"`) — they never match,
   so agent skills silently fail to load.

   Fix: when an entry contains "::", parse out the skill name (last
   two path segments) before matching.

3. **Pattern matching uses absolute filePath instead of skill name**
   (skill-resolver.ts)
   Settings patterns written by `toggleExecutionSkill()` are relative
   (e.g. `"web-research/SKILL.md"`), but the resolver compares them
   against `skill.filePath` which is absolute. Patterns can never
   match, producing spurious "not found in discovered skills" warnings.

   Fix: match patterns against `skill.name` (case-insensitive) with
   fallback to exact `skill.filePath` match for backward compatibility.
This commit is contained in:
Harry Cordewener
2026-05-03 01:15:56 -05:00
parent bd8bf5d827
commit c904351954
3 changed files with 48 additions and 19 deletions

View File

@@ -261,8 +261,11 @@ export function createSkillsAdapter(options: {
const discoveredSkills: DiscoveredSkill[] = []; const discoveredSkills: DiscoveredSkill[] = [];
for (const resource of skillResources) { for (const resource of skillResources) {
// Compute relative path for the skill // Compute relative path for the skill.
const skillRelativePath = "skills/" + relative(resource.metadata.baseDir ?? "", resource.path); // Guard against baseDir being the parent of the skills directory,
// which causes relative() to already include "skills/" in the result.
const _rel = relative(resource.metadata.baseDir ?? "", resource.path);
const skillRelativePath = _rel.startsWith("skills/") ? _rel : "skills/" + _rel;
const skillId = computeSkillId(resource.metadata.source, skillRelativePath); const skillId = computeSkillId(resource.metadata.source, skillRelativePath);
const skillName = extractSkillName(skillRelativePath, resource.metadata.source); const skillName = extractSkillName(skillRelativePath, resource.metadata.source);

View File

@@ -101,12 +101,24 @@ export function normalizeAgentSkills(
} }
} }
// Skip invalid/empty entries and deduplicate // Skip invalid/empty entries and deduplicate.
if (name && name.length > 0 && !seen.has(name)) { // If the entry is a full skill ID ("source::path"), extract just the
// skill name (last two path segments) so it matches the discovered
// skill.name produced by extractSkillName().
if (name && name.length > 0) {
if (name.includes("::")) {
const idPath = name.split("::").pop()!;
const parts = idPath.replace(/\\/g, "/").split("/").filter(Boolean);
if (parts.length >= 2) {
name = parts.slice(-2).join("/");
}
}
if (!seen.has(name)) {
seen.add(name); seen.add(name);
result.push(name); result.push(name);
} }
} }
}
return result; return result;
} }

View File

@@ -327,20 +327,40 @@ export function createSkillsOverrideFromSelection(
// Skills must match the inclusion criteria AND not be in the exclusion list // Skills must match the inclusion criteria AND not be in the exclusion list
const hasExcluded = excludedSkillPaths.size > 0; const hasExcluded = excludedSkillPaths.size > 0;
let filteredSkills: Skill[]; let filteredSkills: Skill[];
// Build a name-based lookup for pattern/exclusion matching.
// Settings patterns are relative (e.g. "web-research/SKILL.md") but
// skill.filePath is absolute. Match against skill.name instead so
// that patterns written by toggleExecutionSkill() actually resolve.
const skillNameMatches = (skill: Skill, pattern: string): boolean =>
skill.name.toLowerCase() === pattern.toLowerCase()
|| skill.filePath === pattern;
const isExcluded = (skill: Skill): boolean => {
for (const ep of excludedSkillPaths) {
if (skillNameMatches(skill, ep)) return true;
}
return false;
};
const isAllowed = (skill: Skill): boolean => {
for (const ap of allowedSkillPaths) {
if (skillNameMatches(skill, ap)) return true;
}
return false;
};
if (hasRequestedNames) { if (hasRequestedNames) {
// Filter by requested names (case-insensitive match) // Filter by requested names (case-insensitive match)
const requestedNamesLower = new Set(requestedSkillNames!.map((n) => n.toLowerCase())); const requestedNamesLower = new Set(requestedSkillNames!.map((n) => n.toLowerCase()));
filteredSkills = base.skills.filter( filteredSkills = base.skills.filter(
(skill) => requestedNamesLower.has(skill.name.toLowerCase()) && !excludedSkillPaths.has(skill.filePath) (skill) => requestedNamesLower.has(skill.name.toLowerCase()) && !isExcluded(skill)
); );
} else if (hasPatterns) { } else if (hasPatterns) {
// Filter by file path (in allowed set AND not in excluded set) // Filter by pattern (allowed AND not excluded)
filteredSkills = base.skills.filter( filteredSkills = base.skills.filter(
(skill) => allowedSkillPaths.has(skill.filePath) && !excludedSkillPaths.has(skill.filePath) (skill) => isAllowed(skill) && !isExcluded(skill)
); );
} else if (hasExcluded) { } else if (hasExcluded) {
// Only exclusions set - filter out excluded skills // Only exclusions set - filter out excluded skills
filteredSkills = base.skills.filter((skill) => !excludedSkillPaths.has(skill.filePath)); filteredSkills = base.skills.filter((skill) => !isExcluded(skill));
} else { } else {
// No filter criteria - this shouldn't happen if filterActive is true // No filter criteria - this shouldn't happen if filterActive is true
filteredSkills = base.skills; filteredSkills = base.skills;
@@ -350,28 +370,22 @@ export function createSkillsOverrideFromSelection(
const newDiagnostics: ResourceDiagnostic[] = []; const newDiagnostics: ResourceDiagnostic[] = [];
// Check for excluded paths that DO match a discovered skill (disabled) // Check for excluded paths that DO match a discovered skill (disabled)
// These are skills that exist but were explicitly excluded by project patterns
const purpose = sessionPurpose ? ` [${sessionPurpose}]` : ""; const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";
const discoveredPaths = new Set(base.skills.map((s) => s.filePath)); const discoveredNames = new Set(base.skills.map((s) => s.name.toLowerCase()));
for (const excludedPath of excludedSkillPaths) { for (const excludedPath of excludedSkillPaths) {
if (discoveredPaths.has(excludedPath)) { if (discoveredNames.has(excludedPath.toLowerCase())) {
// Skill exists but was disabled by project patterns
// Use "warning" type since ResourceDiagnostic only supports warning|error|collision
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}`,
path: excludedPath, path: excludedPath,
}); });
} }
// If the path doesn't match any discovered skill, it's not a disabled skill - it's just not relevant
} }
// 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
// Note: At this point, we have access to base.skills for validation
for (const allowedPath of allowedSkillPaths) { for (const allowedPath of allowedSkillPaths) {
if (!discoveredPaths.has(allowedPath)) { if (!discoveredNames.has(allowedPath.toLowerCase())) {
// Allowed path doesn't match any discovered skill - this is a missing/invalid pattern
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}`,