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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7858-plugin-skill-session-toggle.md
Normal file
7
.changeset/fn-7858-plugin-skill-session-toggle.md
Normal file
@@ -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).
|
||||
@@ -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)
|
||||
|
||||
|
||||
62
packages/core/src/__tests__/skill-settings.test.ts
Normal file
62
packages/core/src/__tests__/skill-settings.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
102
packages/core/src/skill-settings.ts
Normal file
102
packages/core/src/skill-settings.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
export type SkillSettingState = "enabled" | "disabled";
|
||||
|
||||
export interface SkillSettingsScope {
|
||||
skills?: string[];
|
||||
packages?: Array<string | { source: string; skills?: string[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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" / "<src>::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<typeof getSkillSettingState>[1],
|
||||
const enabled = resolvePluginSkillEnabled(
|
||||
settings as Parameters<typeof resolvePluginSkillEnabled>[0],
|
||||
pluginId,
|
||||
name,
|
||||
skill.enabled,
|
||||
);
|
||||
const enabled = settingState === undefined
|
||||
? skill.enabled !== false
|
||||
: settingState === "enabled";
|
||||
discoveredSkills.push({
|
||||
id,
|
||||
name,
|
||||
|
||||
@@ -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<string, unknown>): Promise<string> {
|
||||
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",
|
||||
|
||||
@@ -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<string>();
|
||||
const pluginIds = new Set<string>();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export interface SkillSelectionResult {
|
||||
/**
|
||||
* Project settings structure relevant to skill selection.
|
||||
*/
|
||||
interface ProjectSkillSettings {
|
||||
export interface ProjectSkillSettings {
|
||||
skills?: string[];
|
||||
packages?: Array<string | { source: string; skills?: string[] }>;
|
||||
}
|
||||
@@ -137,7 +137,7 @@ function readJsonObject(path: string): Record<string, unknown> {
|
||||
/**
|
||||
* 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)) {
|
||||
|
||||
Reference in New Issue
Block a user