Files
fusion/packages/engine/src/triage.test.ts
2026-04-15 09:31:03 -07:00

2544 lines
81 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { TaskStore, Task, TaskDetail, Settings } from "@fusion/core";
import {
TriageProcessor,
TRIAGE_SYSTEM_PROMPT,
buildSpecificationPrompt,
readAttachmentContents,
computeUserCommentFingerprint,
} from "./triage.js";
import { join } from "node:path";
import { mkdir, writeFile, rm, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { setTimeout as delay } from "node:timers/promises";
const { mockReviewStep, mockCreateKbAgent } = vi.hoisted(() => ({
mockReviewStep: vi.fn(),
mockCreateKbAgent: vi.fn(),
}));
vi.mock("./reviewer.js", () => ({
reviewStep: mockReviewStep,
}));
vi.mock("./pi.js", () => ({
createKbAgent: mockCreateKbAgent,
describeModel: vi.fn().mockReturnValue("mock-model"),
promptWithFallback: vi.fn().mockReturnValue("mock-prompt"),
}));
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual("@fusion/core");
return {
...actual,
resolveAgentPrompt: vi.fn().mockReturnValue(null),
};
});
async function createTriageFixtureRoot(prefix: string): Promise<string> {
return mkdtemp(join(tmpdir(), prefix));
}
async function cleanupTriageFixtureRoot(rootDir: string | undefined): Promise<void> {
if (!rootDir) return;
const retryableCodes = new Set(["ENOTEMPTY", "EBUSY", "EPERM"]);
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
await rm(rootDir, { recursive: true, force: true });
return;
} catch (error: any) {
if (!retryableCodes.has(error?.code) || attempt === 4) {
throw error;
}
await delay(25 * (attempt + 1));
}
}
}
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn().mockResolvedValue(undefined),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
} as Settings),
updateSettings: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
on: vi.fn(),
emit: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
const mockTaskDetail: TaskDetail = {
id: "FN-001",
description: "Test task description",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# KB-001 - Test Task\n\nOriginal specification content.",
attachments: [],
};
describe("buildSpecificationPrompt", () => {
const baseTask: TaskDetail = {
...mockTaskDetail,
title: "Test Task",
};
it("generates basic specification prompt", () => {
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
);
expect(prompt).toContain("Specify this task");
expect(prompt).toContain("FN-001");
expect(prompt).toContain("Test Task");
expect(prompt).toContain("Test task description");
expect(prompt).toContain(".fusion/tasks/KB-001/PROMPT.md");
});
it("includes project commands when provided", () => {
const settings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
testCommand: "pnpm test",
buildCommand: "pnpm build",
};
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
settings,
);
expect(prompt).toContain("Project Commands");
expect(prompt).toContain("pnpm test");
expect(prompt).toContain("pnpm build");
});
it("generates revision prompt when existingPrompt and feedback provided", () => {
const existingPrompt = "# Original Spec\n\nOriginal content.";
const feedback = "Add more details about error handling";
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
undefined,
[],
existingPrompt,
feedback,
);
expect(prompt).toContain("Revise this task");
expect(prompt).toContain("Revision Instructions");
expect(prompt).toContain("Existing Specification");
expect(prompt).toContain("User Feedback");
expect(prompt).toContain(existingPrompt);
expect(prompt).toContain(feedback);
expect(prompt).toContain("revising an existing task specification");
});
it("generates fresh re-specification prompt when only feedback is provided", () => {
const feedback = "Start fresh and avoid the stale bootstrap assumption";
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
undefined,
[],
undefined,
feedback,
);
expect(prompt).toContain("Re-specify this task");
expect(prompt).toContain("Re-specification Instructions");
expect(prompt).toContain("fresh replacement specification");
expect(prompt).toContain(feedback);
expect(prompt).not.toContain("Existing Specification");
expect(prompt).toContain("without carrying forward stale assumptions");
});
it("includes attachments when provided", () => {
const attachments = [
{
originalName: "screenshot.png",
mimeType: "image/png" as const,
text: null as string | null,
},
{
originalName: "notes.txt",
mimeType: "text/plain" as const,
text: "Some notes content",
},
];
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
undefined,
attachments,
);
expect(prompt).toContain("Attachments");
expect(prompt).toContain("screenshot.png");
expect(prompt).toContain("notes.txt");
expect(prompt).toContain("Some notes content");
});
it("includes dependencies when present", () => {
const taskWithDeps: TaskDetail = {
...baseTask,
dependencies: ["FN-002", "FN-003"],
};
const prompt = buildSpecificationPrompt(
taskWithDeps,
".fusion/tasks/KB-001/PROMPT.md",
);
expect(prompt).toContain("Dependencies");
expect(prompt).toContain("FN-002, FN-003");
});
it("handles task without title", () => {
const taskWithoutTitle: TaskDetail = {
...baseTask,
title: undefined,
};
const prompt = buildSpecificationPrompt(
taskWithoutTitle,
".fusion/tasks/KB-001/PROMPT.md",
);
expect(prompt).toContain("(none)");
});
it("includes proactive subtask guidance when breakdown was not explicitly requested", () => {
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
);
expect(prompt).toContain("## Subtask Consideration");
expect(prompt).toContain("MORE THAN 7 implementation steps");
expect(prompt).toContain("GOOD TO SPLIT");
expect(prompt).not.toContain("## Subtask Breakdown Requested");
});
it("keeps explicit breakIntoSubtasks flow mandatory when requested", () => {
const prompt = buildSpecificationPrompt(
{
...baseTask,
breakIntoSubtasks: true,
},
".fusion/tasks/KB-001/PROMPT.md",
);
expect(prompt).toContain("## Subtask Breakdown Requested");
expect(prompt).toContain("If splitting: use the \\\`task_create\\\` tool");
expect(prompt).not.toContain("## Subtask Consideration");
});
describe("memoryEnabled setting", () => {
it("includes memory instructions when memoryEnabled: true", () => {
const settings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
memoryEnabled: true,
};
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
settings,
);
expect(prompt).toContain("Specify this task");
expect(prompt).toContain("## Project Memory");
expect(prompt).toContain(".fusion/memory.md");
});
it("excludes memory instructions when memoryEnabled: false", () => {
const settings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
memoryEnabled: false,
};
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
settings,
);
expect(prompt).toContain("Specify this task");
expect(prompt).not.toContain("## Project Memory");
});
it("includes memory instructions when memoryEnabled is undefined (default enabled)", () => {
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
undefined,
);
expect(prompt).toContain("Specify this task");
expect(prompt).toContain("## Project Memory");
expect(prompt).toContain(".fusion/memory.md");
});
});
describe("memoryBackendType setting", () => {
it("includes .fusion/memory.md for file backend", () => {
const settings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
memoryEnabled: true,
memoryBackendType: "file",
};
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
settings,
);
expect(prompt).toContain("## Project Memory");
expect(prompt).toContain(".fusion/memory.md");
});
it("includes read-only wording for readonly backend without write directives", () => {
const settings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
memoryEnabled: true,
memoryBackendType: "readonly",
};
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
settings,
);
expect(prompt).toContain("## Project Memory");
// Should NOT contain write/update directives
expect(prompt).not.toMatch(/write.*memory|update.*memory/i);
// Should NOT contain the specific file path
expect(prompt).not.toContain(".fusion/memory.md");
});
it("does not include .fusion/memory.md for qmd backend", () => {
const settings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
memoryEnabled: true,
memoryBackendType: "qmd",
};
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
settings,
);
expect(prompt).toContain("## Project Memory");
// QMD should NOT unconditionally reference .fusion/memory.md
expect(prompt).not.toContain(".fusion/memory.md");
// Should instruct to consult project memory
expect(prompt).toMatch(/consult.*project memory/i);
});
});
describe("user comments", () => {
it("includes user comments section when user comments exist", () => {
const taskWithComments: TaskDetail = {
...baseTask,
comments: [
{
id: "c1",
text: "Please add error handling for edge cases",
author: "user",
createdAt: "2026-01-02T10:00:00.000Z",
updatedAt: "2026-01-02T10:00:00.000Z",
},
{
id: "c2",
text: "Make sure to update the README too",
author: "user",
createdAt: "2026-01-02T11:00:00.000Z",
},
],
};
const prompt = buildSpecificationPrompt(
taskWithComments,
".fusion/tasks/KB-001/PROMPT.md",
);
expect(prompt).toContain("## User Comments");
expect(prompt).toContain("Please add error handling for edge cases");
expect(prompt).toContain("Make sure to update the README too");
expect(prompt).toContain("Address every comment");
expect(prompt).toContain("Missing comment coverage is a spec quality failure");
});
it("excludes agent/system comments from user comments section", () => {
const taskWithMixedComments: TaskDetail = {
...baseTask,
comments: [
{
id: "c1",
text: "User feedback here",
author: "user",
createdAt: "2026-01-02T10:00:00.000Z",
},
{
id: "c2",
text: "Agent system note",
author: "agent",
createdAt: "2026-01-02T11:00:00.000Z",
},
{
id: "c3",
text: "System auto-message",
author: "system",
createdAt: "2026-01-02T12:00:00.000Z",
},
],
};
const prompt = buildSpecificationPrompt(
taskWithMixedComments,
".fusion/tasks/KB-001/PROMPT.md",
);
expect(prompt).toContain("User feedback here");
expect(prompt).not.toContain("Agent system note");
expect(prompt).not.toContain("System auto-message");
});
it("does not include user comments section when no comments exist", () => {
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
);
expect(prompt).not.toContain("## User Comments");
});
it("does not include user comments section when only agent comments exist", () => {
const taskWithOnlyAgentComments: TaskDetail = {
...baseTask,
comments: [
{
id: "c1",
text: "Agent note",
author: "agent",
createdAt: "2026-01-02T10:00:00.000Z",
},
],
};
const prompt = buildSpecificationPrompt(
taskWithOnlyAgentComments,
".fusion/tasks/KB-001/PROMPT.md",
);
expect(prompt).not.toContain("## User Comments");
});
});
});
describe("TRIAGE_SYSTEM_PROMPT", () => {
it("requires specs to keep lint, tests, build, and typecheck green even outside initial file scope", () => {
expect(TRIAGE_SYSTEM_PROMPT).toContain("If keeping lint/tests/build/typecheck green requires edits outside the initial File Scope");
expect(TRIAGE_SYSTEM_PROMPT).toContain("Run lint check");
expect(TRIAGE_SYSTEM_PROMPT).toContain("Run project typecheck if available");
expect(TRIAGE_SYSTEM_PROMPT).toContain("Lint passing");
expect(TRIAGE_SYSTEM_PROMPT).toContain("Typecheck passing (if available)");
expect(TRIAGE_SYSTEM_PROMPT).toContain("Specs must instruct executors to fix lint failures and quality-gate failures directly");
expect(TRIAGE_SYSTEM_PROMPT).toContain("Refuse necessary fixes just because they touch files outside the initial File Scope");
});
});
describe("TRIAGE_SYSTEM_PROMPT", () => {
it("includes proactive M/L subtask breakdown guidance", () => {
expect(TRIAGE_SYSTEM_PROMPT).toContain(
"## Proactive Subtask Breakdown for M/L Tasks",
);
expect(TRIAGE_SYSTEM_PROMPT).toContain(
"Even when `breakIntoSubtasks` is not set to `true`",
);
expect(TRIAGE_SYSTEM_PROMPT).toContain(
"Size S tasks should generally NOT be split",
);
});
it("includes explicit subtask breakdown thresholds", () => {
expect(TRIAGE_SYSTEM_PROMPT).toContain("MORE THAN 7 implementation steps");
expect(TRIAGE_SYSTEM_PROMPT).toContain(
"MORE THAN 3 different packages/modules",
);
});
it("includes anti-pattern warning for oversized tasks", () => {
expect(TRIAGE_SYSTEM_PROMPT).toContain("ANTI-PATTERN");
expect(TRIAGE_SYSTEM_PROMPT).toContain("10+ steps");
expect(TRIAGE_SYSTEM_PROMPT).toContain(
"Only keep a task as one unit if it genuinely has 5 or fewer focused steps",
);
});
});
describe("readAttachmentContents", () => {
let testDir = "";
const taskId = "FN-TEST";
beforeEach(async () => {
testDir = await createTriageFixtureRoot("fusion-triage-attachments-");
await mkdir(join(testDir, ".fusion", "tasks", taskId, "attachments"), {
recursive: true,
});
});
afterEach(async () => {
await cleanupTriageFixtureRoot(testDir);
});
it("returns empty arrays when no attachments provided", async () => {
const result = await readAttachmentContents(testDir, taskId, undefined);
expect(result.attachmentContents).toHaveLength(0);
expect(result.imageContents).toHaveLength(0);
});
it("handles empty attachments array", async () => {
const result = await readAttachmentContents(testDir, taskId, []);
expect(result.attachmentContents).toHaveLength(0);
expect(result.imageContents).toHaveLength(0);
});
it("reads text attachment content", async () => {
const attachments = [
{
filename: "1234567890-notes.txt",
originalName: "notes.txt",
mimeType: "text/plain" as const,
size: 100,
createdAt: "2026-01-01T00:00:00.000Z",
},
];
const content = "Test notes content";
await writeFile(
join(testDir, ".fusion", "tasks", taskId, "attachments", "1234567890-notes.txt"),
content,
);
const result = await readAttachmentContents(testDir, taskId, attachments);
expect(result.attachmentContents).toHaveLength(1);
expect(result.attachmentContents[0].originalName).toBe("notes.txt");
expect(result.attachmentContents[0].text).toBe(content);
expect(result.imageContents).toHaveLength(0);
});
it("truncates text files over 50KB", async () => {
const attachments = [
{
filename: "1234567890-large.txt",
originalName: "large.txt",
mimeType: "text/plain" as const,
size: 100000,
createdAt: "2026-01-01T00:00:00.000Z",
},
];
const largeContent = "a".repeat(60 * 1024); // 60KB
await writeFile(
join(testDir, ".fusion", "tasks", taskId, "attachments", "1234567890-large.txt"),
largeContent,
);
const result = await readAttachmentContents(testDir, taskId, attachments);
expect(result.attachmentContents[0].text).toContain("truncated at 50KB");
expect(result.attachmentContents[0].text!.length).toBeLessThan(largeContent.length);
});
it("reads image as base64 content", async () => {
const attachments = [
{
filename: "1234567890-image.png",
originalName: "image.png",
mimeType: "image/png" as const,
size: 100,
createdAt: "2026-01-01T00:00:00.000Z",
},
];
// Write fake PNG data (just some bytes)
const imageData = Buffer.from([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
await writeFile(
join(testDir, ".fusion", "tasks", taskId, "attachments", "1234567890-image.png"),
imageData,
);
const result = await readAttachmentContents(testDir, taskId, attachments);
expect(result.attachmentContents).toHaveLength(1);
expect(result.attachmentContents[0].text).toBeNull();
expect(result.imageContents).toHaveLength(1);
expect(result.imageContents[0].type).toBe("image");
expect(result.imageContents[0].mimeType).toBe("image/png");
expect(result.imageContents[0].data).toBe(imageData.toString("base64"));
});
it("skips unreadable attachments", async () => {
const attachments = [
{
filename: "1234567890-missing.txt",
originalName: "missing.txt",
mimeType: "text/plain" as const,
size: 100,
createdAt: "2026-01-01T00:00:00.000Z",
},
];
// Don't write the file
const result = await readAttachmentContents(testDir, taskId, attachments);
expect(result.attachmentContents).toHaveLength(0);
expect(result.imageContents).toHaveLength(0);
});
});
describe("TriageProcessor", () => {
let store: TaskStore;
let processor: TriageProcessor;
const rootDir = "/fake/root";
beforeEach(() => {
store = createMockStore();
processor = new TriageProcessor(store, rootDir);
mockReviewStep.mockReset();
});
it("creates processor with default options", () => {
expect(processor).toBeInstanceOf(TriageProcessor);
});
it("can be started and stopped", () => {
processor.start();
processor.stop();
// Should not throw
});
it("handles settings:updated event for globalPause", () => {
const handler = vi.fn();
(store.on as ReturnType<typeof vi.fn>).mockImplementation(
(event: string, cb: (...args: any[]) => void) => {
if (event === "settings:updated") {
// Simulate globalPause transition
cb({ settings: { globalPause: true }, previous: { globalPause: false } });
}
}
);
// Create a new processor to trigger the event handler setup
new TriageProcessor(store, rootDir);
expect(store.on).toHaveBeenCalledWith("settings:updated", expect.any(Function));
});
it("re-reads settings when review_spec runs so reviewer uses the latest validator model", async () => {
const taskId = "FN-001";
const testRootDir = await createTriageFixtureRoot("fusion-triage-review-spec-");
try {
const promptPath = `.fusion/tasks/${taskId}/PROMPT.md`;
const taskDir = join(testRootDir, ".fusion", "tasks", taskId);
await mkdir(taskDir, { recursive: true });
await writeFile(join(taskDir, "PROMPT.md"), "# Spec\n\nCurrent prompt");
const freshSettings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
defaultProvider: "openai-codex",
defaultModelId: "gpt-5.4",
validatorProvider: "zai",
validatorModelId: "glm-5.1",
};
store = createMockStore({
getSettings: vi.fn().mockResolvedValue(freshSettings),
getTask: vi.fn().mockResolvedValue({
...mockTaskDetail,
id: taskId,
comments: [],
}),
});
processor = new TriageProcessor(store, testRootDir);
mockReviewStep.mockResolvedValue({
verdict: "APPROVE",
review: "Looks good.",
summary: "approved",
});
const tool = (processor as any).createReviewSpecTool(
taskId,
promptPath,
{ current: null },
{ current: null },
{ current: null },
{
defaultProvider: "anthropic",
defaultModelId: "claude-opus-4-6",
projectValidatorProvider: "anthropic",
projectValidatorModelId: "claude-opus-4-6",
},
);
await tool.execute({});
expect(store.getSettings).toHaveBeenCalled();
expect(mockReviewStep).toHaveBeenCalledWith(
testRootDir,
taskId,
0,
"Specification",
"spec",
"# Spec\n\nCurrent prompt",
undefined,
expect.objectContaining({
defaultProvider: "openai-codex",
defaultModelId: "gpt-5.4",
projectValidatorProvider: "zai",
projectValidatorModelId: "glm-5.1",
userComments: undefined,
}),
);
} finally {
await cleanupTriageFixtureRoot(testRootDir);
}
});
});
describe("Re-specification flow", () => {
const taskWithRevisionRequest: Task = {
id: "FN-001",
description: "Test task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [
{
timestamp: "2026-01-01T00:00:00.000Z",
action: "AI spec revision requested",
outcome: "Please add more details about error handling",
},
],
status: "needs-respecify",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
it("detects needs-respecify status", () => {
expect(taskWithRevisionRequest.status).toBe("needs-respecify");
});
it("extracts feedback from log entry", () => {
const revisionLogEntry = [...taskWithRevisionRequest.log]
.reverse()
.find((entry) => entry.action === "AI spec revision requested");
expect(revisionLogEntry).toBeDefined();
expect(revisionLogEntry?.outcome).toBe("Please add more details about error handling");
});
it("finds most recent revision request when multiple exist", () => {
const taskWithMultipleRequests: Task = {
...taskWithRevisionRequest,
log: [
{
timestamp: "2026-01-01T00:00:00.000Z",
action: "AI spec revision requested",
outcome: "First feedback",
},
{
timestamp: "2026-01-01T00:01:00.000Z",
action: "Other action",
},
{
timestamp: "2026-01-01T00:02:00.000Z",
action: "AI spec revision requested",
outcome: "Most recent feedback",
},
],
};
const revisionLogEntry = [...taskWithMultipleRequests.log]
.reverse()
.find((entry) => entry.action === "AI spec revision requested");
expect(revisionLogEntry?.outcome).toBe("Most recent feedback");
});
});
describe("requirePlanApproval setting", () => {
let rootDir = "";
beforeEach(async () => {
rootDir = await createTriageFixtureRoot("fusion-triage-approval-");
});
afterEach(async () => {
await cleanupTriageFixtureRoot(rootDir);
});
it("sets awaiting-approval status instead of moving to todo when requirePlanApproval is true", async () => {
const taskDir = join(rootDir, ".fusion", "tasks", "FN-001");
await mkdir(taskDir, { recursive: true });
await writeFile(
join(taskDir, "task.json"),
JSON.stringify({
id: "FN-001",
description: "Test task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
}),
);
await writeFile(
join(taskDir, "PROMPT.md"),
"# KB-001\n\n**Size:** M\n\n## Review Level: 1\n\nTest specification",
);
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
requirePlanApproval: true,
} as Settings),
getTask: vi.fn().mockResolvedValue({
...mockTaskDetail,
prompt: "# KB-001\n\nTest spec",
}),
listTasks: vi.fn().mockResolvedValue([
{
id: "FN-001",
description: "Test task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
status: "specifying",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
]),
});
const processor = new TriageProcessor(store, rootDir);
// Simulate that a spec was written and approved by reviewer
// We can't easily run the full specifyTask without mocking the AI,
// but we can verify the store setup is correct
expect(await store.getSettings()).toHaveProperty("requirePlanApproval", true);
});
it("auto-moves to todo when requirePlanApproval is false", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
requirePlanApproval: false,
} as Settings),
});
const settings = await store.getSettings();
expect(settings.requirePlanApproval).toBe(false);
});
it("defaults to false when requirePlanApproval is not set", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
} as Settings),
});
const settings = await store.getSettings();
expect(settings.requirePlanApproval).toBeUndefined();
});
});
describe("approved triage recovery", () => {
let rootDir = "";
beforeEach(async () => {
rootDir = await createTriageFixtureRoot("fusion-triage-recovery-");
await mkdir(join(rootDir, ".fusion", "tasks", "FN-001"), { recursive: true });
await writeFile(
join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"),
"# Task: FN-001\n\n**Size:** M\n\n## Review Level: 2\n\nRecovered specification",
);
});
afterEach(async () => {
await cleanupTriageFixtureRoot(rootDir);
});
it("moves approved specifying task to todo during recovery", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
requirePlanApproval: false,
} as Settings),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue(["FN-1247"]),
});
const processor = new TriageProcessor(store, rootDir);
const recovered = await processor.recoverApprovedTask({
id: "FN-001",
description: "Recovered triage task",
column: "triage",
status: "specifying",
dependencies: [],
steps: [],
currentStep: 0,
log: [
{ timestamp: "2026-01-01T00:00:00.000Z", action: "Spec review requested" },
{ timestamp: "2026-01-01T00:01:00.000Z", action: "Spec review: APPROVE" },
],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:02:00.000Z",
});
expect(recovered).toBe(true);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
status: null,
error: null,
dependencies: ["FN-1247"],
size: "M",
reviewLevel: 2,
});
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
"Auto-recovered approved specification stuck in specifying — moved to todo",
);
});
it("clears status and error before moving approved tasks to todo", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
requirePlanApproval: false,
} as Settings),
});
const processor = new TriageProcessor(store, rootDir);
const recovered = await processor.recoverApprovedTask({
id: "FN-001",
description: "Recovered triage task",
column: "triage",
status: "specifying",
dependencies: [],
steps: [],
currentStep: 0,
log: [
{ timestamp: "2026-01-01T00:00:00.000Z", action: "Spec review: APPROVE" },
],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:02:00.000Z",
});
expect(recovered).toBe(true);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({
status: null,
error: null,
}));
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
});
it("moves approved specifying task to awaiting-approval when manual approval is required", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
requirePlanApproval: true,
} as Settings),
});
const processor = new TriageProcessor(store, rootDir);
const recovered = await processor.recoverApprovedTask({
id: "FN-001",
description: "Recovered triage task",
column: "triage",
status: "specifying",
dependencies: [],
steps: [],
currentStep: 0,
log: [
{ timestamp: "2026-01-01T00:00:00.000Z", action: "Spec review: APPROVE" },
],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:02:00.000Z",
});
expect(recovered).toBe(true);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
status: "awaiting-approval",
});
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
"Auto-recovered approved specification stuck in specifying — awaiting manual approval",
);
});
});
describe("taskCreate tool model inheritance", () => {
it("inherits parent task model settings when creating subtasks", async () => {
const parentTask: Task = {
id: "FN-001",
description: "Parent task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
};
const createdSubtask: Task = {
id: "FN-002",
description: "Child task description",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(parentTask),
createTask: vi.fn().mockResolvedValue(createdSubtask),
});
// Simulate the taskCreate tool behavior
const parentTaskId = "FN-001";
const parentTaskResult = await store.getTask(parentTaskId);
await store.createTask({
title: "Child Task",
description: "Child task description",
dependencies: [],
column: "triage",
modelProvider: parentTaskResult?.modelProvider,
modelId: parentTaskResult?.modelId,
validatorModelProvider: parentTaskResult?.validatorModelProvider,
validatorModelId: parentTaskResult?.validatorModelId,
});
expect(store.getTask).toHaveBeenCalledWith("FN-001");
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
title: "Child Task",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
}));
});
it("handles missing parent task gracefully when creating subtasks", async () => {
const createdSubtask: Task = {
id: "FN-002",
description: "Child task description",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const store = createMockStore({
getTask: vi.fn().mockRejectedValue(new Error("Task not found")),
createTask: vi.fn().mockResolvedValue(createdSubtask),
});
// Simulate the taskCreate tool behavior with missing parent
const parentTaskId = "FN-NONEXISTENT";
let parentTask;
try {
parentTask = await store.getTask(parentTaskId);
} catch {
parentTask = undefined;
}
await store.createTask({
title: "Child Task",
description: "Child task description",
dependencies: [],
column: "triage",
modelProvider: parentTask?.modelProvider,
modelId: parentTask?.modelId,
validatorModelProvider: parentTask?.validatorModelProvider,
validatorModelId: parentTask?.validatorModelId,
});
expect(store.getTask).toHaveBeenCalledWith("FN-NONEXISTENT");
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
modelProvider: undefined,
modelId: undefined,
validatorModelProvider: undefined,
validatorModelId: undefined,
}));
});
describe("proactive subtask creation (task_create always available)", () => {
it("task_create tool is included in triage tools regardless of breakIntoSubtasks", () => {
const store = createMockStore();
const processor = new TriageProcessor(store, "/test/root");
const createdSubtasksRef = { current: [] };
const tools = (processor as any).createTriageTools({
parentTaskId: "FN-400",
allowTaskCreate: true,
createdSubtasksRef,
});
const toolNames = tools.map((t: any) => t.name);
expect(toolNames).toContain("task_create");
expect(toolNames).toContain("task_list");
expect(toolNames).toContain("task_get");
expect(tools).toHaveLength(3);
});
it("task_create tool succeeds and tracks created subtask", async () => {
const parentTask: Task = {
id: "FN-400",
description: "Large task without breakIntoSubtasks",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const createdSubtask: Task = {
id: "FN-401",
description: "Child task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(parentTask),
createTask: vi.fn().mockResolvedValue(createdSubtask),
});
const processor = new TriageProcessor(store, "/test/root");
const createdSubtasksRef = { current: [] };
const tools = (processor as any).createTriageTools({
parentTaskId: "FN-400",
allowTaskCreate: true,
createdSubtasksRef,
});
const taskCreateTool = tools.find((t: any) => t.name === "task_create");
const result = await taskCreateTool.execute("call-1", {
description: "Child task description",
title: "Child Task",
dependencies: [],
});
// Should NOT return an error about task creation being disabled
const text = result.content[0].text;
expect(text).not.toContain("ERROR");
expect(text).not.toContain("not enabled");
expect(text).toContain("Created child task FN-401");
// Subtask should be tracked in the ref
expect(createdSubtasksRef.current).toContain("FN-401");
// Should inherit parent model settings
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
title: "Child Task",
description: "Child task description",
}));
});
it("closes parent after proactive split even when breakIntoSubtasks is undefined", async () => {
// Test that the post-session closure path doesn't gate on breakIntoSubtasks.
// Strategy: capture the customTools from createKbAgent, then have
// promptWithFallback invoke the task_create tool to simulate the agent
// proactively splitting an oversized task.
const task: Task = {
id: "FN-500",
description: "Oversized task without breakIntoSubtasks flag",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const childTask1: Task = {
id: "FN-501",
description: "Child part 1",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const childTask2: Task = {
id: "FN-502",
description: "Child part 2",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const taskDetail: TaskDetail = {
...task,
prompt: "",
attachments: [],
// breakIntoSubtasks is explicitly undefined
};
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(taskDetail),
createTask: vi.fn()
.mockResolvedValueOnce(childTask1)
.mockResolvedValueOnce(childTask2),
});
// Capture customTools from createKbAgent call
let capturedCustomTools: any[] = [];
const mockDispose = vi.fn();
mockCreateKbAgent.mockImplementation(async (opts: any) => {
capturedCustomTools = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: mockDispose,
subscribe: vi.fn(),
sessionManager: {
getLeafId: vi.fn().mockReturnValue(null),
navigateTree: vi.fn(),
},
},
};
});
// Make promptWithFallback invoke the task_create tool twice to simulate
// the agent proactively splitting the oversized task
const { promptWithFallback } = await import("./pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementationOnce(
async () => {
const taskCreateTool = capturedCustomTools.find(
(t: any) => t.name === "task_create",
);
expect(taskCreateTool).toBeDefined();
// Simulate agent creating two child tasks
await taskCreateTool.execute("call-1", {
description: "Child part 1",
title: "Part 1",
dependencies: [],
});
await taskCreateTool.execute("call-2", {
description: "Child part 2",
title: "Part 2",
dependencies: [],
});
},
);
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000,
});
await processor.specifyTask(task);
// The parent task should be deleted because subtasks were created,
// even though breakIntoSubtasks was NOT set
expect(store.logEntry).toHaveBeenCalledWith(
"FN-500",
expect.stringContaining("Converted into subtasks: FN-501, FN-502"),
);
expect(store.deleteTask).toHaveBeenCalledWith("FN-500");
});
});
describe("bounded recovery retries for triage", () => {
it("sets recoveryRetryCount and nextRecoveryAt on first transient error via specifyTask", async () => {
const task = {
id: "FN-200",
description: "Test triage task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as Task;
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
});
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000,
});
// Mock createKbAgent to throw a transient error
mockCreateKbAgent.mockRejectedValue(new Error("upstream connect error"));
await processor.specifyTask(task);
expect(store.updateTask).toHaveBeenCalledWith("FN-200", expect.objectContaining({
recoveryRetryCount: 1,
nextRecoveryAt: expect.any(String),
}));
});
it("escalates to error state when triage retries are exhausted via specifyTask", async () => {
const task = {
id: "FN-201",
description: "Test triage task",
column: "triage",
recoveryRetryCount: 3, // Already at max
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as Task;
const onSpecifyError = vi.fn();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
});
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000,
onSpecifyError,
});
mockCreateKbAgent.mockRejectedValue(new Error("connection reset"));
await processor.specifyTask(task);
// Should set error and clear recovery metadata
expect(store.updateTask).toHaveBeenCalledWith("FN-201", expect.objectContaining({
error: expect.stringContaining("Specification failed after 3 transient errors"),
recoveryRetryCount: null,
nextRecoveryAt: null,
}));
expect(onSpecifyError).toHaveBeenCalled();
});
});
describe("recovery due-time gating (nextRecoveryAt)", () => {
it("skips triage tasks whose nextRecoveryAt is in the future", async () => {
const future = new Date(Date.now() + 60_000).toISOString();
const task = {
id: "FN-100",
description: "Test triage task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
nextRecoveryAt: future,
recoveryRetryCount: 1,
} as unknown as Task;
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([task]),
});
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000, // long interval so only manual poll runs
});
// Spy on specifyTask to ensure it's NOT called for gated tasks
const specifySpy = vi.spyOn(processor, "specifyTask");
processor.start();
// Wait a tick for the initial poll
await new Promise((r) => setTimeout(r, 50));
processor.stop();
expect(specifySpy).not.toHaveBeenCalled();
specifySpy.mockRestore();
});
it("processes triage tasks whose nextRecoveryAt has elapsed", async () => {
const past = new Date(Date.now() - 1000).toISOString();
const task = {
id: "FN-101",
description: "Test triage task past",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
nextRecoveryAt: past,
recoveryRetryCount: 1,
} as unknown as Task;
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([task]),
});
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000,
});
const specifySpy = vi.spyOn(processor, "specifyTask").mockResolvedValue(undefined);
processor.start();
await new Promise((r) => setTimeout(r, 50));
processor.stop();
expect(specifySpy).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-101" }));
specifySpy.mockRestore();
});
});
describe("triage model logging in agent log", () => {
it("appends triage model info to agent log after session creation", async () => {
const task = {
id: "FN-300",
description: "Test triage task for model logging",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as Task;
const mockDispose = vi.fn();
const mockPrompt = vi.fn().mockResolvedValue(undefined);
const mockGetLeafId = vi.fn().mockReturnValue(null);
const mockNavigateTree = vi.fn();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
});
// Set up createKbAgent to return a session that immediately throws
// after the model log line, so we can verify the appendAgentLog call.
// The session will be created, model logged, then promptWithFallback
// throws — but the model log has already been written.
mockCreateKbAgent.mockResolvedValue({
session: {
prompt: mockPrompt,
dispose: mockDispose,
sessionManager: {
getLeafId: mockGetLeafId,
navigateTree: mockNavigateTree,
},
},
});
// Make promptWithFallback throw so we can stop execution after model log
const { promptWithFallback } = await import("./pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("test stop after model log"),
);
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000,
});
await processor.specifyTask(task);
// Verify appendAgentLog was called with model info and triage role
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-300",
"Triage using model: mock-model",
"text",
undefined,
"triage",
);
});
});
describe("per-task planning model override", () => {
it("uses per-task planningModelProvider/planningModelId when set on the task", async () => {
const task = {
id: "FN-400",
description: "Test per-task planning model override",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
} as unknown as Task;
const mockDispose = vi.fn();
const mockPrompt = vi.fn().mockResolvedValue(undefined);
const mockGetLeafId = vi.fn().mockReturnValue(null);
const mockNavigateTree = vi.fn();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
planningProvider: "openai",
planningModelId: "gpt-4o",
} as Settings),
});
mockCreateKbAgent.mockResolvedValue({
session: {
prompt: mockPrompt,
dispose: mockDispose,
sessionManager: {
getLeafId: mockGetLeafId,
navigateTree: mockNavigateTree,
},
},
});
const { promptWithFallback } = await import("./pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("test stop after model check"),
);
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000,
});
await processor.specifyTask(task);
// Per-task override should take precedence over settings
expect(mockCreateKbAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "google",
defaultModelId: "gemini-2.5-pro",
}),
);
});
it("falls back to settings planningProvider/planningModelId when task has no override", async () => {
const task = {
id: "FN-401",
description: "Test fallback to settings planning model",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
// No planningModelProvider/planningModelId set
} as unknown as Task;
const mockDispose = vi.fn();
const mockPrompt = vi.fn().mockResolvedValue(undefined);
const mockGetLeafId = vi.fn().mockReturnValue(null);
const mockNavigateTree = vi.fn();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
planningProvider: "openai",
planningModelId: "gpt-4o",
} as Settings),
});
mockCreateKbAgent.mockResolvedValue({
session: {
prompt: mockPrompt,
dispose: mockDispose,
sessionManager: {
getLeafId: mockGetLeafId,
navigateTree: mockNavigateTree,
},
},
});
const { promptWithFallback } = await import("./pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("test stop after model check"),
);
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000,
});
await processor.specifyTask(task);
// Should use settings planning model when no per-task override
expect(mockCreateKbAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "openai",
defaultModelId: "gpt-4o",
}),
);
});
it("falls back to global defaults when neither task nor settings have planning model", async () => {
const task = {
id: "FN-402",
description: "Test fallback to global defaults",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as Task;
const mockDispose = vi.fn();
const mockPrompt = vi.fn().mockResolvedValue(undefined);
const mockGetLeafId = vi.fn().mockReturnValue(null);
const mockNavigateTree = vi.fn();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
// No planningProvider/planningModelId set
} as Settings),
});
mockCreateKbAgent.mockResolvedValue({
session: {
prompt: mockPrompt,
dispose: mockDispose,
sessionManager: {
getLeafId: mockGetLeafId,
navigateTree: mockNavigateTree,
},
},
});
const { promptWithFallback } = await import("./pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("test stop after model check"),
);
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000,
});
await processor.specifyTask(task);
// Should fall back to global defaults
expect(mockCreateKbAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
}),
);
});
});
});
describe("computeUserCommentFingerprint", () => {
it("returns empty string for undefined comments", () => {
expect(computeUserCommentFingerprint(undefined)).toBe("");
});
it("returns empty string for empty comments array", () => {
expect(computeUserCommentFingerprint([])).toBe("");
});
it("returns empty string when only agent comments exist", () => {
const comments = [
{ id: "c1", text: "agent note", author: "agent", createdAt: "2026-01-01T00:00:00.000Z" },
];
expect(computeUserCommentFingerprint(comments as any)).toBe("");
});
it("returns sorted semicolon-joined IDs for user comments", () => {
const comments = [
{ id: "c3", text: "user 3", author: "user", createdAt: "2026-01-01T00:00:00.000Z" },
{ id: "c1", text: "user 1", author: "user", createdAt: "2026-01-01T00:00:00.000Z" },
{ id: "c2", text: "agent", author: "agent", createdAt: "2026-01-01T00:00:00.000Z" },
];
// Should be sorted: c1;c3 (c2 is agent, excluded)
expect(computeUserCommentFingerprint(comments as any)).toBe("c1;c3");
});
it("detects changed fingerprint when new user comment is added", () => {
const before = [
{ id: "c1", text: "user 1", author: "user", createdAt: "2026-01-01T00:00:00.000Z" },
];
const after = [
{ id: "c1", text: "user 1", author: "user", createdAt: "2026-01-01T00:00:00.000Z" },
{ id: "c2", text: "user 2", author: "user", createdAt: "2026-01-01T00:00:00.000Z" },
];
expect(computeUserCommentFingerprint(before as any)).not.toBe(
computeUserCommentFingerprint(after as any),
);
});
});
describe("awaiting-approval poll exclusion", () => {
it("excludes awaiting-approval tasks from poll discovery", async () => {
const awaitingTask: Task = {
id: "FN-AW1",
description: "Awaiting approval task",
column: "triage",
status: "awaiting-approval",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const normalTask: Task = {
id: "FN-NT1",
description: "Normal triage task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const specifySpy = vi.fn();
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([awaitingTask, normalTask]),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 60000,
groupOverlappingFiles: false,
autoMerge: true,
}),
});
const processor = new TriageProcessor(store, "/tmp");
// Mark as running so poll() proceeds
(processor as any).running = true;
// Override specifyTask to spy on which tasks get dispatched
(processor as any).specifyTask = specifySpy;
// Trigger poll via private method
await (processor as any).poll();
// Only the normal task should have been dispatched
expect(specifySpy).toHaveBeenCalledTimes(1);
expect(specifySpy).toHaveBeenCalledWith(normalTask);
});
});
describe("stale approval detection", () => {
let rootDir = "";
beforeEach(async () => {
rootDir = await createTriageFixtureRoot("fusion-triage-stale-approval-");
});
afterEach(async () => {
await cleanupTriageFixtureRoot(rootDir);
});
it("computeUserCommentFingerprint detects added user comment", () => {
const before = [
{ id: "c1", text: "First", author: "user", createdAt: "2026-01-01T00:00:00.000Z" },
];
const after = [
{ id: "c1", text: "First", author: "user", createdAt: "2026-01-01T00:00:00.000Z" },
{ id: "c2", text: "Second", author: "user", createdAt: "2026-01-02T00:00:00.000Z" },
];
const fpBefore = computeUserCommentFingerprint(before as any);
const fpAfter = computeUserCommentFingerprint(after as any);
expect(fpBefore).toBe("c1");
expect(fpAfter).toBe("c1;c2");
expect(fpBefore).not.toBe(fpAfter);
});
it("computeUserCommentFingerprint is stable when comments unchanged", () => {
const comments = [
{ id: "c1", text: "Same", author: "user", createdAt: "2026-01-01T00:00:00.000Z" },
];
const fp1 = computeUserCommentFingerprint(comments as any);
const fp2 = computeUserCommentFingerprint(comments as any);
expect(fp1).toBe(fp2);
});
it("captures fingerprint on review_spec APPROVE", async () => {
const taskId = "FN-CAP";
const taskDir = join(rootDir, ".fusion", "tasks", taskId);
await mkdir(taskDir, { recursive: true });
await writeFile(join(taskDir, "PROMPT.md"), "# Spec\n\nCurrent prompt");
const comments = [
{ id: "c1", text: "Feedback", author: "user", createdAt: "2026-01-01T00:00:00.000Z" },
];
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
} as Settings),
getTask: vi.fn().mockResolvedValue({
...mockTaskDetail,
id: taskId,
comments,
}),
});
const processor = new TriageProcessor(store, rootDir);
mockReviewStep.mockResolvedValue({
verdict: "APPROVE",
review: "Looks good.",
summary: "approved",
});
const approvedCommentFingerprintRef = { current: "" };
const tool = (processor as any).createReviewSpecTool(
taskId,
`.fusion/tasks/${taskId}/PROMPT.md`,
{ current: null },
{ current: null },
{ current: null },
approvedCommentFingerprintRef,
{},
);
// Execute review_spec — should capture fingerprint at APPROVE time
await tool.execute({});
// Verify fingerprint was captured from the user comments at approval time
expect(approvedCommentFingerprintRef.current).toBe("c1");
});
it("fingerprint is empty string when review_spec returns REVISE (no capture)", async () => {
const taskId = "FN-REV";
const taskDir = join(rootDir, ".fusion", "tasks", taskId);
await mkdir(taskDir, { recursive: true });
await writeFile(join(taskDir, "PROMPT.md"), "# Spec\n\nCurrent prompt");
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
} as Settings),
getTask: vi.fn().mockResolvedValue({
...mockTaskDetail,
id: taskId,
comments: [],
}),
});
const processor = new TriageProcessor(store, rootDir);
mockReviewStep.mockResolvedValue({
verdict: "REVISE",
review: "Fix the spec.",
summary: "needs work",
});
const approvedCommentFingerprintRef = { current: "" };
const tool = (processor as any).createReviewSpecTool(
taskId,
`.fusion/tasks/${taskId}/PROMPT.md`,
{ current: null },
{ current: null },
{ current: null },
approvedCommentFingerprintRef,
{},
);
await tool.execute({});
// Fingerprint should NOT be captured on REVISE
expect(approvedCommentFingerprintRef.current).toBe("");
});
});
describe("pause-abort status clearing (bug fix)", () => {
it("clears specifying status to null on global pause (not a no-op)", async () => {
const settingsListeners: Array<(e: any) => void> = [];
const store = {
on: vi.fn((event: string, cb: (e: any) => void) => {
if (event === "settings:updated") settingsListeners.push(cb);
}),
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail }),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 10000, groupOverlappingFiles: false, autoMerge: true } as Settings),
listTasks: vi.fn().mockResolvedValue([]),
updateTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
let resolveDispose: () => void;
const disposePromise = new Promise<void>((r) => { resolveDispose = r; });
mockCreateKbAgent.mockResolvedValue({
session: {
state: {},
sessionManager: {},
prompt: vi.fn().mockReturnValue(disposePromise),
dispose: vi.fn().mockImplementation(() => resolveDispose()),
navigateTree: vi.fn(),
},
});
const task: Task = { id: "FN-001", description: "test", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
const processor = new TriageProcessor(store, "/tmp/root");
const specifyPromise = processor.specifyTask(task);
await new Promise((r) => setTimeout(r, 20));
for (const fn of settingsListeners) {
fn({ settings: { globalPause: true }, previous: { globalPause: false } });
}
await specifyPromise;
// Status must be set to null so the next poll can retry (old bug: undefined was a no-op)
const nullStatusCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls
.find((c) => c[1]?.status === null);
expect(nullStatusCall).toBeDefined();
const undefinedStatusCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls
.find((c) => "status" in c[1] && c[1].status === undefined);
expect(undefinedStatusCall).toBeUndefined();
});
});
describe("stuck task detector integration", () => {
it("markStuckAborted clears specifying status to null for retry", async () => {
const store = {
on: vi.fn(),
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail }),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 10000, groupOverlappingFiles: false, autoMerge: true } as Settings),
listTasks: vi.fn().mockResolvedValue([]),
updateTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
let resolveDispose: () => void;
let mockDispose: ReturnType<typeof vi.fn>;
const disposePromise = new Promise<void>((r) => { resolveDispose = r; });
mockDispose = vi.fn().mockImplementation(() => resolveDispose());
mockCreateKbAgent.mockResolvedValue({
session: {
state: {},
sessionManager: {},
prompt: vi.fn().mockReturnValue(disposePromise),
dispose: mockDispose,
navigateTree: vi.fn(),
},
});
const task: Task = { id: "FN-001", description: "test", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
const processor = new TriageProcessor(store, "/tmp/root");
const specifyPromise = processor.specifyTask(task);
await new Promise((r) => setTimeout(r, 20));
// Stuck detector marks task then disposes the session (simulating StuckTaskDetector.killAndRetry)
processor.markStuckAborted("FN-001");
mockDispose();
await specifyPromise;
// Status cleared to null so next poll retries
const nullStatusCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls
.find((c) => c[1]?.status === null);
expect(nullStatusCall).toBeDefined();
});
it("tracks and untracks sessions with stuckTaskDetector", async () => {
const trackTask = vi.fn();
const untrackTask = vi.fn();
const recordActivity = vi.fn();
const mockDetector = { trackTask, untrackTask, recordActivity } as any;
const store = {
on: vi.fn(),
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail }),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 10000, groupOverlappingFiles: false, autoMerge: true } as Settings),
listTasks: vi.fn().mockResolvedValue([]),
updateTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
mockCreateKbAgent.mockResolvedValue({
session: {
state: {},
sessionManager: {},
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
});
const task: Task = { id: "FN-001", description: "test", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
const processor = new TriageProcessor(store, "/tmp/root", { stuckTaskDetector: mockDetector });
await processor.specifyTask(task);
expect(trackTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ dispose: expect.any(Function) }));
expect(untrackTask).toHaveBeenCalledWith("FN-001");
expect(recordActivity).toHaveBeenCalled();
});
});
describe("tool callback behavior (FN-1500)", () => {
it("records activity via stuckTaskDetector on tool callbacks", async () => {
const recordActivity = vi.fn();
const mockDetector = { trackTask: vi.fn(), untrackTask: vi.fn(), recordActivity } as any;
const store = createMockStore();
const processor = new TriageProcessor(store, "/tmp/root", { stuckTaskDetector: mockDetector });
// Access the agentLogger via internal agentWork closure
// by running specifyTask and intercepting the createKbAgent call
let capturedOnAgentTool: ((id: string, name: string) => void) | undefined;
mockCreateKbAgent.mockImplementation(async (opts: any) => {
// Capture the onToolStart callback that was passed to createKbAgent
// This is the onAgentTool from agentLogger
if (opts.onToolStart) {
capturedOnAgentTool = opts.onToolStart;
}
return {
session: {
state: {},
sessionManager: {},
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
const task: Task = { id: "FN-TOOL-001", description: "test tool callbacks", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
await processor.specifyTask(task);
// Simulate tool callbacks
if (capturedOnAgentTool) {
capturedOnAgentTool("call-1", "read");
capturedOnAgentTool("call-2", "write");
capturedOnAgentTool("call-3", "bash");
}
// Stuck detector should have recorded activity
expect(recordActivity).toHaveBeenCalledWith("FN-TOOL-001");
// Activity should have been recorded at least once (for each tool callback)
expect(recordActivity.mock.calls.length).toBeGreaterThanOrEqual(1);
});
it("agent logger persists tool events via appendAgentLog", async () => {
const store = createMockStore();
const processor = new TriageProcessor(store, "/tmp/root");
let capturedOnToolStart: ((name: string, args?: Record<string, unknown>) => void) | undefined;
mockCreateKbAgent.mockImplementation(async (opts: any) => {
if (opts.onToolStart) {
capturedOnToolStart = opts.onToolStart;
}
return {
session: {
state: {},
sessionManager: {},
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
const task: Task = { id: "FN-TOOL-002", description: "test tool logging", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
await processor.specifyTask(task);
// Simulate tool call
if (capturedOnToolStart) {
capturedOnToolStart("read", { path: "test.txt" });
}
// Agent logger should have persisted via appendAgentLog
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-TOOL-002",
"read",
"tool",
"test.txt",
"triage",
);
});
it("does not emit stdout 'tool:' log pattern during triage (FN-1500)", async () => {
// Spy on console.log to verify no tool: spam
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const store = createMockStore();
const processor = new TriageProcessor(store, "/tmp/root");
let capturedOnToolStart: ((name: string, args?: Record<string, unknown>) => void) | undefined;
mockCreateKbAgent.mockImplementation(async (opts: any) => {
if (opts.onToolStart) {
capturedOnToolStart = opts.onToolStart;
}
return {
session: {
state: {},
sessionManager: {},
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
const task: Task = { id: "FN-STDOUT-001", description: "test no stdout spam", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
await processor.specifyTask(task);
// Simulate multiple tool calls
if (capturedOnToolStart) {
capturedOnToolStart("read", { path: "file1.txt" });
capturedOnToolStart("edit", { path: "file2.txt" });
capturedOnToolStart("bash", { command: "npm test" });
}
// Verify no stdout "tool:" pattern was emitted
const toolSpamLogs = (consoleLogSpy.mock.calls as string[][]).filter(
(args) => args.some((arg) => typeof arg === "string" && arg.includes("tool:"))
);
expect(toolSpamLogs).toHaveLength(0);
consoleLogSpy.mockRestore();
});
});
// ── Skill Selection Regression Tests (FN-1514) ──────────────────────────
//
// Note: These tests verify that skillSelection is passed through the triage
// pipeline. The actual agent skill lookup is tested in session-skill-context.test.ts.
// Here we focus on the contract that skillSelection flows correctly.
describe("TriageProcessor skillSelection regression (FN-1511)", () => {
const projectRoot = "/tmp/test-project";
beforeEach(() => {
vi.clearAllMocks();
mockCreateKbAgent.mockResolvedValue({
session: {
state: {},
sessionManager: {},
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
});
});
/**
* Helper: execute triage on a task and capture createKbAgent call arguments.
*/
async function captureCreateKbAgentArgs(options?: {
assignedAgentId?: string;
assignedAgentSkills?: string[];
}) {
const { assignedAgentId, assignedAgentSkills } = options || {};
const mockAgentStore = {
getAgent: vi.fn().mockImplementation(async (id: string) => {
if (id === assignedAgentId && assignedAgentSkills) {
return {
id,
name: "Test Agent",
role: "triage",
state: "idle",
metadata: { skills: assignedAgentSkills },
};
}
return null;
}),
};
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({
id: "FN-SKILL",
title: "Skill Test",
description: "Test skill selection",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
assignedAgentId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
});
let capturedArgs: any = null;
mockCreateKbAgent.mockImplementationOnce(async (opts: any) => {
capturedArgs = opts;
return {
session: {
state: {},
sessionManager: {},
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
const processor = new TriageProcessor(store, projectRoot, {
agentStore: mockAgentStore as any,
});
const task: Task = {
id: "FN-SKILL",
description: "Test skill selection",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
assignedAgentId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await processor.specifyTask(task);
return capturedArgs;
}
describe("skillSelection context propagation", () => {
it("passes skillSelection to createKbAgent with correct projectRootDir", async () => {
const args = await captureCreateKbAgentArgs({
assignedAgentId: "agent-001",
assignedAgentSkills: ["triage"],
});
expect(args).not.toBeNull();
expect(args).toHaveProperty("skillSelection");
expect(args.skillSelection.projectRootDir).toBe(projectRoot);
});
it("uses 'triage' as sessionPurpose for triage sessions", async () => {
const args = await captureCreateKbAgentArgs({
assignedAgentId: "agent-001",
assignedAgentSkills: ["triage"],
});
expect(args).not.toBeNull();
expect(args.skillSelection?.sessionPurpose).toBe("triage");
});
it("skillSelection is undefined when no agentStore provided (role fallback behavior)", async () => {
// When no agentStore is provided, buildSessionSkillContext uses role fallback
// and skillSelection may be undefined or use role fallback skills
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({
id: "FN-SKILL",
description: "Test",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
});
let capturedArgs: any = null;
mockCreateKbAgent.mockImplementationOnce(async (opts: any) => {
capturedArgs = opts;
return {
session: {
state: {},
sessionManager: {},
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
const processor = new TriageProcessor(store, projectRoot);
await processor.specifyTask({
id: "FN-SKILL",
description: "Test",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Without agentStore, role fallback is used which adds skillSelection with triage skill
expect(capturedArgs).not.toBeNull();
expect(capturedArgs).toHaveProperty("skillSelection");
});
});
describe("parity with executor paths", () => {
it("uses same skillSelection field structure as executor", async () => {
const args = await captureCreateKbAgentArgs({
assignedAgentId: "agent-001",
assignedAgentSkills: ["triage"],
});
expect(args).not.toBeNull();
// Triage and executor should use the same skillSelection field structure
expect(args).toHaveProperty("skillSelection");
expect(args.skillSelection).toHaveProperty("projectRootDir");
expect(args.skillSelection).toHaveProperty("requestedSkillNames");
expect(args.skillSelection).toHaveProperty("sessionPurpose");
});
});
});
describe("evictStaleProcessing", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("evicts tasks that have been in processing longer than 30 minutes", () => {
const store = createMockStore();
const processor = new TriageProcessor(store, "/tmp/root");
// Simulate a task that entered processing 31 minutes ago
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
(processor as any).processing.add("FN-001");
(processor as any).processingSince.set("FN-001", Date.now());
// Advance time 31 minutes
vi.setSystemTime(new Date("2026-01-01T00:31:00.000Z"));
const evicted = processor.evictStaleProcessing();
expect(evicted).toEqual(new Set(["FN-001"]));
expect(processor.getProcessingTaskIds().has("FN-001")).toBe(false);
});
it("does not evict tasks that have been in processing less than 30 minutes", () => {
const store = createMockStore();
const processor = new TriageProcessor(store, "/tmp/root");
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
(processor as any).processing.add("FN-001");
(processor as any).processingSince.set("FN-001", Date.now());
// Advance time 29 minutes — not stale yet
vi.setSystemTime(new Date("2026-01-01T00:29:00.000Z"));
const evicted = processor.evictStaleProcessing();
expect(evicted.size).toBe(0);
expect(processor.getProcessingTaskIds().has("FN-001")).toBe(true);
});
it("cleans up activeSessions and stuckAborted when evicting", () => {
const store = createMockStore();
const processor = new TriageProcessor(store, "/tmp/root");
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
(processor as any).processing.add("FN-001");
(processor as any).processingSince.set("FN-001", Date.now());
(processor as any).activeSessions.set("FN-001", { dispose: vi.fn() });
(processor as any).stuckAborted.add("FN-001");
vi.setSystemTime(new Date("2026-01-01T00:31:00.000Z"));
const evicted = processor.evictStaleProcessing();
expect(evicted).toEqual(new Set(["FN-001"]));
expect((processor as any).activeSessions.has("FN-001")).toBe(false);
expect((processor as any).stuckAborted.has("FN-001")).toBe(false);
});
it("returns empty set when no tasks are stale", () => {
const store = createMockStore();
const processor = new TriageProcessor(store, "/tmp/root");
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
(processor as any).processing.add("FN-001");
(processor as any).processingSince.set("FN-001", Date.now());
// Only 5 minutes — well within threshold
vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z"));
const evicted = processor.evictStaleProcessing();
expect(evicted.size).toBe(0);
expect(processor.getProcessingTaskIds().has("FN-001")).toBe(true);
});
it("evicts multiple stale tasks while keeping fresh ones", () => {
const store = createMockStore();
const processor = new TriageProcessor(store, "/tmp/root");
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
(processor as any).processing.add("FN-001");
(processor as any).processingSince.set("FN-001", Date.now());
vi.setSystemTime(new Date("2026-01-01T00:15:00.000Z"));
(processor as any).processing.add("FN-002");
(processor as any).processingSince.set("FN-002", Date.now());
// FN-001 entered at 00:00, FN-002 at 00:15
vi.setSystemTime(new Date("2026-01-01T00:35:00.000Z"));
const evicted = processor.evictStaleProcessing();
// FN-001 has been in for 35min → evicted. FN-002 for 20min → not stale.
expect(evicted).toEqual(new Set(["FN-001"]));
expect(processor.getProcessingTaskIds().has("FN-001")).toBe(false);
expect(processor.getProcessingTaskIds().has("FN-002")).toBe(true);
});
});