From c13d2ee9c2294bfa3c9e2efdc73438bf6ad8cdc9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 12 Jul 2026 11:15:52 -0700 Subject: [PATCH] FN-7858: honor per-project plugin-skill toggles in session merging Session skill merging (collectPluginSkillNames) previously ignored per-project Skills view enable/disable toggles and only consulted each plugin's static default, so a user disabling a plugin skill in the Skills view would still see it merged into live agent sessions. Extracted the effective-enablement resolver shared by dashboard discovery and engine session assembly into @fusion/core so both surfaces stay in sync. - Added packages/core/src/skill-settings.ts with computeSkillId/parseSkillId/ normalizeStoredSkillPath/getSkillSettingState/resolvePluginSkillEnabled, exported from @fusion/core's index. - packages/dashboard/src/skills-adapter.ts now re-exports and delegates to the shared @fusion/core resolver instead of duplicating its own getSkillSettingState/computeSkillId/parseSkillId implementations. - packages/engine/src/session-skill-context.ts: collectPluginSkillNames now accepts a projectRootDir, reads project settings via skill-resolver's newly exported readProjectSettings/resolveProjectRoot, and calls resolvePluginSkillEnabled instead of only checking the plugin's static skill.enabled flag; mergePluginSkills passes projectRootDir through. - packages/engine/src/skill-resolver.ts: exported readProjectSettings and ProjectSkillSettings for reuse by session-skill-context. - Updated docs/plugin-management.md to document that per-project Skills view toggles now apply to runtime agent sessions, not just discovery. - Added unit tests for the new core resolver and updated dashboard/engine tests to cover per-project toggle overrides in session merging. - Added a patch changeset for @runfusion/fusion. Files changed: .changeset/fn-7858-plugin-skill-session-toggle.md | 7 ++ docs/plugin-management.md | 4 +- packages/core/src/__tests__/skill-settings.test.ts | 62 +++++++++ packages/core/src/index.ts | 8 ++ packages/core/src/skill-settings.ts | 102 +++++++++++++++ .../dashboard/src/__tests__/skills-adapter.test.ts | 60 ++++++++- packages/dashboard/src/skills-adapter.ts | 107 +++------------- .../src/__tests__/session-skill-context.test.ts | 140 ++++++++++++++++++++- packages/engine/src/session-skill-context.ts | 23 +++- packages/engine/src/skill-resolver.ts | 4 +- 10 files changed, 409 insertions(+), 108 deletions(-) Fusion-Task-Id: FN-7858 Fusion-Task-Lineage: 90e44d24-e385-4a74-b8e4-3c864ec39a95 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7858-plugin-skill-session-toggle.md | 7 + docs/plugin-management.md | 4 +- .../core/src/__tests__/skill-settings.test.ts | 62 ++++++++ packages/core/src/index.ts | 8 + packages/core/src/skill-settings.ts | 102 +++++++++++++ .../src/__tests__/skills-adapter.test.ts | 60 +++++++- packages/dashboard/src/skills-adapter.ts | 107 ++----------- .../__tests__/session-skill-context.test.ts | 140 +++++++++++++++++- packages/engine/src/session-skill-context.ts | 23 ++- packages/engine/src/skill-resolver.ts | 4 +- 10 files changed, 409 insertions(+), 108 deletions(-) create mode 100644 .changeset/fn-7858-plugin-skill-session-toggle.md create mode 100644 packages/core/src/__tests__/skill-settings.test.ts create mode 100644 packages/core/src/skill-settings.ts diff --git a/.changeset/fn-7858-plugin-skill-session-toggle.md b/.changeset/fn-7858-plugin-skill-session-toggle.md new file mode 100644 index 0000000000..47bca24f4a --- /dev/null +++ b/.changeset/fn-7858-plugin-skill-session-toggle.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Per-project plugin-skill toggles now apply to agent sessions, not just the Skills view. +category: fix +dev: collectPluginSkillNames now resolves effective enablement via the shared @fusion/core resolver (getSkillSettingState), matching discoverSkills; fixes issue #2016 (FN-7858). diff --git a/docs/plugin-management.md b/docs/plugin-management.md index 4c1eb75053..13bf2f1858 100644 --- a/docs/plugin-management.md +++ b/docs/plugin-management.md @@ -151,7 +151,7 @@ After a plugin is installed/enabled, these are the current user-visible capabili ### A) Plugin-contributed skills (runtime behavior) -Plugin-contributed skills are merged into agent sessions automatically at runtime when enabled. +Plugin-contributed skills are merged into agent sessions automatically at runtime when enabled; per-project Skills view toggles override the plugin's default for those sessions. 1. Install + enable the plugin. 2. Run a task through an agent flow (triage/executor/reviewer/merger). @@ -159,7 +159,7 @@ Plugin-contributed skills are merged into agent sessions automatically at runtim Expected outcome: plugin skills affect session behavior, but there is no dedicated "plugin skills" management panel in Fusion Plugins. -> Note: **Skills view** shows discovered execution skills and toggles, but plugin-contributed skills are documented as runtime session behavior here (not a plugin-manager-specific skills UI). +> Note: **Skills view** shows discovered execution skills and toggles, and its per-project plugin-skill toggles apply to runtime agent sessions as well as discovery (not a plugin-manager-specific skills UI). ### B) Plugin-contributed workflow step templates (dashboard + API) diff --git a/packages/core/src/__tests__/skill-settings.test.ts b/packages/core/src/__tests__/skill-settings.test.ts new file mode 100644 index 0000000000..6949a616d3 --- /dev/null +++ b/packages/core/src/__tests__/skill-settings.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + computeSkillId, + getSkillSettingState, + parseSkillId, + resolvePluginSkillEnabled, +} from "../skill-settings.js"; + +describe("skill-settings", () => { + it("computes and parses stable skill IDs", () => { + const id = computeSkillId("plugin:fusion-plugin", "skills/ce-plan/SKILL.md"); + expect(id).toBe("plugin%3Afusion-plugin::skills/ce-plan/SKILL.md"); + expect(parseSkillId(id)).toEqual({ + source: "plugin:fusion-plugin", + relativePath: "skills/ce-plan/SKILL.md", + }); + expect(parseSkillId("not-a-skill-id")).toBeNull(); + }); + + it("resolves top-level + and - skill entries by path or wildcard ID", () => { + const skillId = computeSkillId("plugin:fusion-plugin", "skills/ce-plan/SKILL.md"); + + expect(getSkillSettingState(skillId, { skills: ["+ce-plan/SKILL.md"] })).toBe("enabled"); + expect(getSkillSettingState(skillId, { skills: ["-skills/ce-plan/SKILL.md"] })).toBe("disabled"); + expect(getSkillSettingState(computeSkillId("*", "skills/ce-plan/SKILL.md"), { + skills: ["+ce-plan/SKILL.md"], + })).toBe("enabled"); + }); + + it("resolves package-scoped plugin skill entries", () => { + const skillId = computeSkillId("plugin:fusion-plugin", "skills/ce-plan/SKILL.md"); + + expect(getSkillSettingState(skillId, { + packages: [{ source: "plugin:fusion-plugin", skills: ["+skills/ce-plan/SKILL.md"] }], + })).toBe("enabled"); + expect(getSkillSettingState(skillId, { + packages: [{ source: "plugin:fusion-plugin", skills: ["-ce-plan/SKILL.md"] }], + })).toBe("disabled"); + }); + + it("uses project toggles ahead of static plugin defaults", () => { + expect(resolvePluginSkillEnabled({ + packages: [{ source: "plugin:fusion-plugin", skills: ["+skills/opt-in/SKILL.md"] }], + }, "fusion-plugin", "opt-in", false)).toBe(true); + + expect(resolvePluginSkillEnabled({ + packages: [{ source: "plugin:fusion-plugin", skills: ["-skills/default-on/SKILL.md"] }], + }, "fusion-plugin", "default-on", true)).toBe(false); + }); + + 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); + }); + + it("honors both top-level and package-scoped settings entries", () => { + expect(resolvePluginSkillEnabled({ skills: ["+skills/top-level/SKILL.md"] }, "fusion-plugin", "top-level", false)).toBe(true); + expect(resolvePluginSkillEnabled({ + packages: [{ source: "plugin:fusion-plugin", skills: ["-skills/package-level/SKILL.md"] }], + }, "fusion-plugin", "package-level", true)).toBe(false); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f3365aaed9..4af6b164ad 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -34,6 +34,14 @@ export { export type { OpenAiCodexProviderRegistration } from "./openai-models.js"; export { detectImageMimeFromBytes } from "./image-mime.js"; export type { DetectedImageMime } from "./image-mime.js"; +export { + computeSkillId, + getSkillSettingState, + normalizeStoredSkillPath, + parseSkillId, + resolvePluginSkillEnabled, +} from "./skill-settings.js"; +export type { SkillSettingState, SkillSettingsScope } from "./skill-settings.js"; export { redactSecrets } from "./redact-secrets.js"; export { evaluatePromptCondition, diff --git a/packages/core/src/skill-settings.ts b/packages/core/src/skill-settings.ts new file mode 100644 index 0000000000..21144cdabc --- /dev/null +++ b/packages/core/src/skill-settings.ts @@ -0,0 +1,102 @@ +export type SkillSettingState = "enabled" | "disabled"; + +export interface SkillSettingsScope { + skills?: string[]; + packages?: Array; +} + +/** + * Compute deterministic skill ID from metadata. + * Format: encodeURIComponent(metadata.source) + "::" + relativePath + * + * @param source - The package source identifier + * @param relativePath - Path relative to the skill directory + * @returns Deterministic skill ID + */ +export function computeSkillId(source: string, relativePath: string): string { + const normalizedPath = relativePath.replaceAll("\\", "/"); + return `${encodeURIComponent(source)}::${normalizedPath}`; +} + +/** + * Parse a skill ID back into source and relativePath components. + */ +export function parseSkillId(skillId: string): { source: string; relativePath: string } | null { + const parts = skillId.split("::"); + if (parts.length !== 2) return null; + try { + return { + source: decodeURIComponent(parts[0]!), + relativePath: parts[1]!, + }; + } catch { + return null; + } +} + +export function normalizeStoredSkillPath(path: string): string { + return path.replaceAll("\\", "/").replace(/^skills\//, ""); +} + +function settingEntryState(entry: string): SkillSettingState { + return entry.startsWith("+") ? "enabled" : "disabled"; +} + +/** + * FNXC:PluginSkills 2026-07-12-00:00: + * Plugin-skill effective enablement must have one resolver shared by dashboard discovery and engine session assembly. FN-7858 fixed drift where Skills view honored per-project toggles but collectPluginSkillNames only used static plugin defaults. + */ +export function getSkillSettingState( + skillId: string, + settings: SkillSettingsScope, +): SkillSettingState | undefined { + const parsedSkillId = parseSkillId(skillId); + if (!parsedSkillId) { + return undefined; + } + + const normalizedSkillPath = normalizeStoredSkillPath(parsedSkillId.relativePath); + + const skills = settings.skills ?? []; + for (const entry of skills) { + if (typeof entry !== "string") continue; + const entryPath = normalizeStoredSkillPath( + entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry, + ); + const entryId = computeSkillId("*", `skills/${entryPath}`); + if (entryId === skillId || entryPath === normalizedSkillPath) { + return settingEntryState(entry); + } + } + + const packages = settings.packages ?? []; + for (const pkg of packages) { + const source = typeof pkg === "string" ? pkg : pkg.source; + const pkgSkills = typeof pkg === "object" && pkg !== null ? pkg.skills : undefined; + if (!pkgSkills) continue; + + for (const entry of pkgSkills) { + if (typeof entry !== "string") continue; + const entryPath = normalizeStoredSkillPath( + entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry, + ); + const entryId = computeSkillId(source, `skills/${entryPath}`); + if (entryId === skillId || entryPath === normalizedSkillPath) { + return settingEntryState(entry); + } + } + } + + return undefined; +} + +export function resolvePluginSkillEnabled( + settings: SkillSettingsScope, + pluginId: string, + skillName: string, + staticEnabled: boolean | undefined, +): boolean { + const skillId = computeSkillId(`plugin:${pluginId}`, `skills/${skillName}/SKILL.md`); + 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 7e33f36fb1..6ba3edc87f 100644 --- a/packages/dashboard/src/__tests__/skills-adapter.test.ts +++ b/packages/dashboard/src/__tests__/skills-adapter.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createSkillsAdapter, extractSkillName, computeSkillId, bareSkillName } from "../skills-adapter.js"; +import { resolvePluginSkillEnabled } from "@fusion/core"; import { writeFile, mkdir, access, readFile, rm } from "node:fs/promises"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; @@ -863,12 +864,10 @@ describe("createSkillsAdapter - plugin skill merge", () => { // ce-plan defaults to enabled, but a "-" entry under its plugin package // source must disable it; without the settings lookup the toggle is lost. const relativePath = "skills/ce-plan/SKILL.md"; - await writeFile( - settingsPath, - JSON.stringify({ - packages: [{ source: "plugin:fusion-plugin-compound-engineering", skills: [`-${relativePath}`] }], - }), - ); + const settings = { + packages: [{ source: "plugin:fusion-plugin-compound-engineering", skills: [`-${relativePath}`] }], + }; + await writeFile(settingsPath, JSON.stringify(settings)); try { const adapter = createSkillsAdapter({ @@ -883,6 +882,55 @@ describe("createSkillsAdapter - plugin skill merge", () => { const cePlan = skills.find((s) => s.name === "ce-plan"); expect(cePlan).toBeDefined(); expect(cePlan!.enabled).toBe(false); + expect(cePlan!.enabled).toBe(resolvePluginSkillEnabled( + settings, + "fusion-plugin-compound-engineering", + "ce-plan", + true, + )); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("keeps plugin discovery enablement in parity with the shared session resolver", async () => { + const dir = join(tmpdir(), `skills-adapter-plugin-parity-${process.pid}-${Date.now()}`); + const settingsPath = join(dir, "settings.json"); + await mkdir(dir, { recursive: true }); + const settings = { + packages: [ + { source: "plugin:fusion-plugin-compound-engineering", skills: ["+skills/opt-in/SKILL.md"] }, + { source: "plugin:fusion-plugin-compound-engineering", skills: ["-skills/opt-out/SKILL.md"] }, + ], + }; + await writeFile(settingsPath, JSON.stringify(settings)); + + try { + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: () => settingsPath, + getPluginSkills: () => [ + { pluginId: "fusion-plugin-compound-engineering", skill: { name: "opt-in", enabled: false } }, + { pluginId: "fusion-plugin-compound-engineering", skill: { name: "opt-out", enabled: true } }, + { pluginId: "fusion-plugin-compound-engineering", skill: { name: "default-on" } }, + ], + }); + + const byName = new Map((await adapter.discoverSkills(dir)).map((skill) => [skill.name, skill.enabled])); + expect(byName).toEqual(new Map([ + ["opt-in", true], + ["opt-out", false], + ["default-on", true], + ])); + for (const [name, enabled] of byName) { + const staticEnabled = name === "opt-in" ? false : true; + expect(enabled).toBe(resolvePluginSkillEnabled( + settings, + "fusion-plugin-compound-engineering", + name, + staticEnabled, + )); + } } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/packages/dashboard/src/skills-adapter.ts b/packages/dashboard/src/skills-adapter.ts index ad4abce563..d87bfa8c85 100644 --- a/packages/dashboard/src/skills-adapter.ts +++ b/packages/dashboard/src/skills-adapter.ts @@ -7,7 +7,15 @@ import { access, readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises"; import { join, relative, dirname, resolve, sep } from "node:path"; -import { superviseSpawn } from "@fusion/core"; +import { + computeSkillId, + getSkillSettingState, + normalizeStoredSkillPath, + parseSkillId, + resolvePluginSkillEnabled, + superviseSpawn, +} from "@fusion/core"; +export { computeSkillId, getSkillSettingState, parseSkillId } from "@fusion/core"; import type { ChildProcess } from "node:child_process"; /** @@ -197,39 +205,6 @@ export interface SkillFileContent { isText: boolean; } -/** - * Compute deterministic skill ID from metadata. - * Format: encodeURIComponent(metadata.source) + "::" + relativePath - * - * @param source - The package source identifier - * @param relativePath - Path relative to the skill directory - * @returns Deterministic skill ID - */ -export function computeSkillId(source: string, relativePath: string): string { - const normalizedPath = relativePath.replaceAll("\\", "/"); - return `${encodeURIComponent(source)}::${normalizedPath}`; -} - -/** - * Parse a skill ID back into source and relativePath components. - */ -export function parseSkillId(skillId: string): { source: string; relativePath: string } | null { - const parts = skillId.split("::"); - if (parts.length !== 2) return null; - try { - return { - source: decodeURIComponent(parts[0]!), - relativePath: parts[1]!, - }; - } catch { - return null; - } -} - -function normalizeStoredSkillPath(path: string): string { - return path.replaceAll("\\", "/").replace(/^skills\//, ""); -} - /** * Reduce any skill-name form to a single bare token (lowercased) for dedup: * "ce-work/SKILL.md" / "::skills/ce-work/SKILL.md" / "ce-work" → "ce-work" @@ -274,55 +249,6 @@ async function waitForSupervisedExit( return Promise.race([exitPromise, spawnError]); } -/** - * Resolve a skill's explicit enable/disable state from settings. - * Checks both top-level skills and package-scoped skills. Returns "enabled" or - * "disabled" when a settings entry matches, or undefined when the settings file - * says nothing about this skill -- so callers can apply their own default. - */ -function getSkillSettingState( - skillId: string, - settings: { skills?: string[]; packages?: Array<{ source: string; skills?: string[] }> }, -): "enabled" | "disabled" | undefined { - const parsedSkillId = parseSkillId(skillId); - if (!parsedSkillId) { - return undefined; - } - - const normalizedSkillPath = normalizeStoredSkillPath(parsedSkillId.relativePath); - - const skills = settings.skills ?? []; - for (const entry of skills) { - const entryPath = normalizeStoredSkillPath( - entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry, - ); - const entryId = computeSkillId("*", `skills/${entryPath}`); - if (entryId === skillId || entryPath === normalizedSkillPath) { - return entry.startsWith("+") ? "enabled" : "disabled"; - } - } - - // Check package-scoped skills - const packages = settings.packages ?? []; - for (const pkg of packages) { - const source = typeof pkg === "string" ? pkg : pkg.source; - const pkgSkills = typeof pkg === "object" ? pkg.skills : undefined; - if (!pkgSkills) continue; - - for (const entry of pkgSkills) { - const entryPath = normalizeStoredSkillPath( - entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry, - ); - const entryId = computeSkillId(source, `skills/${entryPath}`); - if (entryId === skillId || entryPath === normalizedSkillPath) { - return entry.startsWith("+") ? "enabled" : "disabled"; - } - } - } - - return undefined; -} - /** * Check if a skill path is enabled in the settings. * Checks both top-level skills and package-scoped skills. @@ -433,17 +359,12 @@ export function createSkillsAdapter(options: { seenBareNames.add(bare); const relativePath = `skills/${name}/SKILL.md`; const id = computeSkillId(`plugin:${pluginId}`, relativePath); - // Respect an explicit enable/disable written to project settings by - // toggleExecutionSkill, falling back to the plugin's declared default. - // Without consulting settings here, a user toggle on a plugin skill - // would be silently reverted on the very next discovery. - const settingState = getSkillSettingState( - id, - settings as Parameters[1], + const enabled = resolvePluginSkillEnabled( + settings as Parameters[0], + pluginId, + name, + skill.enabled, ); - const enabled = settingState === undefined - ? skill.enabled !== false - : settingState === "enabled"; discoveredSkills.push({ id, name, diff --git a/packages/engine/src/__tests__/session-skill-context.test.ts b/packages/engine/src/__tests__/session-skill-context.test.ts index 4920292525..6794114052 100644 --- a/packages/engine/src/__tests__/session-skill-context.test.ts +++ b/packages/engine/src/__tests__/session-skill-context.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { normalizeAgentSkills, collectPluginSkillNames, @@ -10,6 +13,26 @@ import { import type { Agent, AgentStore } from "@fusion/core"; import type { PluginRunner } from "../plugin-runner.js"; +const tempDirs: string[] = []; + +async function createProjectWithSettings(settings: Record): Promise { + const projectRoot = await mkdtemp(join(tmpdir(), "session-skill-context-")); + tempDirs.push(projectRoot); + await mkdir(join(projectRoot, ".fusion"), { recursive: true }); + await writeFile(join(projectRoot, ".fusion", "settings.json"), JSON.stringify(settings), "utf-8"); + return projectRoot; +} + +function pluginRunnerWithSkills( + skills: Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }>, +): PluginRunner { + return { getPluginSkills: vi.fn().mockReturnValue(skills) } as unknown as PluginRunner; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + describe("normalizeAgentSkills", () => { it("returns empty array for non-array input", () => { expect(normalizeAgentSkills(undefined)).toEqual([]); @@ -279,6 +302,23 @@ describe("buildSessionSkillContextSync", () => { expect(result.resolvedSkillNames).toEqual(["fusion", "plugin-skill"]); }); + it("honors per-project plugin skill toggles in sync path", async () => { + const projectRoot = await createProjectWithSettings({ + packages: [ + { source: "plugin:plugin-a", skills: ["+skills/opt-in/SKILL.md"] }, + { source: "plugin:plugin-b", skills: ["-skills/opt-out/SKILL.md"] }, + ], + }); + const pluginRunner = pluginRunnerWithSkills([ + { pluginId: "plugin-a", skill: { name: "opt-in", enabled: false } }, + { pluginId: "plugin-b", skill: { name: "opt-out", enabled: true } }, + { pluginId: "plugin-c", skill: { name: "default-on" } }, + ]); + + const result = buildSessionSkillContextSync(null, "executor", projectRoot, pluginRunner); + expect(result.resolvedSkillNames).toEqual(["fusion", "opt-in", "default-on"]); + }); + it("keeps legacy behavior in sync path when pluginRunner is omitted", () => { const result = buildSessionSkillContextSync(null, "executor", projectRootDir); expect(result.resolvedSkillNames).toEqual(["fusion"]); @@ -323,6 +363,78 @@ describe("collectPluginSkillNames", () => { pluginIds: ["plugin-b"], }); }); + + it("uses project settings to enable a statically disabled plugin skill", async () => { + const projectRoot = await createProjectWithSettings({ + packages: [{ source: "plugin:plugin-a", skills: ["+skills/alpha/SKILL.md"] }], + }); + const pluginRunner = pluginRunnerWithSkills([ + { pluginId: "plugin-a", skill: { name: "alpha", enabled: false } }, + ]); + + expect(collectPluginSkillNames(pluginRunner, projectRoot)).toEqual({ + names: ["alpha"], + pluginIds: ["plugin-a"], + }); + }); + + it("uses project settings to disable a statically enabled plugin skill", async () => { + const projectRoot = await createProjectWithSettings({ + packages: [{ source: "plugin:plugin-a", skills: ["-skills/alpha/SKILL.md"] }], + }); + const pluginRunner = pluginRunnerWithSkills([ + { pluginId: "plugin-a", skill: { name: "alpha", enabled: true } }, + { pluginId: "plugin-b", skill: { name: "beta" } }, + ]); + + expect(collectPluginSkillNames(pluginRunner, projectRoot)).toEqual({ + names: ["beta"], + pluginIds: ["plugin-b"], + }); + }); + + it("falls back to static defaults when project settings omit a plugin skill", async () => { + const projectRoot = await createProjectWithSettings({ skills: [] }); + const pluginRunner = pluginRunnerWithSkills([ + { pluginId: "plugin-a", skill: { name: "alpha", enabled: false } }, + { pluginId: "plugin-b", skill: { name: "beta" } }, + ]); + + expect(collectPluginSkillNames(pluginRunner, projectRoot)).toEqual({ + names: ["beta"], + pluginIds: ["plugin-b"], + }); + }); + + it("honors top-level skill toggle entries for plugin skills", async () => { + const projectRoot = await createProjectWithSettings({ + skills: ["+skills/alpha/SKILL.md"], + }); + const pluginRunner = pluginRunnerWithSkills([ + { pluginId: "plugin-a", skill: { name: "alpha", enabled: false } }, + ]); + + expect(collectPluginSkillNames(pluginRunner, projectRoot)).toEqual({ + names: ["alpha"], + pluginIds: ["plugin-a"], + }); + }); + + it("resolves settings from the real project root when called with a worktree path", async () => { + const projectRoot = await createProjectWithSettings({ + packages: [{ source: "plugin:plugin-a", skills: ["+skills/alpha/SKILL.md"] }], + }); + const worktreeRoot = join(projectRoot, ".worktrees", "branch-a"); + await mkdir(worktreeRoot, { recursive: true }); + const pluginRunner = pluginRunnerWithSkills([ + { pluginId: "plugin-a", skill: { name: "alpha", enabled: false } }, + ]); + + expect(collectPluginSkillNames(pluginRunner, worktreeRoot)).toEqual({ + names: ["alpha"], + pluginIds: ["plugin-a"], + }); + }); }); describe("buildSessionSkillContext", () => { @@ -489,6 +601,32 @@ describe("buildSessionSkillContext", () => { expect(result.skillSelectionContext?.requestedSkillNames).toEqual(["fusion", "plugin-skill"]); }); + it("honors per-project plugin skill toggles in async path", async () => { + const projectRoot = await createProjectWithSettings({ + packages: [ + { source: "plugin:plugin-a", skills: ["+skills/opt-in/SKILL.md"] }, + { source: "plugin:plugin-b", skills: ["-skills/opt-out/SKILL.md"] }, + ], + }); + const mockAgentStore = { getAgent: vi.fn().mockResolvedValue(null) } as unknown as AgentStore; + const pluginRunner = pluginRunnerWithSkills([ + { pluginId: "plugin-a", skill: { name: "opt-in", enabled: false } }, + { pluginId: "plugin-b", skill: { name: "opt-out", enabled: true } }, + { pluginId: "plugin-c", skill: { name: "default-on" } }, + ]); + + const result = await buildSessionSkillContext({ + agentStore: mockAgentStore, + task: {}, + sessionPurpose: "executor", + projectRootDir: projectRoot, + pluginRunner, + }); + + expect(result.resolvedSkillNames).toEqual(["fusion", "opt-in", "default-on"]); + expect(result.skillSelectionContext?.requestedSkillNames).toEqual(["fusion", "opt-in", "default-on"]); + }); + it("deduplicates plugin skills against assigned-agent skills case-insensitively", async () => { const mockAgent: Agent = { id: "agent-001", diff --git a/packages/engine/src/session-skill-context.ts b/packages/engine/src/session-skill-context.ts index 02f4c06a56..cd01f5924b 100644 --- a/packages/engine/src/session-skill-context.ts +++ b/packages/engine/src/session-skill-context.ts @@ -30,9 +30,10 @@ */ import type { Agent, AgentStore } from "@fusion/core"; +import { resolvePluginSkillEnabled } from "@fusion/core"; import { piLog } from "./logger.js"; import type { PluginRunner } from "./plugin-runner.js"; -import type { SkillSelectionContext } from "./skill-resolver.js"; +import { readProjectSettings, resolveProjectRoot, type SkillSelectionContext } from "./skill-resolver.js"; // ── Types ─────────────────────────────────────────────────────────────────── @@ -123,13 +124,27 @@ export function normalizeAgentSkills( return result; } +/** + * FNXC:PluginSkills 2026-07-12-00:00: + * Session assembly must honor the same per-project plugin-skill override as the Skills view. Resolve worktree project roots before reading settings, then delegate effective enablement to @fusion/core so static plugin defaults cannot drift from project toggles again. + */ export function collectPluginSkillNames( pluginRunner: PluginRunner | undefined, + projectRootDir?: string, ): { names: string[]; pluginIds: string[] } { if (!pluginRunner) { return { names: [], pluginIds: [] }; } + let settings = {}; + if (projectRootDir) { + try { + settings = readProjectSettings(resolveProjectRoot(projectRootDir)); + } catch { + settings = {}; + } + } + const pluginSkills = pluginRunner.getPluginSkills(); const seenNames = new Set(); const pluginIds = new Set(); @@ -137,11 +152,11 @@ export function collectPluginSkillNames( for (const contribution of pluginSkills) { const { pluginId, skill } = contribution; - if (skill.enabled === false) { + const name = skill.name.trim(); + if (!resolvePluginSkillEnabled(settings, pluginId, name, skill.enabled)) { continue; } - const name = skill.name.trim(); if (name.length === 0 || seenNames.has(name)) { continue; } @@ -296,7 +311,7 @@ function mergePluginSkills( projectRootDir: string, pluginRunner: PluginRunner | undefined, ): SessionSkillContextResult { - const { names: pluginSkillNames } = collectPluginSkillNames(pluginRunner); + const { names: pluginSkillNames } = collectPluginSkillNames(pluginRunner, projectRootDir); if (pluginSkillNames.length === 0) { return baseResult; } diff --git a/packages/engine/src/skill-resolver.ts b/packages/engine/src/skill-resolver.ts index 765252ccee..14d5efb263 100644 --- a/packages/engine/src/skill-resolver.ts +++ b/packages/engine/src/skill-resolver.ts @@ -110,7 +110,7 @@ export interface SkillSelectionResult { /** * Project settings structure relevant to skill selection. */ -interface ProjectSkillSettings { +export interface ProjectSkillSettings { skills?: string[]; packages?: Array; } @@ -137,7 +137,7 @@ function readJsonObject(path: string): Record { /** * Read project settings from .fusion/settings.json. */ -function readProjectSettings(projectRootDir: string): ProjectSkillSettings { +export function readProjectSettings(projectRootDir: string): ProjectSkillSettings { const fusionSettings = join(projectRootDir, ".fusion", "settings.json"); if (existsSync(fusionSettings)) {