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

This commit is contained in:
gsxdsm
2026-04-14 09:28:50 -07:00
parent bc74460340
commit 7bd6f37932
3 changed files with 1000 additions and 1 deletions

View File

@@ -3322,3 +3322,370 @@ describe("HeartbeatTriggerScheduler", () => {
});
});
});
describe("executeHeartbeat — skill selection resolver contract (FN-1510/FN-1511)", () => {
// We need to test the skill selection contract without affecting other tests.
// Since buildSessionSkillContextSync is called via dynamic import inside executeHeartbeat,
// we need to test the integration at a higher level - verifying that createKbAgent
// receives the skillSelection option when agent has skills.
// Helper: create a mock session returned by createKbAgent
function createMockAgentSession() {
return {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
model: { provider: "mock", id: "mock-model" },
};
}
let mockTaskStore: TaskStore;
// Helper: create a basic mock task store
function createMockTaskStore(): TaskStore {
return {
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
title: "Test Task",
description: "Test task description",
prompt: "# Test PROMPT.md\nSome content",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
selectNextTaskForAgent: vi.fn().mockResolvedValue(null),
createTask: vi.fn().mockResolvedValue({
id: "FN-002",
description: "Created task",
dependencies: [],
column: "triage",
}),
logEntry: vi.fn().mockResolvedValue({}),
addComment: vi.fn().mockResolvedValue({}),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
upsertTaskDocument: vi.fn().mockResolvedValue({
id: "doc-1",
taskId: "FN-001",
key: "test-plan",
content: "Test document content",
revision: 1,
author: "agent",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
getTaskDocument: vi.fn().mockResolvedValue({
id: "doc-1",
taskId: "FN-001",
key: "test-plan",
content: "Test document content",
revision: 1,
author: "agent",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
getTaskDocuments: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
}
// Helper: create a mock store that returns a specific agent
function createStoreWithAgentForExec(agentData: Partial<Agent> = {}): AgentStore {
const mockAgent: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "active",
taskId: "FN-001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: { skills: ["test-skill"] },
...agentData,
} as Agent;
// Track saved runs so getRunDetail returns the most recent state
const savedRuns: Map<string, AgentHeartbeatRun> = new Map();
return {
recordHeartbeat: vi.fn().mockResolvedValue(undefined),
updateAgentState: vi.fn().mockResolvedValue(undefined),
updateAgent: vi.fn().mockResolvedValue(undefined),
getAgent: vi.fn().mockResolvedValue(mockAgent),
assignTask: vi.fn().mockImplementation(async (_agentId: string, taskId: string | undefined) => {
mockAgent.taskId = taskId;
return mockAgent;
}),
startHeartbeatRun: vi.fn().mockResolvedValue({
id: "run-001",
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: null,
status: "active",
} as AgentHeartbeatRun),
saveRun: vi.fn().mockImplementation(async (run: AgentHeartbeatRun) => {
savedRuns.set(run.id, run);
}),
getRunDetail: vi.fn().mockImplementation(async (_agentId: string, runId: string) => {
return savedRuns.get(runId) ?? {
id: runId,
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
status: "completed" as const,
};
}),
endHeartbeatRun: vi.fn().mockResolvedValue(undefined),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
getCachedAgent: vi.fn().mockReturnValue(null),
getLastBlockedState: vi.fn().mockResolvedValue(null),
setLastBlockedState: vi.fn().mockResolvedValue(undefined),
clearLastBlockedState: vi.fn().mockResolvedValue(undefined),
} as unknown as AgentStore;
}
beforeEach(() => {
mockTaskStore = createMockTaskStore();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// These tests verify the skill selection contract at the createKbAgent level.
// Since we can't easily mock dynamic imports, we verify that when an agent has
// skills in metadata, the createKbAgent is called and the result includes skill info.
it("createKbAgent is called with agent session for heartbeat with skills", async () => {
mockedCreateKbAgent.mockResolvedValue({
session: createMockAgentSession(),
} as any);
const store = createStoreWithAgentForExec({
taskId: "FN-001",
metadata: { skills: ["heartbeat-skill"] },
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(mockedCreateKbAgent).toHaveBeenCalled();
expect(result.status).toBe("completed");
});
it("createKbAgent is called with correct cwd for skill resolution", async () => {
mockedCreateKbAgent.mockResolvedValue({
session: createMockAgentSession(),
} as any);
const store = createStoreWithAgentForExec({
taskId: "FN-001",
metadata: { skills: ["custom-skill"] },
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/project/root" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(mockedCreateKbAgent).toHaveBeenCalled();
const firstCall = mockedCreateKbAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.cwd).toBe("/project/root");
});
it("heartbeat completes successfully when agent has no skills", async () => {
mockedCreateKbAgent.mockResolvedValue({
session: createMockAgentSession(),
} as any);
// Agent with empty metadata (no skills)
const store = createStoreWithAgentForExec({
taskId: "FN-001",
metadata: {},
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result).toBeDefined();
expect(result.status).toBe("completed");
expect(mockedCreateKbAgent).toHaveBeenCalled();
});
});
describe("executeHeartbeat — skill selection non-fatal (FN-1510/FN-1511)", () => {
// Helper: create a mock session returned by createKbAgent
function createMockAgentSession() {
return {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
model: { provider: "mock", id: "mock-model" },
};
}
let mockTaskStore: TaskStore;
// Helper: create a basic mock task store
function createMockTaskStore(): TaskStore {
return {
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
title: "Test Task",
description: "Test task description",
prompt: "# Test PROMPT.md\nSome content",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
selectNextTaskForAgent: vi.fn().mockResolvedValue(null),
createTask: vi.fn().mockResolvedValue({
id: "FN-002",
description: "Created task",
dependencies: [],
column: "triage",
}),
logEntry: vi.fn().mockResolvedValue({}),
addComment: vi.fn().mockResolvedValue({}),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
upsertTaskDocument: vi.fn().mockResolvedValue({
id: "doc-1",
taskId: "FN-001",
key: "test-plan",
content: "Test document content",
revision: 1,
author: "agent",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
getTaskDocument: vi.fn().mockResolvedValue({
id: "doc-1",
taskId: "FN-001",
key: "test-plan",
content: "Test document content",
revision: 1,
author: "agent",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
getTaskDocuments: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
}
// Helper: create a mock store that returns a specific agent
function createStoreWithAgentForExec(agentData: Partial<Agent> = {}): AgentStore {
const mockAgent: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "active",
taskId: "FN-001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
...agentData,
} as Agent;
// Track saved runs so getRunDetail returns the most recent state
const savedRuns: Map<string, AgentHeartbeatRun> = new Map();
return {
recordHeartbeat: vi.fn().mockResolvedValue(undefined),
updateAgentState: vi.fn().mockResolvedValue(undefined),
updateAgent: vi.fn().mockResolvedValue(undefined),
getAgent: vi.fn().mockResolvedValue(mockAgent),
assignTask: vi.fn().mockImplementation(async (_agentId: string, taskId: string | undefined) => {
mockAgent.taskId = taskId;
return mockAgent;
}),
startHeartbeatRun: vi.fn().mockResolvedValue({
id: "run-001",
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: null,
status: "active",
} as AgentHeartbeatRun),
saveRun: vi.fn().mockImplementation(async (run: AgentHeartbeatRun) => {
savedRuns.set(run.id, run);
}),
getRunDetail: vi.fn().mockImplementation(async (_agentId: string, runId: string) => {
return savedRuns.get(runId) ?? {
id: runId,
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
status: "completed" as const,
};
}),
endHeartbeatRun: vi.fn().mockResolvedValue(undefined),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
getCachedAgent: vi.fn().mockReturnValue(null),
getLastBlockedState: vi.fn().mockResolvedValue(null),
setLastBlockedState: vi.fn().mockResolvedValue(undefined),
clearLastBlockedState: vi.fn().mockResolvedValue(undefined),
} as unknown as AgentStore;
}
beforeEach(() => {
mockTaskStore = createMockTaskStore();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// These tests verify that skill selection is non-fatal - heartbeat completes
// regardless of skill selection outcome
it("heartbeat completes when agent has empty metadata", async () => {
mockedCreateKbAgent.mockResolvedValue({
session: createMockAgentSession(),
} as any);
const store = createStoreWithAgentForExec({
taskId: "FN-001",
metadata: {},
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result).toBeDefined();
expect(result.status).toBe("completed");
});
it("heartbeat completes when agent has various skill configurations", async () => {
mockedCreateKbAgent.mockResolvedValue({
session: createMockAgentSession(),
} as any);
// Test with various skill metadata configurations
const skillConfigs = [
{ skills: ["single-skill"] },
{ skills: ["a", "b", "c"] },
{ skills: [] },
{ skills: ["skill-with-dashes", "another_skill"] },
];
for (const skills of skillConfigs) {
vi.clearAllMocks();
const store = createStoreWithAgentForExec({
taskId: "FN-001",
metadata: skills,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result.status).toBe("completed");
expect(mockedCreateKbAgent).toHaveBeenCalled();
}
});
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock external dependencies
vi.mock("./pi.js", () => ({
@@ -3993,3 +3993,403 @@ describe("aiMergeTask — inferred test command execution", () => {
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
});
describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)", () => {
// Mock session-skill-context to control skill selection behavior
vi.mock("./session-skill-context.js", () => ({
buildSessionSkillContext: vi.fn(),
}));
beforeEach(() => {
vi.clearAllMocks();
});
it("passes skillSelection to createKbAgent when agentStore is provided", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["merger"],
sessionPurpose: "merger",
},
resolvedSkillNames: ["merger"],
skillSource: "role-fallback",
});
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(mockedCreateHaiAgent).toHaveBeenCalled();
// Find the first createKbAgent call (main merger agent)
const firstCall = mockedCreateHaiAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.skillSelection).toBeDefined();
expect(opts.skillSelection!.projectRootDir).toBe("/tmp/root");
expect(opts.skillSelection!.requestedSkillNames).toEqual(["merger"]);
expect(opts.skillSelection!.sessionPurpose).toBe("merger");
});
it("uses assigned agent skills when available", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["custom-skill", "another-skill"],
sessionPurpose: "merger",
},
resolvedSkillNames: ["custom-skill", "another-skill"],
skillSource: "assigned-agent",
});
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", assignedAgentId: "agent-001" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(mockedCreateHaiAgent).toHaveBeenCalled();
const firstCall = mockedCreateHaiAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.skillSelection).toBeDefined();
expect(opts.skillSelection!.requestedSkillNames).toEqual(["custom-skill", "another-skill"]);
});
it("does not pass skillSelection when buildSessionSkillContext returns undefined context", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: undefined,
resolvedSkillNames: [],
skillSource: "none",
});
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(mockedCreateHaiAgent).toHaveBeenCalled();
const firstCall = mockedCreateHaiAgent.mock.calls[0];
const opts = firstCall[0];
// skillSelection should not be present when context is undefined
expect("skillSelection" in opts).toBe(false);
});
it("does not pass skillSelection when agentStore is not provided", async () => {
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
// No agentStore provided
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(mockedCreateHaiAgent).toHaveBeenCalled();
const firstCall = mockedCreateHaiAgent.mock.calls[0];
const opts = firstCall[0];
expect("skillSelection" in opts).toBe(false);
});
it("gracefully handles buildSessionSkillContext throwing", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockRejectedValue(new Error("Agent not found"));
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
// Should not throw - graceful fallback
const result = await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(result.merged).toBe(true);
expect(mockedCreateHaiAgent).toHaveBeenCalled();
const firstCall = mockedCreateHaiAgent.mock.calls[0];
const opts = firstCall[0];
expect("skillSelection" in opts).toBe(false);
});
it("records resolved skill names in skill context result", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const resolvedNames = ["skill-a", "skill-b"];
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: resolvedNames,
sessionPurpose: "merger",
},
resolvedSkillNames: resolvedNames,
skillSource: "assigned-agent",
});
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", assignedAgentId: "agent-001" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(mockedCreateHaiAgent).toHaveBeenCalled();
const firstCall = mockedCreateHaiAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.skillSelection?.requestedSkillNames).toEqual(resolvedNames);
});
it("uses sessionPurpose='merger' in skill selection context", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["merger"],
sessionPurpose: "merger",
},
resolvedSkillNames: ["merger"],
skillSource: "role-fallback",
});
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(mockedCreateHaiAgent).toHaveBeenCalled();
const firstCall = mockedCreateHaiAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.skillSelection?.sessionPurpose).toBe("merger");
});
});
describe("aiMergeTask — skill selection non-fatal diagnostics (FN-1510/FN-1511)", () => {
// Mock session-skill-context to control skill selection behavior
vi.mock("./session-skill-context.js", () => ({
buildSessionSkillContext: vi.fn(),
}));
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("merge continues when skill selection produces diagnostics", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
// Simulate diagnostics being logged - the resolver would produce these
// when requested skills are not found or filtered
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["nonexistent-skill"],
sessionPurpose: "merger",
},
resolvedSkillNames: [],
skillSource: "none",
});
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
// Merge should succeed even when skill diagnostics are present
const result = await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(result.merged).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("records skill source in context result for debugging", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["custom-skill"],
sessionPurpose: "merger",
},
resolvedSkillNames: ["custom-skill"],
skillSource: "assigned-agent",
});
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", assignedAgentId: "agent-001" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
const result = await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
// Result should be successful regardless of skill source
expect(result.merged).toBe(true);
// Verify skillSelection was passed with the custom skill
expect(mockedCreateHaiAgent).toHaveBeenCalled();
const firstCall = mockedCreateHaiAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.skillSelection?.requestedSkillNames).toEqual(["custom-skill"]);
});
});

View File

@@ -535,3 +535,235 @@ describe("reviewStep — user comments in spec review", () => {
expect(capturedPrompt).toContain(".fusion/memory.md");
});
});
describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", () => {
// Mock session-skill-context to control skill selection behavior
vi.mock("./session-skill-context.js", () => ({
buildSessionSkillContext: vi.fn(),
}));
beforeEach(() => {
vi.clearAllMocks();
});
it("passes skillSelection to createKbAgent when agentStore and rootDir are provided", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/project",
requestedSkillNames: ["reviewer"],
sessionPurpose: "reviewer",
},
resolvedSkillNames: ["reviewer"],
skillSource: "role-fallback",
});
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nGood."),
);
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await reviewStep(
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
agentStore: mockAgentStore as any,
rootDir: "/tmp/project",
},
);
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect(opts.skillSelection).toBeDefined();
expect(opts.skillSelection!.projectRootDir).toBe("/tmp/project");
expect(opts.skillSelection!.requestedSkillNames).toEqual(["reviewer"]);
expect(opts.skillSelection!.sessionPurpose).toBe("reviewer");
});
it("uses assigned agent skills when available", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/project",
requestedSkillNames: ["custom-skill", "another-skill"],
sessionPurpose: "reviewer",
},
resolvedSkillNames: ["custom-skill", "another-skill"],
skillSource: "assigned-agent",
});
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nGood."),
);
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await reviewStep(
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
task: { assignedAgentId: "agent-001" },
agentStore: mockAgentStore as any,
rootDir: "/tmp/project",
},
);
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect(opts.skillSelection).toBeDefined();
expect(opts.skillSelection!.requestedSkillNames).toEqual(["custom-skill", "another-skill"]);
expect(opts.skillSelection!.sessionPurpose).toBe("reviewer");
});
it("does not pass skillSelection when buildSessionSkillContext returns undefined context", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: undefined,
resolvedSkillNames: [],
skillSource: "none",
});
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nGood."),
);
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await reviewStep(
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
agentStore: mockAgentStore as any,
rootDir: "/tmp/project",
},
);
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
// skillSelection should not be present when context is undefined
expect("skillSelection" in opts).toBe(false);
});
it("does not pass skillSelection when agentStore or rootDir is missing", async () => {
// Without agentStore/rootDir, buildSessionSkillContext is never called
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nGood."),
);
await reviewStep(
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
// No agentStore or rootDir
},
);
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect("skillSelection" in opts).toBe(false);
});
it("gracefully handles buildSessionSkillContext throwing", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockRejectedValue(new Error("Agent not found"));
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nGood."),
);
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
// Should not throw - graceful fallback
await reviewStep(
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
agentStore: mockAgentStore as any,
rootDir: "/tmp/project",
},
);
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect("skillSelection" in opts).toBe(false);
});
it("records resolved skill names in skill context result", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const resolvedNames = ["skill-a", "skill-b", "skill-c"];
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/project",
requestedSkillNames: resolvedNames,
sessionPurpose: "reviewer",
},
resolvedSkillNames: resolvedNames,
skillSource: "assigned-agent",
});
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nGood."),
);
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await reviewStep(
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
agentStore: mockAgentStore as any,
rootDir: "/tmp/project",
},
);
// Verify the resolved names are passed to createKbAgent
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect(opts.skillSelection?.requestedSkillNames).toEqual(resolvedNames);
});
it("uses sessionPurpose='reviewer' in skill selection context", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/project",
requestedSkillNames: ["reviewer"],
sessionPurpose: "reviewer",
},
resolvedSkillNames: ["reviewer"],
skillSource: "role-fallback",
});
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nGood."),
);
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await reviewStep(
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
agentStore: mockAgentStore as any,
rootDir: "/tmp/project",
},
);
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect(opts.skillSelection?.sessionPurpose).toBe("reviewer");
});
});