FN-7954: fix plugin skill toggle keys for custom skillFiles paths

Align plugin skill enable/disable reads with the resolved skillFiles path so Skills-view toggles persist and sessions honor them.

- Accept optional skillRelativePath in resolvePluginSkillEnabled for custom skillFiles keys
- Pass resolved relativePath from dashboard skills adapter when merging plugin skills
- Reuse resolved body path in session-skill-context for enable checks and additionalSkillPaths
- Add unit coverage for custom-path round-trip, enable, and disable behavior
- Ship patch changeset for @runfusion/fusion

Files changed:
 .changeset/fn-7954-plugin-skill-toggle-key-fix.md  |  7 +++
 packages/core/src/__tests__/skill-settings.test.ts | 15 ++++++
 packages/core/src/skill-settings.ts                |  8 +++-
 .../dashboard/src/__tests__/skills-adapter.test.ts | 55 ++++++++++++++++++++++
 packages/dashboard/src/skills-adapter.ts           |  1 +
 .../src/__tests__/session-skill-context.test.ts    | 45 ++++++++++++++++++
 packages/engine/src/session-skill-context.ts       | 29 +++++++-----
 7 files changed, 147 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-7954

Fusion-Task-Lineage: 3399186b-7325-4ed7-8900-85eb2ef98c7e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-15 10:38:04 -07:00
parent 49114a27ad
commit 49a459a869
7 changed files with 147 additions and 13 deletions

View File

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

View File

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

View File

@@ -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/<name>/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";
}

View File

@@ -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";

View File

@@ -381,6 +381,7 @@ export function createSkillsAdapter(options: {
pluginId,
name,
skill.enabled,
relativePath,
);
discoveredSkills.push({
id,

View File

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

View File

@@ -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<typeof resolvePluginSkillBodyPath> | 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}`);