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:
7
.changeset/fn-7954-plugin-skill-toggle-key-fix.md
Normal file
7
.changeset/fn-7954-plugin-skill-toggle-key-fix.md
Normal 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.
|
||||||
@@ -48,6 +48,21 @@ describe("skill-settings", () => {
|
|||||||
}, "fusion-plugin", "default-on", true)).toBe(false);
|
}, "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", () => {
|
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-on", undefined)).toBe(true);
|
||||||
expect(resolvePluginSkillEnabled({}, "fusion-plugin", "default-off", false)).toBe(false);
|
expect(resolvePluginSkillEnabled({}, "fusion-plugin", "default-off", false)).toBe(false);
|
||||||
|
|||||||
@@ -90,13 +90,19 @@ export function getSkillSettingState(
|
|||||||
return undefined;
|
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(
|
export function resolvePluginSkillEnabled(
|
||||||
settings: SkillSettingsScope,
|
settings: SkillSettingsScope,
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
skillName: string,
|
skillName: string,
|
||||||
staticEnabled: boolean | undefined,
|
staticEnabled: boolean | undefined,
|
||||||
|
skillRelativePath?: string,
|
||||||
): boolean {
|
): 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);
|
const settingState = getSkillSettingState(skillId, settings);
|
||||||
return settingState === undefined ? staticEnabled !== false : settingState === "enabled";
|
return settingState === undefined ? staticEnabled !== false : settingState === "enabled";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -874,6 +874,61 @@ describe("createSkillsAdapter - plugin skill merge", () => {
|
|||||||
expect(skill.path).toBe("skills/entity-framework-core/SKILL.md");
|
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 () => {
|
it("passes the requesting project root into async plugin-skill discovery", async () => {
|
||||||
const daemonRoot = "/tmp/daemon-root";
|
const daemonRoot = "/tmp/daemon-root";
|
||||||
const projectRoot = "/tmp/managed-project";
|
const projectRoot = "/tmp/managed-project";
|
||||||
|
|||||||
@@ -381,6 +381,7 @@ export function createSkillsAdapter(options: {
|
|||||||
pluginId,
|
pluginId,
|
||||||
name,
|
name,
|
||||||
skill.enabled,
|
skill.enabled,
|
||||||
|
relativePath,
|
||||||
);
|
);
|
||||||
discoveredSkills.push({
|
discoveredSkills.push({
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ async function createPluginSkillRoot(skillName: string, body = "# Plugin skill\n
|
|||||||
return { pluginRoot, skillDir };
|
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 () => {
|
afterEach(async () => {
|
||||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
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 () => {
|
it("falls back to static defaults when project settings omit a plugin skill", async () => {
|
||||||
const projectRoot = await createProjectWithSettings({ skills: [] });
|
const projectRoot = await createProjectWithSettings({ skills: [] });
|
||||||
const pluginRunner = pluginRunnerWithSkills([
|
const pluginRunner = pluginRunnerWithSkills([
|
||||||
|
|||||||
@@ -160,7 +160,19 @@ export function collectPluginSkillNames(
|
|||||||
for (const contribution of pluginSkills) {
|
for (const contribution of pluginSkills) {
|
||||||
const { pluginId, skill } = contribution;
|
const { pluginId, skill } = contribution;
|
||||||
const name = skill.name.trim();
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,17 +184,10 @@ export function collectPluginSkillNames(
|
|||||||
pluginIds.add(pluginId);
|
pluginIds.add(pluginId);
|
||||||
names.push(name);
|
names.push(name);
|
||||||
|
|
||||||
if (contribution.pluginRoot) {
|
if (bodyPath) {
|
||||||
try {
|
const bodyDir = dirname(bodyPath.absolutePath);
|
||||||
const bodyPath = resolvePluginSkillBodyPath(skill, contribution.pluginRoot);
|
additionalSkillPathSet.add(bodyDir);
|
||||||
const bodyDir = dirname(bodyPath.absolutePath);
|
additionalSkillPathSet.add(dirname(bodyDir));
|
||||||
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)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
piLog.log(`[skills] Plugin ${pluginId} contributes skill: ${name}`);
|
piLog.log(`[skills] Plugin ${pluginId} contributes skill: ${name}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user