feat(FN-1811): merge fusion/fn-1811

This commit is contained in:
gsxdsm
2026-04-14 13:00:49 -07:00
parent 136d9cc4ce
commit 3e6b8fad02
3 changed files with 444 additions and 0 deletions

View File

@@ -0,0 +1,243 @@
/**
* Integration-style tests for agent skills flow.
*
* Tests the full metadata → engine flow using mocked AgentStore and in-memory filesystem.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildSessionSkillContext } from "../session-skill-context.js";
import { resolveSessionSkills, createSkillsOverrideFromSelection } from "../skill-resolver.js";
import type { Agent, AgentStore } from "@fusion/core";
// ── Mock Setup ───────────────────────────────────────────────────────────────
// In-memory file system for tests - using a module-scoped Map
const mockFiles = new Map<string, string>();
let mockDirCounter = 0;
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
existsSync: (path: unknown) => mockFiles.has(String(path)),
readFileSync: (path: unknown) => mockFiles.get(String(path)) ?? "{}",
mkdtempSync: () => `/tmp/agent-skills-flow-mock-${++mockDirCounter}`,
writeFileSync: (path: unknown, content: unknown) => mockFiles.set(String(path), String(content)),
rmSync: (path: unknown) => {
const pathStr = String(path);
for (const key of mockFiles.keys()) {
if (key.startsWith(pathStr)) mockFiles.delete(key);
}
},
};
});
// ── Test Helpers ─────────────────────────────────────────────────────────────
function createMockProjectDir(settings: Record<string, unknown> | null): string {
const dir = `/tmp/agent-skills-flow-mock-${++mockDirCounter}`;
if (settings !== null) {
mockFiles.set(`${dir}/.fusion/settings.json`, JSON.stringify(settings));
}
return dir;
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe("agent skills flow - full integration", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
beforeEach(() => {
mockFiles.clear();
mockDirCounter = 0;
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it("full end-to-end flow: settings patterns + agent metadata + discovered skills produce correct override", async () => {
// Step 1: Set up mock filesystem with settings that include review but exclude lint
const projectRootDir = createMockProjectDir({
skills: ["+skills/review/SKILL.md", "+skills/lint/SKILL.md", "-skills/lint/SKILL.md"],
});
// Step 2: Create mock AgentStore with agent that has review skill
const mockAgent: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "idle",
metadata: { skills: ["review"] },
} as unknown as Agent;
const mockAgentStore = {
getAgent: vi.fn().mockResolvedValue(mockAgent),
} as unknown as AgentStore;
// Step 3: Create task with assigned agent
const task = { assignedAgentId: "agent-001" };
// Step 4: Build session skill context from agent metadata
const sessionResult = await buildSessionSkillContext({
agentStore: mockAgentStore,
task,
sessionPurpose: "executor",
projectRootDir,
});
// Verify skill source and resolved names
expect(sessionResult.skillSource).toBe("assigned-agent");
expect(sessionResult.resolvedSkillNames).toEqual(["review"]);
expect(sessionResult.skillSelectionContext).toBeDefined();
expect(sessionResult.skillSelectionContext?.requestedSkillNames).toEqual(["review"]);
// Step 5: Resolve session skills from project settings
const resolvedSkills = resolveSessionSkills(sessionResult.skillSelectionContext!);
// Verify the settings-based resolution
expect(resolvedSkills.filterActive).toBe(true);
expect(resolvedSkills.allowedSkillPaths.has("skills/review/SKILL.md")).toBe(true);
// lint should NOT be in allowed paths (it was explicitly excluded)
expect(resolvedSkills.allowedSkillPaths.has("skills/lint/SKILL.md")).toBe(false);
// lint should be in excluded paths
expect(resolvedSkills.excludedSkillPaths.has("skills/lint/SKILL.md")).toBe(true);
// Step 6: Create override callback from selection
const override = createSkillsOverrideFromSelection(resolvedSkills, {
sessionPurpose: "executor",
});
// Step 7: Apply override to discovered skills (both review and lint exist)
const base = {
skills: [
{ name: "review", filePath: "skills/review/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
{ name: "lint", filePath: "skills/lint/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
],
diagnostics: [],
};
const overrideResult = override(base);
// Step 8: Verify only review passes through (lint is disabled)
expect(overrideResult.skills).toHaveLength(1);
expect(overrideResult.skills[0].name).toBe("review");
// Step 9: Verify warning diagnostic for disabled lint skill
const disabledLintWarning = overrideResult.diagnostics.find(d =>
d.message.includes("disabled") && d.message.includes("lint")
);
expect(disabledLintWarning).toBeDefined();
expect(disabledLintWarning?.type).toBe("warning");
// Step 10: Verify console.error was called with disabled skill warning
const loggedMessages = consoleErrorSpy.mock.calls.map(c => c[0] as string);
const hasDisabledLintWarning = loggedMessages.some(m =>
m.includes("disabled") && m.includes("lint")
);
expect(hasDisabledLintWarning).toBe(true);
});
it("flow with no exclusion pattern - both review and lint requested", async () => {
// Step 1: Set up mock filesystem with settings that include both skills
const projectRootDir = createMockProjectDir({
skills: ["+skills/review/SKILL.md", "+skills/lint/SKILL.md"],
});
// Step 2: Create mock AgentStore with agent that has BOTH review and lint skills
const mockAgent: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "idle",
metadata: { skills: ["review", "lint"] },
} as unknown as Agent;
const mockAgentStore = {
getAgent: vi.fn().mockResolvedValue(mockAgent),
} as unknown as AgentStore;
// Step 3: Build session skill context
const sessionResult = await buildSessionSkillContext({
agentStore: mockAgentStore,
task: { assignedAgentId: "agent-001" },
sessionPurpose: "executor",
projectRootDir,
});
expect(sessionResult.skillSource).toBe("assigned-agent");
expect(sessionResult.resolvedSkillNames).toEqual(["review", "lint"]);
// Step 4: Resolve session skills from settings
const resolvedSkills = resolveSessionSkills(sessionResult.skillSelectionContext!);
expect(resolvedSkills.filterActive).toBe(true);
expect(resolvedSkills.allowedSkillPaths.has("skills/review/SKILL.md")).toBe(true);
expect(resolvedSkills.allowedSkillPaths.has("skills/lint/SKILL.md")).toBe(true);
expect(resolvedSkills.excludedSkillPaths.size).toBe(0);
// Step 5: Create override and apply to discovered skills
const override = createSkillsOverrideFromSelection(resolvedSkills, {
sessionPurpose: "executor",
});
const base = {
skills: [
{ name: "review", filePath: "skills/review/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
{ name: "lint", filePath: "skills/lint/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
],
diagnostics: [],
};
const overrideResult = override(base);
// When both skills are requested, both should pass through
expect(overrideResult.skills).toHaveLength(2);
expect(overrideResult.skills.map(s => s.name)).toEqual(["review", "lint"]);
// No disabled warnings since neither skill was excluded
const disabledWarnings = overrideResult.diagnostics.filter(d =>
d.message.includes("disabled")
);
expect(disabledWarnings).toHaveLength(0);
});
it("flow with role fallback when assigned agent has no skills", async () => {
// Step 1: Set up mock filesystem
const projectRootDir = createMockProjectDir({
skills: ["+skills/executor/SKILL.md"],
});
// Step 2: Create mock AgentStore with agent that has NO skills
const mockAgent: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "idle",
metadata: { skills: [] },
} as unknown as Agent;
const mockAgentStore = {
getAgent: vi.fn().mockResolvedValue(mockAgent),
} as unknown as AgentStore;
// Step 3: Build session skill context - should fall back to role
const sessionResult = await buildSessionSkillContext({
agentStore: mockAgentStore,
task: { assignedAgentId: "agent-001" },
sessionPurpose: "executor",
projectRootDir,
});
// Verify role fallback
expect(sessionResult.skillSource).toBe("role-fallback");
expect(sessionResult.resolvedSkillNames).toEqual(["executor"]);
expect(sessionResult.skillSelectionContext?.requestedSkillNames).toEqual(["executor"]);
// Step 4: Resolve session skills from settings
const resolvedSkills = resolveSessionSkills(sessionResult.skillSelectionContext!);
expect(resolvedSkills.filterActive).toBe(true);
expect(resolvedSkills.allowedSkillPaths.has("skills/executor/SKILL.md")).toBe(true);
});
});

View File

@@ -84,6 +84,20 @@ describe("normalizeAgentSkills", () => {
it("returns empty array for array of only invalid entries", () => {
expect(normalizeAgentSkills([null, undefined, "", 123, {}])).toEqual([]);
});
it("handles object entries with name, deduplicates, trims, and drops invalid entries", () => {
const skills = [
{ name: " review " },
" custom-skill ",
{ name: "review" },
123,
null,
{ foo: "bar" },
"",
{ name: "" },
];
expect(normalizeAgentSkills(skills)).toEqual(["review", "custom-skill"]);
});
});
describe("buildSessionSkillContextSync", () => {
@@ -125,6 +139,26 @@ describe("buildSessionSkillContextSync", () => {
expect(result.resolvedSkillNames).toEqual(["triage", "executor"]);
});
it("correctly extracts skills from a cached agent object", () => {
const agent = {
id: "agent-001",
name: "Test Agent",
role: "executor" as const,
state: "idle" as const,
metadata: { skills: ["triage", "executor"] },
} as unknown as Agent;
const result = buildSessionSkillContextSync(agent, "executor", projectRootDir);
expect(result.skillSource).toBe("assigned-agent");
expect(result.resolvedSkillNames).toEqual(["triage", "executor"]);
expect(result.skillSelectionContext).toEqual({
projectRootDir,
requestedSkillNames: ["triage", "executor"],
sessionPurpose: "executor",
});
});
it("falls back to role when agent has empty skills", () => {
const agent: Agent = {
id: "agent-001",
@@ -261,6 +295,56 @@ describe("buildSessionSkillContext", () => {
expect(mockAgentStore.getAgent).toHaveBeenCalledWith("agent-001");
});
it("resolves assigned-agent skills when task.assignedAgentId points to an agent with metadata.skills", async () => {
const mockAgent: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "idle",
metadata: { skills: ["review", "custom-skill"] },
} as unknown as Agent;
const mockAgentStore = {
getAgent: vi.fn().mockResolvedValue(mockAgent),
} as unknown as AgentStore;
const result = await buildSessionSkillContext({
agentStore: mockAgentStore,
task: { assignedAgentId: "agent-001" },
sessionPurpose: "executor",
projectRootDir,
});
expect(result.skillSource).toBe("assigned-agent");
expect(result.resolvedSkillNames).toEqual(["review", "custom-skill"]);
expect(result.skillSelectionContext?.requestedSkillNames).toEqual(["review", "custom-skill"]);
expect(mockAgentStore.getAgent).toHaveBeenCalledWith("agent-001");
});
it("falls back to role fallback skills when assigned agent has no skills", async () => {
const mockAgent: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "idle",
metadata: { skills: [] },
} as unknown as Agent;
const mockAgentStore = {
getAgent: vi.fn().mockResolvedValue(mockAgent),
} as unknown as AgentStore;
const result = await buildSessionSkillContext({
agentStore: mockAgentStore,
task: { assignedAgentId: "agent-001" },
sessionPurpose: "executor",
projectRootDir,
});
expect(result.skillSource).toBe("role-fallback");
expect(result.resolvedSkillNames).toEqual(["executor"]);
});
it("falls back to role when no assignedAgentId", async () => {
const mockAgentStore = {
getAgent: vi.fn(),

View File

@@ -358,6 +358,29 @@ describe("resolveSessionSkills", () => {
expect(infoDiags).toHaveLength(1); // Only + pattern gets info diag
expect(infoDiags[0].skillPath).toBe("skills/foo/SKILL.md");
});
it("with requestedSkillNames intersects with allowed patterns and produces correct diagnostics", () => {
const dir = createMockProjectDir({
skills: ["+skills/review/SKILL.md", "+skills/lint/SKILL.md"],
});
const result = resolveSessionSkills({
projectRootDir: dir,
requestedSkillNames: ["review", "missing-skill"],
});
expect(result.filterActive).toBe(true);
expect(result.allowedSkillPaths.has("skills/review/SKILL.md")).toBe(true);
expect(result.allowedSkillPaths.has("skills/lint/SKILL.md")).toBe(true);
// Check for info diagnostics
const infoDiags = result.diagnostics.filter(d => d.type === "info");
// Should have diagnostics for: review (requested), missing-skill (requested), +skills/review (pattern), +skills/lint (pattern)
expect(infoDiags.some(d => d.skillName === "review")).toBe(true);
expect(infoDiags.some(d => d.skillName === "missing-skill")).toBe(true);
expect(infoDiags.some(d => d.skillPath === "skills/review/SKILL.md")).toBe(true);
expect(infoDiags.some(d => d.skillPath === "skills/lint/SKILL.md")).toBe(true);
});
});
});
@@ -673,5 +696,99 @@ describe("createSkillsOverrideFromSelection", () => {
// Order is preserved from input order (deterministic = consistent)
expect(result1.skills.map(s => s.name)).toEqual(["c", "a", "b"]);
});
it("filters discovered Skill[] by requested names and logs warnings for missing skills", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Create selection with empty allowed paths (only requested names filtering)
const selection: SkillSelectionResult = {
allowedSkillPaths: new Set<string>(),
excludedSkillPaths: new Set<string>(),
diagnostics: [],
filterActive: true,
};
const override = createSkillsOverrideFromSelection(selection, {
requestedSkillNames: ["found-skill", "missing-skill"],
sessionPurpose: "executor",
});
// Only one skill matches the requested names
const base = {
skills: [
{ name: "found-skill", filePath: "/path/found", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
],
diagnostics: [],
};
const result = override(base);
// Should only return the found skill
expect(result.skills).toHaveLength(1);
expect(result.skills[0].name).toBe("found-skill");
// Should produce warning for missing-skill
const missingWarning = result.diagnostics.find(d =>
d.type === "warning" && d.message.includes("missing-skill") && d.message.includes("not found in discovered skills")
);
expect(missingWarning).toBeDefined();
// Verify console.error logging
expect(consoleErrorSpy).toHaveBeenCalled();
const loggedMessages = consoleErrorSpy.mock.calls.map(c => c[0] as string);
const hasExecutorPrefix = loggedMessages.some(m => m.includes("[executor]") && m.includes("missing-skill"));
expect(hasExecutorPrefix).toBe(true);
consoleErrorSpy.mockRestore();
});
});
describe("end-to-end flow with project settings + agent metadata + discovered skills", () => {
beforeEach(() => {
mockFiles.clear();
mockDirCounter = 0;
});
it("full end-to-end flow: settings patterns + agent skills produce matching override", () => {
// Create mock project dir with settings that include review but exclude lint
const dir = createMockProjectDir({
skills: ["+skills/review/SKILL.md"],
});
// Step 1: Resolve session skills from settings
const resolvedSkills = resolveSessionSkills({
projectRootDir: dir,
requestedSkillNames: ["review"],
});
expect(resolvedSkills.filterActive).toBe(true);
expect(resolvedSkills.allowedSkillPaths.has("skills/review/SKILL.md")).toBe(true);
// Step 2: Create override from selection
const override = createSkillsOverrideFromSelection(resolvedSkills, {
sessionPurpose: "executor",
});
// Step 3: Apply override to discovered skills (both review and lint exist)
const base = {
skills: [
{ name: "review", filePath: "skills/review/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
{ name: "lint", filePath: "skills/lint/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
],
diagnostics: [],
};
const result = override(base);
// Only review should pass through (lint is not in allowed paths)
expect(result.skills).toHaveLength(1);
expect(result.skills[0].name).toBe("review");
// No missing-skill warnings since review exists and matches the pattern
const missingWarnings = result.diagnostics.filter(d =>
d.type === "warning" && d.message.includes("not found")
);
expect(missingWarnings).toHaveLength(0);
});
});
});