feat(FN-1515): merge fusion/fn-1515
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user