diff --git a/.changeset/fn-7954-plugin-skill-toggle-key-fix.md b/.changeset/fn-7954-plugin-skill-toggle-key-fix.md new file mode 100644 index 0000000000..a503d1a325 --- /dev/null +++ b/.changeset/fn-7954-plugin-skill-toggle-key-fix.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix plugin skill toggles for custom skillFiles paths so sessions honor them. +category: fix +dev: resolvePluginSkillEnabled now keys reads by the resolved plugin skillFiles path when available. diff --git a/packages/core/src/__tests__/skill-settings.test.ts b/packages/core/src/__tests__/skill-settings.test.ts index 6949a616d3..7f44af5e99 100644 --- a/packages/core/src/__tests__/skill-settings.test.ts +++ b/packages/core/src/__tests__/skill-settings.test.ts @@ -48,6 +48,21 @@ describe("skill-settings", () => { }, "fusion-plugin", "default-on", true)).toBe(false); }); + it("matches custom plugin skill paths only when the resolved path is supplied", () => { + const settings = { + packages: [{ source: "plugin:fusion-plugin", skills: ["+skills/data/ef-core/SKILL.md"] }], + }; + + expect(resolvePluginSkillEnabled(settings, "fusion-plugin", "ef-core", false, "skills/data/ef-core/SKILL.md")).toBe(true); + expect(resolvePluginSkillEnabled(settings, "fusion-plugin", "ef-core", false)).toBe(false); + }); + + it("preserves name-derived lookup when no resolved plugin skill path is supplied", () => { + expect(resolvePluginSkillEnabled({ + packages: [{ source: "plugin:fusion-plugin", skills: ["+skills/ef-core/SKILL.md"] }], + }, "fusion-plugin", "ef-core", false)).toBe(true); + }); + it("falls back to static defaults when settings omit the plugin skill", () => { expect(resolvePluginSkillEnabled({}, "fusion-plugin", "default-on", undefined)).toBe(true); expect(resolvePluginSkillEnabled({}, "fusion-plugin", "default-off", false)).toBe(false); diff --git a/packages/core/src/skill-settings.ts b/packages/core/src/skill-settings.ts index 21144cdabc..8b1f5eff9c 100644 --- a/packages/core/src/skill-settings.ts +++ b/packages/core/src/skill-settings.ts @@ -90,13 +90,19 @@ export function getSkillSettingState( return undefined; } +/** + * FNXC:PluginSkills 2026-07-14-00:00: + * FN-7954 closes the plugin-skill toggle write/read key-schema mismatch for custom skillFiles paths. Dashboard toggles persist entries under the resolved plugin-root-relative SKILL.md path, so read paths must pass that same relative path when it is known; omitting skillRelativePath intentionally preserves the legacy name-derived skills//SKILL.md lookup for callers without a pluginRoot. + */ export function resolvePluginSkillEnabled( settings: SkillSettingsScope, pluginId: string, skillName: string, staticEnabled: boolean | undefined, + skillRelativePath?: string, ): boolean { - const skillId = computeSkillId(`plugin:${pluginId}`, `skills/${skillName}/SKILL.md`); + const relativePath = skillRelativePath ?? `skills/${skillName}/SKILL.md`; + const skillId = computeSkillId(`plugin:${pluginId}`, relativePath); const settingState = getSkillSettingState(skillId, settings); return settingState === undefined ? staticEnabled !== false : settingState === "enabled"; } diff --git a/packages/dashboard/src/__tests__/skills-adapter.test.ts b/packages/dashboard/src/__tests__/skills-adapter.test.ts index 2e3fba1fc4..e9167e5e69 100644 --- a/packages/dashboard/src/__tests__/skills-adapter.test.ts +++ b/packages/dashboard/src/__tests__/skills-adapter.test.ts @@ -874,6 +874,61 @@ describe("createSkillsAdapter - plugin skill merge", () => { expect(skill.path).toBe("skills/entity-framework-core/SKILL.md"); }); + it("round-trips plugin toggles keyed by custom skillFiles paths", async () => { + const dir = await mkdtemp(join(tmpdir(), "skills-adapter-custom-toggle-")); + const pluginRoot = join(dir, "plugin"); + const settingsPath = join(dir, ".fusion", "settings.json"); + + try { + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: () => settingsPath, + getPluginSkills: () => [ + { + pluginId: "fusion-plugin-data", + pluginRoot, + skill: { + name: "ef-core", + enabled: true, + skillFiles: ["skills/data/ef-core/SKILL.md"], + }, + }, + { + pluginId: "fusion-plugin-data", + pluginRoot, + skill: { + name: "sql-tuning", + enabled: false, + skillFiles: ["skills/data/sql-tuning/SKILL.md"], + }, + }, + ], + }); + + const initial = new Map((await adapter.discoverSkills(dir)).map((skill) => [skill.name, skill])); + expect(initial.get("ef-core")!.enabled).toBe(true); + expect(initial.get("sql-tuning")!.enabled).toBe(false); + expect(initial.get("ef-core")!.id).toBe(computeSkillId("plugin:fusion-plugin-data", "skills/data/ef-core/SKILL.md")); + + await adapter.toggleExecutionSkill(dir, { skillId: initial.get("ef-core")!.id, enabled: false }); + await adapter.toggleExecutionSkill(dir, { skillId: initial.get("sql-tuning")!.id, enabled: true }); + + const rediscovered = new Map((await adapter.discoverSkills(dir)).map((skill) => [skill.name, skill.enabled])); + expect(rediscovered.get("ef-core")).toBe(false); + expect(rediscovered.get("sql-tuning")).toBe(true); + + const persisted = JSON.parse(await readFile(settingsPath, "utf-8")) as { + packages?: Array<{ source: string; skills?: string[] }>; + }; + expect(persisted.packages).toContainEqual({ + source: "plugin:fusion-plugin-data", + skills: ["-data/ef-core/SKILL.md", "+data/sql-tuning/SKILL.md"], + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it("passes the requesting project root into async plugin-skill discovery", async () => { const daemonRoot = "/tmp/daemon-root"; const projectRoot = "/tmp/managed-project"; diff --git a/packages/dashboard/src/skills-adapter.ts b/packages/dashboard/src/skills-adapter.ts index d963065b02..84f59146b3 100644 --- a/packages/dashboard/src/skills-adapter.ts +++ b/packages/dashboard/src/skills-adapter.ts @@ -381,6 +381,7 @@ export function createSkillsAdapter(options: { pluginId, name, skill.enabled, + relativePath, ); discoveredSkills.push({ id, diff --git a/packages/engine/src/__tests__/session-skill-context.test.ts b/packages/engine/src/__tests__/session-skill-context.test.ts index a0cf7ae405..9ba196e4f6 100644 --- a/packages/engine/src/__tests__/session-skill-context.test.ts +++ b/packages/engine/src/__tests__/session-skill-context.test.ts @@ -38,6 +38,16 @@ async function createPluginSkillRoot(skillName: string, body = "# Plugin skill\n return { pluginRoot, skillDir }; } +async function createPluginSkillRootAt(relativePath: string, body = "# Plugin skill\n"): Promise<{ pluginRoot: string; skillDir: string }> { + const pluginRoot = await mkdtemp(join(tmpdir(), "session-plugin-skill-")); + tempDirs.push(pluginRoot); + const skillFile = join(pluginRoot, relativePath); + const skillDir = dirname(skillFile); + await mkdir(skillDir, { recursive: true }); + await writeFile(skillFile, body, "utf-8"); + return { pluginRoot, skillDir }; +} + afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); @@ -418,6 +428,41 @@ describe("collectPluginSkillNames", () => { }); }); + it("uses package-scoped custom skillFiles paths to enable statically disabled plugin skills", async () => { + const relativePath = "skills/data/ef-core/SKILL.md"; + const { pluginRoot, skillDir } = await createPluginSkillRootAt(relativePath); + const projectRoot = await createProjectWithSettings({ + packages: [{ source: "plugin:plugin-a", skills: [`+${relativePath}`] }], + }); + const pluginRunner = pluginRunnerWithSkills([ + { pluginId: "plugin-a", pluginRoot, skill: { name: "ef-core", enabled: false, skillFiles: [relativePath] } }, + ]); + + expect(collectPluginSkillNames(pluginRunner, projectRoot)).toEqual({ + names: ["ef-core"], + pluginIds: ["plugin-a"], + additionalSkillPaths: [skillDir, dirname(skillDir)], + }); + }); + + it("uses package-scoped custom skillFiles paths to disable statically enabled plugin skills", async () => { + const relativePath = "skills/data/ef-core/SKILL.md"; + const { pluginRoot } = await createPluginSkillRootAt(relativePath); + const projectRoot = await createProjectWithSettings({ + packages: [{ source: "plugin:plugin-a", skills: [`-${relativePath}`] }], + }); + const pluginRunner = pluginRunnerWithSkills([ + { pluginId: "plugin-a", pluginRoot, skill: { name: "ef-core", enabled: true, skillFiles: [relativePath] } }, + { pluginId: "plugin-b", skill: { name: "beta" } }, + ]); + + expect(collectPluginSkillNames(pluginRunner, projectRoot)).toEqual({ + names: ["beta"], + pluginIds: ["plugin-b"], + additionalSkillPaths: [], + }); + }); + it("falls back to static defaults when project settings omit a plugin skill", async () => { const projectRoot = await createProjectWithSettings({ skills: [] }); const pluginRunner = pluginRunnerWithSkills([ diff --git a/packages/engine/src/session-skill-context.ts b/packages/engine/src/session-skill-context.ts index 26ac373d32..0e9860b63b 100644 --- a/packages/engine/src/session-skill-context.ts +++ b/packages/engine/src/session-skill-context.ts @@ -160,7 +160,19 @@ export function collectPluginSkillNames( for (const contribution of pluginSkills) { const { pluginId, skill } = contribution; const name = skill.name.trim(); - if (!resolvePluginSkillEnabled(settings, pluginId, name, skill.enabled)) { + let bodyPath: ReturnType | undefined; + + if (contribution.pluginRoot) { + try { + bodyPath = resolvePluginSkillBodyPath(skill, contribution.pluginRoot); + } catch (error) { + piLog.warn( + `[skills] Plugin ${pluginId} skill ${name} body path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + if (!resolvePluginSkillEnabled(settings, pluginId, name, skill.enabled, bodyPath?.relativePath)) { continue; } @@ -172,17 +184,10 @@ export function collectPluginSkillNames( pluginIds.add(pluginId); names.push(name); - if (contribution.pluginRoot) { - try { - const bodyPath = resolvePluginSkillBodyPath(skill, contribution.pluginRoot); - const bodyDir = dirname(bodyPath.absolutePath); - additionalSkillPathSet.add(bodyDir); - additionalSkillPathSet.add(dirname(bodyDir)); - } catch (error) { - piLog.warn( - `[skills] Plugin ${pluginId} skill ${name} body path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, - ); - } + if (bodyPath) { + const bodyDir = dirname(bodyPath.absolutePath); + additionalSkillPathSet.add(bodyDir); + additionalSkillPathSet.add(dirname(bodyDir)); } piLog.log(`[skills] Plugin ${pluginId} contributes skill: ${name}`);