Route standard triage prompt construction through workflow IR while keeping custom and fast-mode overrides intact. - Export the workflow planning prompt resolver and use it as the standard triage prompt source. - Move duplicated triage policy text into the shared core prompt definition. - Add regression coverage for workflow-sourced planning prompts, duplicate search guidance, and prompt override behavior. - Add a patch changeset for the published CLI bundle. Files changed: .changeset/FN-6232-triage-prompt-single-source.md | 5 + packages/core/src/__tests__/agent-prompts.test.ts | 58 ++-- packages/core/src/agent-prompts.ts | 111 +++++-- packages/core/src/index.ts | 2 + packages/core/src/workflow-ir-resolver.ts | 28 ++ .../triage-duplicate-search-regression.test.ts | 19 +- .../triage-planning-prompt-single-source.test.ts | 179 +++++++++++ packages/engine/src/__tests__/triage.test.ts | 91 +++--- packages/engine/src/triage.ts | 352 +-------------------- 9 files changed, 413 insertions(+), 432 deletions(-) Fusion-Task-Id: FN-6232 Fusion-Task-Lineage: c70c33ed-c61b-49d2-bcea-d860899c1512
4484 lines
153 KiB
TypeScript
4484 lines
153 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import type { TaskStore, Task, TaskDetail, Settings } from "@fusion/core";
|
|
import { resolveAgentPrompt } from "@fusion/core";
|
|
import {
|
|
TriageProcessor,
|
|
FAST_TRIAGE_SYSTEM_PROMPT,
|
|
buildSpecificationPrompt,
|
|
readAttachmentContents,
|
|
computeUserCommentFingerprint,
|
|
} from "../triage.js";
|
|
import { join } from "node:path";
|
|
import { readFileSync } from "node:fs";
|
|
import { mkdir, writeFile, rm, mkdtemp } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import { planLog } from "../logger.js";
|
|
|
|
const { mockReviewStep, mockCreateFnAgent } = vi.hoisted(() => ({
|
|
mockReviewStep: vi.fn(),
|
|
mockCreateFnAgent: vi.fn(),
|
|
}));
|
|
|
|
const TRIAGE_POLICY_PROMPT = resolveAgentPrompt("triage");
|
|
|
|
vi.mock("../reviewer.js", () => ({
|
|
reviewStep: mockReviewStep,
|
|
}));
|
|
|
|
vi.mock("../pi.js", () => ({
|
|
createFnAgent: mockCreateFnAgent,
|
|
describeModel: vi.fn().mockReturnValue("mock-model"),
|
|
promptWithFallback: vi.fn().mockReturnValue("mock-prompt"),
|
|
}));
|
|
|
|
vi.mock("@fusion/core", async (importOriginal) => {
|
|
const { createEngineCoreMock } = await import("../test/mockCore.js");
|
|
const original = await importOriginal<typeof import("@fusion/core")>();
|
|
return createEngineCoreMock(() => Promise.resolve(original), {
|
|
resolveAgentPrompt: vi.fn(original.resolveAgentPrompt),
|
|
});
|
|
});
|
|
|
|
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: [],
|
|
};
|
|
|
|
function createTriageTask(overrides: Partial<Task> = {}): Task {
|
|
return {
|
|
id: "FN-001",
|
|
description: "Triage task",
|
|
column: "triage",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
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");
|
|
});
|
|
|
|
describe("completionDocumentationMode setting", () => {
|
|
it("omits completion documentation guidance when mode is off", () => {
|
|
const settings: Settings = {
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 10000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: true,
|
|
completionDocumentationMode: "off",
|
|
};
|
|
|
|
const prompt = buildSpecificationPrompt(
|
|
baseTask,
|
|
".fusion/tasks/KB-001/PROMPT.md",
|
|
settings,
|
|
);
|
|
|
|
expect(prompt).not.toContain("## Completion Documentation Preference");
|
|
});
|
|
|
|
it("includes changeset guidance when mode is changeset", () => {
|
|
const settings: Settings = {
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 10000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: true,
|
|
completionDocumentationMode: "changeset",
|
|
};
|
|
|
|
const prompt = buildSpecificationPrompt(
|
|
baseTask,
|
|
".fusion/tasks/KB-001/PROMPT.md",
|
|
settings,
|
|
);
|
|
|
|
expect(prompt).toContain("## Completion Documentation Preference");
|
|
expect(prompt).toContain("`completionDocumentationMode` is set to `changeset`");
|
|
expect(prompt).toContain("`.changeset/*.md`");
|
|
expect(prompt).toContain("completion documentation/delivery expectations");
|
|
});
|
|
|
|
it("includes changelog guidance when mode is changelog", () => {
|
|
const settings: Settings = {
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 10000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: true,
|
|
completionDocumentationMode: "changelog",
|
|
};
|
|
|
|
const prompt = buildSpecificationPrompt(
|
|
baseTask,
|
|
".fusion/tasks/KB-001/PROMPT.md",
|
|
settings,
|
|
);
|
|
|
|
expect(prompt).toContain("`completionDocumentationMode` is set to `changelog`");
|
|
expect(prompt).toContain("updating an existing changelog file");
|
|
expect(prompt).toContain("do not invent a new changelog file");
|
|
});
|
|
});
|
|
|
|
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-planning 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");
|
|
expect(prompt).toContain("Treat the current task title and description as required primary inputs");
|
|
});
|
|
|
|
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 \\\`fn_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("fn_memory_search");
|
|
expect(prompt).toContain("fn_memory_get");
|
|
});
|
|
|
|
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("fn_memory_search");
|
|
expect(prompt).toContain("fn_memory_get");
|
|
});
|
|
});
|
|
|
|
describe("memoryBackendType setting", () => {
|
|
it("includes .fusion/memory/ 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/");
|
|
expect(prompt).toContain("fn_memory_append");
|
|
expect(prompt).toContain("Do **not** write");
|
|
expect(prompt).toContain("or any other memory files directly");
|
|
});
|
|
|
|
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/");
|
|
});
|
|
|
|
it("does not include .fusion/memory/ 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/
|
|
expect(prompt).not.toContain(".fusion/memory/");
|
|
expect(prompt).toContain("fn_memory_search");
|
|
expect(prompt).toContain("fn_memory_get");
|
|
expect(prompt).toContain("fn_memory_append");
|
|
expect(prompt).toContain("Do **not** write memory files directly");
|
|
});
|
|
|
|
it("QMD prompt has actionable memory instructions", () => {
|
|
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 contain .fusion/memory/
|
|
expect(prompt).not.toContain(".fusion/memory/");
|
|
expect(prompt).toContain("fn_memory_search");
|
|
});
|
|
});
|
|
|
|
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("canonical triage policy prompt", () => {
|
|
it("does not include unconditional research guidance", () => {
|
|
expect(TRIAGE_POLICY_PROMPT).not.toContain("fn_research_run");
|
|
expect(TRIAGE_POLICY_PROMPT).not.toContain("Keep research bounded");
|
|
});
|
|
|
|
it("requires specs to keep lint, tests, build, and typecheck green even outside initial file scope", () => {
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("If keeping lint/tests/build/typecheck green requires edits outside the initial File Scope");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("Run lint check");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("Run project typecheck if available");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("Lint passing");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("Typecheck passing (if available)");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("Specs must instruct executors to fix lint failures and quality-gate failures directly");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("Refuse necessary fixes just because they touch files outside the initial File Scope");
|
|
});
|
|
|
|
it("includes task-artifact location guidance for forensic/reconciliation tasks", () => {
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("Task Artifact Location");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("<rootDir>/.fusion/tasks/{TARGET_ID}/");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain(".fusion/fusion.db");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("project root");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("forensic");
|
|
});
|
|
});
|
|
|
|
describe("canonical triage policy prompt", () => {
|
|
it("includes proactive M/L subtask breakdown guidance", () => {
|
|
expect(TRIAGE_POLICY_PROMPT).toContain(
|
|
"## Proactive Subtask Breakdown for M/L Tasks",
|
|
);
|
|
expect(TRIAGE_POLICY_PROMPT).toContain(
|
|
"Even when `breakIntoSubtasks` is not set to `true`",
|
|
);
|
|
expect(TRIAGE_POLICY_PROMPT).toContain(
|
|
"Size S tasks should NOT be split",
|
|
);
|
|
});
|
|
|
|
it("includes explicit subtask breakdown thresholds", () => {
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("MORE THAN 7 implementation steps");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain(
|
|
"MORE THAN 3 different packages/modules",
|
|
);
|
|
});
|
|
|
|
it("biases toward keeping tasks whole and acknowledges coordination overhead", () => {
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("Default to keeping the task whole");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("Coordination overhead");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain(
|
|
"7-10 focused steps within a coherent scope is fine as one unit",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("FN-5893 invariant regression wording", () => {
|
|
const corePromptSource = readFileSync(
|
|
fileURLToPath(new URL("../../../core/src/agent-prompts.ts", import.meta.url)),
|
|
"utf8",
|
|
);
|
|
|
|
it("requires invariant-level regression coverage in standard, fast, and core triage prompts", () => {
|
|
for (const prompt of [
|
|
TRIAGE_POLICY_PROMPT,
|
|
FAST_TRIAGE_SYSTEM_PROMPT,
|
|
corePromptSource,
|
|
]) {
|
|
expect(prompt).toContain("invariant across all known surfaces");
|
|
expect(prompt).toContain("provider/bridge");
|
|
expect(prompt).toContain("desktop + mobile breakpoints");
|
|
expect(prompt).toContain("empty/undefined/populated data states");
|
|
expect(prompt).toContain("FN-5787/FN-5789/FN-5803");
|
|
expect(prompt).toContain("FN-5751");
|
|
}
|
|
});
|
|
|
|
it("requires a Surface Enumeration section and proves missing sections are blocking REVISEs for bug-fix specs", () => {
|
|
const missingSectionRevisePattern =
|
|
/For bug fixes and UI-affordance add\/remove tasks, the spec MUST include a `## Surface Enumeration` section\. During self-review via `fn_review_spec\(\)`, treat a missing section on a bug-fix or UI-affordance add\/remove spec as a blocking REVISE\./;
|
|
|
|
for (const prompt of [TRIAGE_POLICY_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) {
|
|
expect(prompt).toContain("## Surface Enumeration");
|
|
expect(prompt).toMatch(missingSectionRevisePattern);
|
|
expect(prompt).toContain("docs/testing.md");
|
|
expect(prompt).toContain("duplicate / populated data states");
|
|
expect(prompt).toContain("shared hooks/components/modules/helpers");
|
|
expect(prompt).toContain("UI-affordance add/remove");
|
|
expect(prompt).toContain("For bug fixes and UI-affordance add/remove tasks");
|
|
expect(prompt).toContain(
|
|
"For bug-fix and UI-affordance add/remove tasks, paste and fill in this checklist",
|
|
);
|
|
}
|
|
|
|
expect(corePromptSource).toContain("## Surface Enumeration");
|
|
expect(corePromptSource).toContain("spec MUST include a \\`## Surface Enumeration\\` section");
|
|
expect(corePromptSource).toContain("blocking REVISE");
|
|
expect(corePromptSource).toContain("docs/testing.md");
|
|
expect(corePromptSource).toContain("duplicate / populated data states");
|
|
expect(corePromptSource).toContain("shared hooks/components/modules/helpers");
|
|
});
|
|
|
|
it("requires implementation-step testing guidance to enumerate invariant surfaces in standard and fast prompts", () => {
|
|
for (const prompt of [TRIAGE_POLICY_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) {
|
|
expect(prompt).toContain(
|
|
"Run targeted tests for changed files, asserting the invariant across all known surfaces",
|
|
);
|
|
expect(prompt).toContain(
|
|
"For bug-fix and UI-affordance add/remove tasks, paste and fill in this checklist in the `## Surface Enumeration` section",
|
|
);
|
|
}
|
|
});
|
|
|
|
it("defines the FN-6229 Symptom Verification contract in standard and fast prompts", () => {
|
|
for (const prompt of [TRIAGE_POLICY_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) {
|
|
expect(prompt).toContain("## Symptom Verification");
|
|
expect(prompt).toContain("Use the exact heading `## Symptom Verification`");
|
|
expect(prompt).toContain("**Original symptom** — what the user/issue reported was broken");
|
|
expect(prompt).toContain("**Exact reproduction** — the precise steps, inputs, fixture, or automated repro that triggered the failure");
|
|
expect(prompt).toContain("**Assertion it is gone**");
|
|
expect(prompt).toContain("final verification must reproduce that original failure condition and assert it no longer occurs");
|
|
expect(prompt).toContain("Green build/tests alone are insufficient");
|
|
expect(prompt).toContain("symptom-based acceptance");
|
|
expect(prompt).toContain("bug-class/bug-fix tasks");
|
|
expect(prompt).toContain("feature/docs/non-bug tasks do not need this section");
|
|
}
|
|
});
|
|
|
|
it("requires Surface Enumeration for UI-affordance add/remove tasks regardless of review-level analysis", () => {
|
|
for (const prompt of [TRIAGE_POLICY_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) {
|
|
expect(prompt).toContain("bug-fix tasks and UI-affordance add/remove tasks");
|
|
expect(prompt).toContain("every component that renders the affordance");
|
|
expect(prompt).toContain("searching the codebase for the icon/class/testid");
|
|
expect(prompt).toContain("leftover shells after removal");
|
|
expect(prompt).toContain("empty buttons");
|
|
}
|
|
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("## Review Level");
|
|
});
|
|
|
|
it("pins the canonical docs checklist heading", () => {
|
|
const docsTestingSource = readFileSync(
|
|
fileURLToPath(new URL("../../../../docs/testing.md", import.meta.url)),
|
|
"utf8",
|
|
);
|
|
|
|
expect(docsTestingSource).toContain("### Surface Enumeration checklist");
|
|
expect(docsTestingSource).toContain("Providers / bridges / execution paths touched by the invariant");
|
|
});
|
|
});
|
|
|
|
describe("fast-mode triage", () => {
|
|
it("exports a lean FAST_TRIAGE_SYSTEM_PROMPT", () => {
|
|
expect(typeof FAST_TRIAGE_SYSTEM_PROMPT).toBe("string");
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT.length).toBeGreaterThan(0);
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("This task is running in **fast mode**");
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("fn_review_spec()");
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("## Review Level");
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("## Triage subtask breakdown");
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("## Proactive Subtask Breakdown");
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("Frontend UX Criteria");
|
|
});
|
|
|
|
it("documents workflow routing in standard and fast prompts", () => {
|
|
for (const prompt of [TRIAGE_POLICY_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) {
|
|
expect(prompt).toContain("## Workflow Routing");
|
|
expect(prompt).toContain("fn_workflow_list");
|
|
expect(prompt).toContain("fn_workflow_select");
|
|
expect(prompt).toContain("workflow_id");
|
|
expect(prompt).toContain("**No commits expected:** true");
|
|
expect(prompt).toContain("builtin:quick-fix");
|
|
expect(prompt).toContain("builtin:coding");
|
|
}
|
|
});
|
|
|
|
it("includes task-artifact location guidance for forensic/reconciliation tasks", () => {
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("Task Artifact Location");
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("<rootDir>/.fusion/tasks/{TARGET_ID}/");
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain(".fusion/fusion.db");
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("project root");
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("forensic");
|
|
});
|
|
|
|
it("selects FAST_TRIAGE_SYSTEM_PROMPT for fast tasks", async () => {
|
|
const task = createTriageTask({ id: "FN-FAST-001", executionMode: "fast" });
|
|
const store = createMockStore({
|
|
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }),
|
|
});
|
|
|
|
let capturedSystemPrompt = "";
|
|
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
|
|
capturedSystemPrompt = opts.systemPrompt;
|
|
return {
|
|
session: {
|
|
state: {},
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
navigateTree: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
const processor = new TriageProcessor(store, "/tmp/root");
|
|
await processor.specifyTask(task);
|
|
|
|
expect(capturedSystemPrompt).toContain("This task is running in **fast mode**");
|
|
expect(capturedSystemPrompt).not.toContain("## Review Level");
|
|
});
|
|
|
|
it("keeps standard prompt for standard tasks", async () => {
|
|
const task = createTriageTask({ id: "FN-FAST-002", executionMode: "standard" });
|
|
const store = createMockStore({
|
|
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }),
|
|
});
|
|
|
|
let capturedSystemPrompt = "";
|
|
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
|
|
capturedSystemPrompt = opts.systemPrompt;
|
|
return {
|
|
session: {
|
|
state: {},
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
navigateTree: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
const processor = new TriageProcessor(store, "/tmp/root");
|
|
await processor.specifyTask(task);
|
|
|
|
expect(capturedSystemPrompt).toContain("## Review Level");
|
|
});
|
|
|
|
it("includes triage plugin contributions when provided", async () => {
|
|
const task = createTriageTask({ id: "FN-FAST-PLUGIN-001", executionMode: "standard" });
|
|
const store = createMockStore({
|
|
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }),
|
|
});
|
|
const pluginRunner = {
|
|
getPromptContributionsForSurface: vi.fn().mockImplementation((surface: string) => {
|
|
if (surface !== "triage") return [];
|
|
return [{ pluginId: "plugin-triage", contribution: { surface: "triage", content: "Use plugin triage policy." }, config: {} }];
|
|
}),
|
|
getPluginSkills: vi.fn().mockReturnValue([]),
|
|
};
|
|
|
|
let capturedSystemPrompt = "";
|
|
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
|
|
capturedSystemPrompt = opts.systemPrompt;
|
|
return {
|
|
session: {
|
|
state: {},
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
navigateTree: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
const processor = new TriageProcessor(store, "/tmp/root", { pluginRunner: pluginRunner as any });
|
|
await processor.specifyTask(task);
|
|
|
|
expect(capturedSystemPrompt).toContain("## Plugin: plugin-triage");
|
|
expect(capturedSystemPrompt).toContain("Use plugin triage policy.");
|
|
});
|
|
|
|
it("keeps triage prompt unchanged when no triage plugin contributions exist", async () => {
|
|
const task = createTriageTask({ id: "FN-FAST-PLUGIN-002", executionMode: "standard" });
|
|
const store = createMockStore({
|
|
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }),
|
|
});
|
|
const pluginRunner = {
|
|
getPromptContributionsForSurface: vi.fn().mockReturnValue([]),
|
|
getPluginSkills: vi.fn().mockReturnValue([]),
|
|
};
|
|
|
|
let capturedSystemPrompt = "";
|
|
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
|
|
capturedSystemPrompt = opts.systemPrompt;
|
|
return {
|
|
session: {
|
|
state: {},
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
navigateTree: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
const processor = new TriageProcessor(store, "/tmp/root", { pluginRunner: pluginRunner as any });
|
|
await processor.specifyTask(task);
|
|
|
|
expect(capturedSystemPrompt).not.toContain("## Plugin:");
|
|
});
|
|
|
|
it("applies triage plugin contributions in fast mode too", async () => {
|
|
const task = createTriageTask({ id: "FN-FAST-PLUGIN-003", executionMode: "fast" });
|
|
const store = createMockStore({
|
|
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }),
|
|
});
|
|
const pluginRunner = {
|
|
getPromptContributionsForSurface: vi.fn().mockReturnValue([
|
|
{ pluginId: "plugin-fast", contribution: { surface: "triage", content: "Fast mode plugin note." }, config: {} },
|
|
]),
|
|
getPluginSkills: vi.fn().mockReturnValue([]),
|
|
};
|
|
|
|
let capturedSystemPrompt = "";
|
|
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
|
|
capturedSystemPrompt = opts.systemPrompt;
|
|
return {
|
|
session: {
|
|
state: {},
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
navigateTree: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
const processor = new TriageProcessor(store, "/tmp/root", { pluginRunner: pluginRunner as any });
|
|
await processor.specifyTask(task);
|
|
|
|
expect(capturedSystemPrompt).toContain("This task is running in **fast mode**");
|
|
expect(capturedSystemPrompt).toContain("## Plugin: plugin-fast");
|
|
});
|
|
|
|
it("auto-approves fn_review_spec in fast mode without calling reviewer", async () => {
|
|
const rootDir = await createTriageFixtureRoot("fusion-triage-fast-review-");
|
|
try {
|
|
const taskId = "FN-FAST-003";
|
|
await mkdir(join(rootDir, ".fusion", "tasks", taskId), { recursive: true });
|
|
await writeFile(join(rootDir, ".fusion", "tasks", taskId, "PROMPT.md"), "# Task\n\nSpec");
|
|
|
|
const store = createMockStore({
|
|
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: taskId, comments: [] }),
|
|
});
|
|
const processor = new TriageProcessor(store, rootDir);
|
|
const verdictRef = { current: null as any };
|
|
const approvedCommentFingerprintRef = { current: "" };
|
|
|
|
const tool = (processor as any).createReviewSpecTool(
|
|
taskId,
|
|
`.fusion/tasks/${taskId}/PROMPT.md`,
|
|
{ current: null },
|
|
{ current: null },
|
|
verdictRef,
|
|
approvedCommentFingerprintRef,
|
|
{},
|
|
true,
|
|
);
|
|
|
|
const result = await tool.execute({});
|
|
|
|
expect(mockReviewStep).not.toHaveBeenCalled();
|
|
expect(verdictRef.current).toBe("APPROVE");
|
|
expect(result.content[0]?.text).toBe("APPROVE");
|
|
expect(store.logEntry).toHaveBeenCalledWith(taskId, "Spec review: APPROVE (auto, fast mode)");
|
|
} finally {
|
|
await cleanupTriageFixtureRoot(rootDir);
|
|
}
|
|
});
|
|
|
|
it("passes post-session gate in fast mode after fn_review_spec auto-approval", async () => {
|
|
const rootDir = await createTriageFixtureRoot("fusion-triage-fast-gate-");
|
|
try {
|
|
const task = createTriageTask({ id: "FN-FAST-004", executionMode: "fast" });
|
|
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
|
|
await mkdir(join(rootDir, ".fusion", "tasks", task.id), { recursive: true });
|
|
|
|
const store = createMockStore({
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 10000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: true,
|
|
experimentalFeatures: { researchView: true },
|
|
} as Settings),
|
|
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }),
|
|
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
|
|
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
|
|
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
|
});
|
|
|
|
let capturedTools: any[] = [];
|
|
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
|
|
capturedTools = opts.customTools;
|
|
return {
|
|
session: {
|
|
state: {},
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
navigateTree: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
const { promptWithFallback } = await import("../pi.js");
|
|
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementationOnce(async () => {
|
|
expect(capturedTools.some((tool: any) => tool.name === "fn_research_run")).toBe(true);
|
|
expect(capturedTools.some((tool: any) => tool.name === "fn_research_list")).toBe(true);
|
|
expect(capturedTools.some((tool: any) => tool.name === "fn_research_get")).toBe(true);
|
|
expect(capturedTools.some((tool: any) => tool.name === "fn_research_cancel")).toBe(true);
|
|
await writeFile(promptPath, "# Task: FN-FAST-004 - Fast\n\n## Mission\n\nShip it.");
|
|
const reviewTool = capturedTools.find((tool) => tool.name === "fn_review_spec");
|
|
expect(reviewTool).toBeDefined();
|
|
await reviewTool.execute({});
|
|
});
|
|
|
|
const processor = new TriageProcessor(store, rootDir);
|
|
await processor.specifyTask(task);
|
|
|
|
expect(mockReviewStep).not.toHaveBeenCalled();
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-FAST-004", "todo");
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-FAST-004", "Spec review: APPROVE (auto, fast mode)");
|
|
} finally {
|
|
await cleanupTriageFixtureRoot(rootDir);
|
|
}
|
|
});
|
|
|
|
it("omits research tools and prompt guidance when researchView experimental flag is disabled", async () => {
|
|
const task = createTriageTask({ id: "FN-FAST-005", executionMode: "fast" });
|
|
const store = createMockStore({
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 10000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: true,
|
|
experimentalFeatures: { researchView: false },
|
|
} as Settings),
|
|
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }),
|
|
});
|
|
|
|
let capturedTools: any[] = [];
|
|
let capturedSystemPrompt = "";
|
|
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
|
|
capturedTools = opts.customTools;
|
|
capturedSystemPrompt = opts.systemPrompt;
|
|
return {
|
|
session: {
|
|
state: {},
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
navigateTree: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
const processor = new TriageProcessor(store, "/tmp/root");
|
|
await processor.specifyTask(task);
|
|
|
|
expect(capturedTools.some((tool: any) => tool.name === "fn_research_run")).toBe(false);
|
|
expect(capturedTools.some((tool: any) => tool.name === "fn_research_list")).toBe(false);
|
|
expect(capturedTools.some((tool: any) => tool.name === "fn_research_get")).toBe(false);
|
|
expect(capturedTools.some((tool: any) => tool.name === "fn_research_cancel")).toBe(false);
|
|
expect(capturedSystemPrompt).not.toContain("fn_research_run");
|
|
});
|
|
|
|
it("includes research prompt guidance when researchView experimental flag is enabled", async () => {
|
|
const task = createTriageTask({ id: "FN-FAST-006", executionMode: "fast" });
|
|
const store = createMockStore({
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 10000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: true,
|
|
experimentalFeatures: { researchView: true },
|
|
} as Settings),
|
|
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }),
|
|
});
|
|
|
|
let capturedSystemPrompt = "";
|
|
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
|
|
capturedSystemPrompt = opts.systemPrompt;
|
|
return {
|
|
session: {
|
|
state: {},
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
navigateTree: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
const processor = new TriageProcessor(store, "/tmp/root");
|
|
await processor.specifyTask(task);
|
|
|
|
expect(capturedSystemPrompt).toContain("fn_research_run");
|
|
expect(capturedSystemPrompt).toContain("Keep research bounded");
|
|
});
|
|
});
|
|
|
|
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("includes workflow discovery and selection tools in the full triage toolset", async () => {
|
|
const task = createTriageTask({ id: "FN-WORKFLOW-TOOLS" });
|
|
const detailedTask = { ...mockTaskDetail, id: task.id, attachments: [], comments: [] };
|
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(detailedTask);
|
|
|
|
let capturedTools: any[] = [];
|
|
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
|
|
capturedTools = opts.customTools;
|
|
return {
|
|
session: {
|
|
state: {},
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
navigateTree: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
await processor.specifyTask(task);
|
|
|
|
const toolNames = capturedTools.map((tool) => tool.name);
|
|
expect(toolNames).toContain("fn_workflow_list");
|
|
expect(toolNames).toContain("fn_workflow_select");
|
|
});
|
|
|
|
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));
|
|
});
|
|
|
|
describe("poll ordering", () => {
|
|
it("dispatches eligible triage tasks by priority desc then createdAt asc", async () => {
|
|
const tasks: Task[] = [
|
|
createTriageTask({
|
|
id: "FN-100",
|
|
priority: "normal",
|
|
createdAt: "2026-01-01T00:01:00.000Z",
|
|
}),
|
|
createTriageTask({
|
|
id: "FN-101",
|
|
priority: "urgent",
|
|
createdAt: "2026-01-01T00:10:00.000Z",
|
|
}),
|
|
createTriageTask({
|
|
id: "FN-102",
|
|
priority: "high",
|
|
createdAt: "2026-01-01T00:03:00.000Z",
|
|
}),
|
|
createTriageTask({
|
|
id: "FN-103",
|
|
priority: "high",
|
|
createdAt: "2026-01-01T00:02:00.000Z",
|
|
}),
|
|
];
|
|
|
|
const triageStore = createMockStore({
|
|
listTasks: vi.fn().mockResolvedValue(tasks),
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
maxConcurrent: 10,
|
|
maxTriageConcurrent: 10,
|
|
pollIntervalMs: 10_000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: true,
|
|
}),
|
|
});
|
|
const triageProcessor = new TriageProcessor(triageStore, rootDir);
|
|
const specifySpy = vi
|
|
.spyOn(triageProcessor, "specifyTask")
|
|
.mockResolvedValue(undefined);
|
|
|
|
(triageProcessor as any).running = true;
|
|
await (triageProcessor as any).poll();
|
|
|
|
expect(specifySpy).toHaveBeenCalledTimes(4);
|
|
expect(specifySpy.mock.calls.map(([task]) => task.id)).toEqual([
|
|
"FN-101",
|
|
"FN-103",
|
|
"FN-102",
|
|
"FN-100",
|
|
]);
|
|
});
|
|
|
|
it("excludes paused, awaiting-approval, failed, stuck-killed, and recovery-gated tasks from ordered candidates", async () => {
|
|
const future = new Date(Date.now() + 60_000).toISOString();
|
|
const tasks: Task[] = [
|
|
createTriageTask({ id: "FN-200", priority: "urgent" }),
|
|
createTriageTask({ id: "FN-201", priority: "urgent", paused: true }),
|
|
createTriageTask({ id: "FN-202", priority: "urgent", status: "awaiting-approval" }),
|
|
createTriageTask({ id: "FN-203", priority: "urgent", status: "failed" }),
|
|
createTriageTask({ id: "FN-204", priority: "urgent", status: "stuck-killed" }),
|
|
createTriageTask({ id: "FN-205", priority: "urgent", nextRecoveryAt: future }),
|
|
];
|
|
|
|
const triageStore = createMockStore({
|
|
listTasks: vi.fn().mockResolvedValue(tasks),
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
maxConcurrent: 10,
|
|
maxTriageConcurrent: 10,
|
|
pollIntervalMs: 10_000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: true,
|
|
}),
|
|
});
|
|
const triageProcessor = new TriageProcessor(triageStore, rootDir);
|
|
const specifySpy = vi
|
|
.spyOn(triageProcessor, "specifyTask")
|
|
.mockResolvedValue(undefined);
|
|
|
|
(triageProcessor as any).running = true;
|
|
await (triageProcessor as any).poll();
|
|
|
|
expect(specifySpy).toHaveBeenCalledTimes(1);
|
|
expect(specifySpy).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-200" }));
|
|
});
|
|
});
|
|
|
|
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",
|
|
},
|
|
false,
|
|
);
|
|
|
|
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-replan",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
|
|
it("detects needs-replan status", () => {
|
|
expect(taskWithRevisionRequest.status).toBe("needs-replan");
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
it("prefers latest comment-triggered re-spec feedback log over legacy revision requests", () => {
|
|
const taskWithCommentTriggeredFeedback: Task = {
|
|
...taskWithRevisionRequest,
|
|
log: [
|
|
{
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
action: "AI spec revision requested",
|
|
outcome: "Older feedback",
|
|
},
|
|
{
|
|
timestamp: "2026-01-01T00:03:00.000Z",
|
|
action: "User comment requested re-specification of planned task",
|
|
outcome: "Latest feedback",
|
|
},
|
|
],
|
|
};
|
|
|
|
const feedbackLogEntry = [...taskWithCommentTriggeredFeedback.log]
|
|
.reverse()
|
|
.find((entry) =>
|
|
entry.action === "User comment requested re-specification of planned task"
|
|
|| entry.action === "User comment invalidated spec approval — task needs re-specification"
|
|
|| entry.action === "AI spec revision requested"
|
|
);
|
|
|
|
expect(feedbackLogEntry?.outcome).toBe("Latest 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: "planning",
|
|
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**No commits expected:** true\n\n## Review Level: 2\n\nRecovered specification",
|
|
);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await cleanupTriageFixtureRoot(rootDir);
|
|
});
|
|
|
|
it("moves approved planning 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: "planning",
|
|
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,
|
|
noCommitsExpected: true,
|
|
});
|
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-001",
|
|
"Auto-recovered approved specification stuck in planning — moved to todo",
|
|
);
|
|
});
|
|
|
|
it("updates malformed metadata title from prompt heading when task ID matches", async () => {
|
|
await writeFile(
|
|
join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"),
|
|
"# Task: FN-001 - Experimental AI Agent Onboarding Flow\n\n**Size:** M\n\n## Review Level: 2\n\nRecovered specification",
|
|
);
|
|
|
|
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: "planning",
|
|
title: "Created task **FN-999** in triage",
|
|
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({ title: "Experimental AI Agent Onboarding Flow" }),
|
|
);
|
|
});
|
|
|
|
it("does not overwrite title when heading task ID does not match", async () => {
|
|
await writeFile(
|
|
join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"),
|
|
"# Task: FN-999 - Wrong Task\n\n**Size:** M\n\n## Review Level: 2\n\nRecovered specification",
|
|
);
|
|
|
|
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: "planning",
|
|
title: "Existing title",
|
|
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.not.objectContaining({ title: expect.any(String) }),
|
|
);
|
|
});
|
|
|
|
it("includes decision-only noCommitsExpected heuristic instructions in system prompts", () => {
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("**No commits expected:** true");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("Decide whether FN-XYZ needs a fix");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("Investigate FN-XYZ and fix if needed");
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("**No commits expected:** true");
|
|
});
|
|
|
|
it("preserves imported GitHub issue titles during planning recovery", async () => {
|
|
await writeFile(
|
|
join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"),
|
|
"# Task: FN-001 - Different AI-generated planning title\n\n**Size:** M\n\n## Review Level: 2\n\nRecovered specification",
|
|
);
|
|
|
|
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: "Imported from GitHub",
|
|
column: "triage",
|
|
status: "planning",
|
|
title: '"Cannot read properties of undefined (reading \'trim\')" when extracting insights',
|
|
sourceType: "github_import",
|
|
sourceIssue: {
|
|
provider: "github",
|
|
repository: "Runfusion/Fusion",
|
|
externalIssueId: "70",
|
|
issueNumber: 70,
|
|
url: "https://github.com/Runfusion/Fusion/issues/70",
|
|
},
|
|
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).not.toHaveBeenCalledWith(
|
|
"FN-001",
|
|
expect.objectContaining({ title: "Different AI-generated planning title" }),
|
|
);
|
|
});
|
|
|
|
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: "planning",
|
|
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 planning 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: "planning",
|
|
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 planning — 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,
|
|
source: { sourceType: "agent_heartbeat", sourceParentTaskId: parentTaskId },
|
|
});
|
|
|
|
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,
|
|
source: { sourceType: "agent_heartbeat", sourceParentTaskId: parentTaskId },
|
|
});
|
|
|
|
expect(store.getTask).toHaveBeenCalledWith("FN-NONEXISTENT");
|
|
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
|
modelProvider: undefined,
|
|
modelId: undefined,
|
|
validatorModelProvider: undefined,
|
|
validatorModelId: undefined,
|
|
}));
|
|
});
|
|
|
|
describe("proactive subtask creation (fn_task_create always available)", () => {
|
|
it("fn_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("fn_task_create");
|
|
expect(toolNames).toContain("fn_task_list");
|
|
expect(toolNames).toContain("fn_task_search");
|
|
expect(toolNames).toContain("fn_task_get");
|
|
expect(tools).toHaveLength(4);
|
|
});
|
|
|
|
it("fn_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 === "fn_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",
|
|
}), expect.objectContaining({
|
|
settings: expect.objectContaining({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it("fn_task_create passes workflow_id and noCommitsExpected through to child tasks", async () => {
|
|
const parentTask: Task = {
|
|
id: "FN-410",
|
|
description: "Parent task",
|
|
column: "triage",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
|
|
const createdSubtask: Task = {
|
|
...parentTask,
|
|
id: "FN-411",
|
|
description: "Decision child task",
|
|
workflowId: "builtin:quick-fix",
|
|
noCommitsExpected: true,
|
|
};
|
|
|
|
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-410",
|
|
allowTaskCreate: true,
|
|
createdSubtasksRef,
|
|
});
|
|
const taskCreateTool = tools.find((t: any) => t.name === "fn_task_create");
|
|
|
|
const result = await taskCreateTool.execute("call-1", {
|
|
description: "Investigate and report the routing decision",
|
|
workflow_id: "builtin:quick-fix",
|
|
noCommitsExpected: true,
|
|
});
|
|
|
|
expect(result.content[0].text).toContain("Created child task FN-411");
|
|
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
|
description: "Investigate and report the routing decision",
|
|
workflowId: "builtin:quick-fix",
|
|
noCommitsExpected: true,
|
|
}), expect.objectContaining({
|
|
settings: expect.objectContaining({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
}),
|
|
}));
|
|
expect(createdSubtasksRef.current).toContain("FN-411");
|
|
});
|
|
|
|
it("fn_task_create rejects a dependency on the parent task being split", async () => {
|
|
// Regression: triage used to accept any id in `dependencies`. If the AI
|
|
// named the parent, the parent got deleted after the split and the child
|
|
// was blocked forever by a nonexistent dep (FN-2163/FN-2164 incident).
|
|
const parentTask: Task = {
|
|
id: "FN-600",
|
|
description: "Parent about to be split",
|
|
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(),
|
|
});
|
|
const processor = new TriageProcessor(store, "/test/root");
|
|
const createdSubtasksRef = { current: [] };
|
|
|
|
const tools = (processor as any).createTriageTools({
|
|
parentTaskId: "FN-600",
|
|
allowTaskCreate: true,
|
|
createdSubtasksRef,
|
|
});
|
|
const taskCreateTool = tools.find((t: any) => t.name === "fn_task_create");
|
|
|
|
const result = await taskCreateTool.execute("call-1", {
|
|
description: "Child that tries to wait for the parent",
|
|
dependencies: ["FN-600"],
|
|
});
|
|
|
|
const text = result.content[0].text;
|
|
expect(text).toContain("ERROR");
|
|
expect(text).toContain("FN-600");
|
|
expect(text).toContain("parent task is deleted after splitting");
|
|
// Must not create the child — the caller has to fix the deps and retry.
|
|
expect(store.createTask).not.toHaveBeenCalled();
|
|
expect(createdSubtasksRef.current).toEqual([]);
|
|
});
|
|
|
|
it("fn_task_create accepts dependencies on sibling subtasks created earlier in the same split", async () => {
|
|
// The valid case: two siblings where the second depends on the first.
|
|
const parentTask: Task = {
|
|
id: "FN-700",
|
|
description: "Parent to split",
|
|
column: "triage",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
const sibling1: Task = { ...parentTask, id: "FN-701", description: "Sibling 1" };
|
|
const sibling2: Task = { ...parentTask, id: "FN-702", description: "Sibling 2" };
|
|
|
|
const createTaskMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(sibling1)
|
|
.mockResolvedValueOnce(sibling2);
|
|
|
|
const store = createMockStore({
|
|
getTask: vi.fn().mockResolvedValue(parentTask),
|
|
createTask: createTaskMock,
|
|
});
|
|
const processor = new TriageProcessor(store, "/test/root");
|
|
const createdSubtasksRef = { current: [] };
|
|
|
|
const tools = (processor as any).createTriageTools({
|
|
parentTaskId: "FN-700",
|
|
allowTaskCreate: true,
|
|
createdSubtasksRef,
|
|
});
|
|
const taskCreateTool = tools.find((t: any) => t.name === "fn_task_create");
|
|
|
|
const firstRes = await taskCreateTool.execute("c1", {
|
|
description: "Sibling 1",
|
|
dependencies: [],
|
|
});
|
|
expect(firstRes.content[0].text).toContain("Created child task FN-701");
|
|
|
|
const secondRes = await taskCreateTool.execute("c2", {
|
|
description: "Sibling 2 depending on sibling 1",
|
|
dependencies: ["FN-701"],
|
|
});
|
|
expect(secondRes.content[0].text).toContain("Created child task FN-702");
|
|
expect(secondRes.content[0].text).not.toContain("ERROR");
|
|
|
|
// The second createTask call should have the resolved sibling id preserved.
|
|
expect(createTaskMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ dependencies: ["FN-701"] }),
|
|
expect.objectContaining({
|
|
settings: expect.objectContaining({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
}),
|
|
}),
|
|
);
|
|
expect(createdSubtasksRef.current).toEqual(["FN-701", "FN-702"]);
|
|
});
|
|
|
|
it("fn_task_create rejects an unknown dependency id that is neither sibling nor existing task", async () => {
|
|
const parentTask: Task = {
|
|
id: "FN-800",
|
|
description: "Parent",
|
|
column: "triage",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
// getTask returns the parent when asked, but throws for unknown ids.
|
|
const store = createMockStore({
|
|
getTask: vi.fn(async (id: string) => {
|
|
if (id === "FN-800") return parentTask;
|
|
throw new Error(`Task ${id} not found`);
|
|
}) as unknown as TaskStore["getTask"],
|
|
createTask: vi.fn(),
|
|
});
|
|
const processor = new TriageProcessor(store, "/test/root");
|
|
const createdSubtasksRef = { current: [] };
|
|
|
|
const tools = (processor as any).createTriageTools({
|
|
parentTaskId: "FN-800",
|
|
allowTaskCreate: true,
|
|
createdSubtasksRef,
|
|
});
|
|
const taskCreateTool = tools.find((t: any) => t.name === "fn_task_create");
|
|
|
|
const result = await taskCreateTool.execute("c1", {
|
|
description: "Child naming a nonexistent dep",
|
|
dependencies: ["FN-9999"],
|
|
});
|
|
|
|
expect(result.content[0].text).toContain("ERROR");
|
|
expect(result.content[0].text).toContain("FN-9999");
|
|
expect(result.content[0].text).toContain("task not found");
|
|
expect(store.createTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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 createFnAgent, then have
|
|
// promptWithFallback invoke the fn_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 createFnAgent call
|
|
let capturedCustomTools: any[] = [];
|
|
const mockDispose = vi.fn();
|
|
mockCreateFnAgent.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 fn_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 === "fn_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", expect.objectContaining({
|
|
removeLineageReferences: true,
|
|
auditContext: expect.objectContaining({
|
|
agentId: "triage",
|
|
runId: expect.stringMatching(/^triage-delete-FN-500-/),
|
|
}),
|
|
}));
|
|
});
|
|
});
|
|
|
|
describe("bounded recovery retries for triage", () => {
|
|
it("requeues triage with backoff when the agent exits without calling fn_review_spec", async () => {
|
|
const task = {
|
|
id: "FN-202",
|
|
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: [], comments: [] }),
|
|
});
|
|
|
|
mockCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue(null),
|
|
navigateTree: vi.fn(),
|
|
},
|
|
},
|
|
});
|
|
|
|
const { promptWithFallback } = await import("../pi.js");
|
|
(promptWithFallback as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
|
|
|
|
const processor = new TriageProcessor(store, "/test/root", {
|
|
pollIntervalMs: 100_000,
|
|
});
|
|
|
|
await processor.specifyTask(task);
|
|
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-202", expect.objectContaining({
|
|
status: null,
|
|
error: null,
|
|
recoveryRetryCount: 1,
|
|
nextRecoveryAt: expect.any(String),
|
|
}));
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-202",
|
|
expect.stringContaining("Spec review not approved (fn_review_spec was never called)"),
|
|
);
|
|
});
|
|
|
|
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 createFnAgent to throw a transient error
|
|
mockCreateFnAgent.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,
|
|
});
|
|
|
|
mockCreateFnAgent.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 failed triage tasks until they are explicitly retried", async () => {
|
|
const task = {
|
|
id: "FN-102",
|
|
description: "Failed triage task",
|
|
column: "triage",
|
|
status: "failed",
|
|
error: "Specification failed",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} 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");
|
|
|
|
processor.start();
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
processor.stop();
|
|
|
|
expect(specifySpy).not.toHaveBeenCalled();
|
|
specifySpy.mockRestore();
|
|
});
|
|
|
|
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 createFnAgent 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.
|
|
mockCreateFnAgent.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,
|
|
provider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
planningProvider: "openai",
|
|
planningModelId: "gpt-4o",
|
|
} as Settings),
|
|
});
|
|
|
|
mockCreateFnAgent.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 and use session defaults keys
|
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
defaultProvider: "google",
|
|
defaultModelId: "gemini-2.5-pro",
|
|
}),
|
|
);
|
|
const triageSessionCall = mockCreateFnAgent.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
|
expect(triageSessionCall).toBeDefined();
|
|
expect(triageSessionCall).not.toHaveProperty("provider");
|
|
expect(triageSessionCall).not.toHaveProperty("modelId");
|
|
});
|
|
|
|
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),
|
|
});
|
|
|
|
mockCreateFnAgent.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(mockCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
defaultProvider: "openai",
|
|
defaultModelId: "gpt-4o",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses project default override when planning lanes are absent", async () => {
|
|
const task = {
|
|
id: "FN-402",
|
|
description: "Test fallback to project default override",
|
|
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",
|
|
defaultProviderOverride: "openai",
|
|
defaultModelIdOverride: "gpt-4o",
|
|
// No planningProvider/planningModelId set
|
|
} as Settings),
|
|
});
|
|
|
|
mockCreateFnAgent.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 project default override when planning lanes are absent
|
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
defaultProvider: "openai",
|
|
defaultModelId: "gpt-4o",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("falls through to global default when project default override is incomplete", async () => {
|
|
const task = {
|
|
id: "FN-403",
|
|
description: "Test fallback when project default override is incomplete",
|
|
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",
|
|
defaultProviderOverride: "openai",
|
|
// defaultModelIdOverride intentionally omitted
|
|
// No planningProvider/planningModelId set
|
|
} as Settings),
|
|
});
|
|
|
|
mockCreateFnAgent.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);
|
|
|
|
// Incomplete override should fall through to global defaults
|
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("falls back to global defaults when neither task nor settings have planning model", async () => {
|
|
const task = {
|
|
id: "FN-404",
|
|
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),
|
|
});
|
|
|
|
mockCreateFnAgent.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(mockCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("assigned-agent triage inheritance", () => {
|
|
it("injects assigned-agent identity into triage system prompt", async () => {
|
|
const task = createTriageTask({ id: "FN-AGENT-001", assignedAgentId: "agent-007" });
|
|
const store = createMockStore({
|
|
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
|
|
});
|
|
|
|
const mockAgentStore = {
|
|
getAgent: vi.fn().mockResolvedValue({
|
|
id: "agent-007",
|
|
name: "Atlas",
|
|
title: "Senior Planner",
|
|
role: "executor",
|
|
soul: "Think in milestones.",
|
|
instructionsText: "Always preserve rollout safety.",
|
|
memory: "Atlas memory context",
|
|
}),
|
|
};
|
|
|
|
let capturedArgs: any;
|
|
mockCreateFnAgent.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, "/test/root", {
|
|
pollIntervalMs: 100_000,
|
|
agentStore: mockAgentStore as any,
|
|
});
|
|
|
|
await processor.specifyTask(task);
|
|
|
|
expect(capturedArgs.systemPrompt).toContain("## Identity");
|
|
expect(capturedArgs.systemPrompt).toContain("You are Atlas, Senior Planner");
|
|
expect(capturedArgs.systemPrompt).toContain("agent ID: agent-007");
|
|
});
|
|
|
|
it("prefers planning settings model ahead of assigned-agent runtime model", async () => {
|
|
const completeRuntimeTask = createTriageTask({ id: "FN-AGENT-MODEL-1", assignedAgentId: "agent-model-complete" });
|
|
const incompleteRuntimeTask = createTriageTask({ id: "FN-AGENT-MODEL-2", assignedAgentId: "agent-model-incomplete" });
|
|
|
|
const store = createMockStore({
|
|
getTask: vi.fn()
|
|
.mockResolvedValueOnce({ ...completeRuntimeTask, attachments: [] })
|
|
.mockResolvedValueOnce({ ...incompleteRuntimeTask, attachments: [] }),
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 10000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: true,
|
|
planningProvider: "openai",
|
|
planningModelId: "gpt-4o",
|
|
} as Settings),
|
|
});
|
|
|
|
const mockAgentStore = {
|
|
getAgent: vi.fn().mockImplementation(async (id: string) => {
|
|
if (id === "agent-model-complete") {
|
|
return {
|
|
id,
|
|
name: "Model Agent",
|
|
role: "executor",
|
|
runtimeConfig: {
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
id,
|
|
name: "Incomplete Model Agent",
|
|
role: "executor",
|
|
runtimeConfig: {
|
|
modelProvider: "anthropic",
|
|
},
|
|
};
|
|
}),
|
|
};
|
|
|
|
const capturedArgs: any[] = [];
|
|
mockCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedArgs.push(opts);
|
|
return {
|
|
session: {
|
|
state: {},
|
|
sessionManager: {},
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
navigateTree: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
const processor = new TriageProcessor(store, "/test/root", {
|
|
pollIntervalMs: 100_000,
|
|
agentStore: mockAgentStore as any,
|
|
});
|
|
|
|
await processor.specifyTask(completeRuntimeTask);
|
|
await processor.specifyTask(incompleteRuntimeTask);
|
|
|
|
const completeCall = capturedArgs.find((entry) => entry.taskId === "FN-AGENT-MODEL-1");
|
|
const fallbackCall = capturedArgs.find((entry) => entry.taskId === "FN-AGENT-MODEL-2");
|
|
|
|
expect(completeCall).toMatchObject({ defaultProvider: "openai", defaultModelId: "gpt-4o" });
|
|
expect(fallbackCall).toMatchObject({ defaultProvider: "openai", defaultModelId: "gpt-4o" });
|
|
});
|
|
|
|
it("passes assigned agent memory context into triage memory tools", async () => {
|
|
const rootDir = await createTriageFixtureRoot("fusion-triage-agent-memory-");
|
|
try {
|
|
const task = createTriageTask({ id: "FN-AGENT-MEM-001", assignedAgentId: "agent-memory-1" });
|
|
const store = createMockStore({
|
|
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 10000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: true,
|
|
memoryBackendType: "file",
|
|
} as Settings),
|
|
});
|
|
|
|
const mockAgentStore = {
|
|
getAgent: vi.fn().mockResolvedValue({
|
|
id: "agent-memory-1",
|
|
name: "Memory Agent",
|
|
role: "executor",
|
|
memory: "The launch runway is blocked by migration sequencing.",
|
|
}),
|
|
};
|
|
|
|
let capturedArgs: any;
|
|
mockCreateFnAgent.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, rootDir, {
|
|
pollIntervalMs: 100_000,
|
|
agentStore: mockAgentStore as any,
|
|
});
|
|
|
|
await processor.specifyTask(task);
|
|
|
|
const memorySearchTool = capturedArgs.customTools.find((tool: any) => tool.name === "fn_memory_search");
|
|
expect(memorySearchTool).toBeDefined();
|
|
|
|
const result = await memorySearchTool.execute("tool-run", { query: "runway", limit: 5 });
|
|
expect(result.details.results.some((hit: any) => String(hit.path).includes(".fusion/agent-memory/agent-memory-1/MEMORY.md"))).toBe(true);
|
|
} finally {
|
|
await cleanupTriageFixtureRoot(rootDir);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
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 fn_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,
|
|
{},
|
|
false,
|
|
);
|
|
|
|
// Execute fn_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 fn_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,
|
|
{},
|
|
false,
|
|
);
|
|
|
|
await tool.execute({});
|
|
|
|
// Fingerprint should NOT be captured on REVISE
|
|
expect(approvedCommentFingerprintRef.current).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("pause-abort status clearing (bug fix)", () => {
|
|
it("clears planning 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; });
|
|
mockCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
state: {},
|
|
sessionManager: {},
|
|
prompt: vi.fn().mockReturnValue(disposePromise),
|
|
dispose: vi.fn().mockImplementation(() => resolveDispose()),
|
|
navigateTree: vi.fn(),
|
|
},
|
|
});
|
|
const { promptWithFallback } = await import("../pi.js");
|
|
(promptWithFallback as ReturnType<typeof vi.fn>).mockReturnValueOnce(disposePromise);
|
|
|
|
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 planning 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());
|
|
mockCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
state: {},
|
|
sessionManager: {},
|
|
prompt: vi.fn().mockReturnValue(disposePromise),
|
|
dispose: mockDispose,
|
|
navigateTree: vi.fn(),
|
|
},
|
|
});
|
|
const { promptWithFallback } = await import("../pi.js");
|
|
(promptWithFallback as ReturnType<typeof vi.fn>).mockReturnValueOnce(disposePromise);
|
|
|
|
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;
|
|
|
|
mockCreateFnAgent.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("specifyTask — status restore failure diagnostics", () => {
|
|
it("logs warning when status restore fails during pause abort", async () => {
|
|
const warnSpy = vi.spyOn(planLog, "warn");
|
|
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().mockImplementation(async (_id: string, patch: Record<string, unknown>) => {
|
|
if (patch?.status === null) {
|
|
throw new Error("pause restore failed");
|
|
}
|
|
}),
|
|
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; });
|
|
mockCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
state: {},
|
|
sessionManager: {},
|
|
prompt: vi.fn().mockReturnValue(disposePromise),
|
|
dispose: vi.fn().mockImplementation(() => resolveDispose()),
|
|
navigateTree: vi.fn(),
|
|
},
|
|
});
|
|
|
|
const { promptWithFallback } = await import("../pi.js");
|
|
const promptWithFallbackMock = promptWithFallback as ReturnType<typeof vi.fn>;
|
|
promptWithFallbackMock.mockReturnValueOnce(disposePromise);
|
|
|
|
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 listener of settingsListeners) {
|
|
listener({ settings: { globalPause: true }, previous: { globalPause: false } });
|
|
}
|
|
|
|
await expect(specifyPromise).resolves.toBeUndefined();
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("FN-001: failed to restore status to 'null' during pause-abort cleanup"),
|
|
);
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("logs warning when status restore fails during stuck-detector abort", async () => {
|
|
const warnSpy = vi.spyOn(planLog, "warn");
|
|
|
|
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().mockImplementation(async (_id: string, patch: Record<string, unknown>) => {
|
|
if (patch?.status === null) {
|
|
throw new Error("stuck restore failed");
|
|
}
|
|
}),
|
|
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; });
|
|
const mockDispose = vi.fn().mockImplementation(() => resolveDispose());
|
|
mockCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
state: {},
|
|
sessionManager: {},
|
|
prompt: vi.fn().mockReturnValue(disposePromise),
|
|
dispose: mockDispose,
|
|
navigateTree: vi.fn(),
|
|
},
|
|
});
|
|
|
|
const { promptWithFallback } = await import("../pi.js");
|
|
const promptWithFallbackMock = promptWithFallback as ReturnType<typeof vi.fn>;
|
|
promptWithFallbackMock.mockReturnValueOnce(disposePromise);
|
|
|
|
const task: Task = { id: "FN-002", 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));
|
|
processor.markStuckAborted("FN-002");
|
|
mockDispose();
|
|
|
|
await expect(specifyPromise).resolves.toBeUndefined();
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("FN-002: failed to restore status to 'null' during stuck-detector abort cleanup"),
|
|
);
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("logs warning when logEntry fails during rate-limit retry", async () => {
|
|
vi.useFakeTimers();
|
|
const warnSpy = vi.spyOn(planLog, "warn");
|
|
|
|
try {
|
|
const task: Task = {
|
|
id: "FN-207",
|
|
description: "Rate limit test",
|
|
column: "triage",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as Task;
|
|
|
|
const store = createMockStore({
|
|
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [], comments: [] }),
|
|
logEntry: vi.fn().mockImplementation(async (_taskId: string, message: string) => {
|
|
if (message.includes("Rate limited — retry")) {
|
|
throw new Error("log write failed");
|
|
}
|
|
}),
|
|
});
|
|
|
|
mockCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
state: {},
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
navigateTree: vi.fn(),
|
|
},
|
|
});
|
|
|
|
const { promptWithFallback } = await import("../pi.js");
|
|
const promptWithFallbackMock = promptWithFallback as ReturnType<typeof vi.fn>;
|
|
promptWithFallbackMock
|
|
.mockRejectedValueOnce(new Error("429 Too Many Requests"))
|
|
.mockResolvedValueOnce(undefined);
|
|
|
|
const processor = new TriageProcessor(store, "/test/root", {
|
|
pollIntervalMs: 100_000,
|
|
});
|
|
|
|
const specifyPromise = processor.specifyTask(task);
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
await expect(specifyPromise).resolves.toBeUndefined();
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("FN-207: failed to log rate-limit retry entry"),
|
|
);
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("logs warning when transient-error retry status update fails", async () => {
|
|
const warnSpy = vi.spyOn(planLog, "warn");
|
|
const task: Task = {
|
|
id: "FN-208",
|
|
description: "Transient retry test",
|
|
column: "triage",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as Task;
|
|
|
|
const store = createMockStore({
|
|
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
|
|
updateTask: vi.fn().mockImplementation(async (_id: string, patch: Record<string, unknown>) => {
|
|
if (patch?.recoveryRetryCount === 1) {
|
|
throw new Error("retry status update failed");
|
|
}
|
|
}),
|
|
});
|
|
|
|
mockCreateFnAgent.mockRejectedValueOnce(new Error("upstream connect error"));
|
|
|
|
const processor = new TriageProcessor(store, "/test/root", {
|
|
pollIntervalMs: 100_000,
|
|
});
|
|
|
|
await expect(processor.specifyTask(task)).resolves.toBeUndefined();
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("FN-208: failed to restore status to 'null' during transient-error retry scheduling"),
|
|
);
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
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 createFnAgent call
|
|
let capturedOnAgentTool: ((id: string, name: string) => void) | undefined;
|
|
mockCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
// Capture the onToolStart callback that was passed to createFnAgent
|
|
// 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;
|
|
mockCreateFnAgent.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;
|
|
mockCreateFnAgent.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();
|
|
mockCreateFnAgent.mockResolvedValue({
|
|
session: {
|
|
state: {},
|
|
sessionManager: {},
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
navigateTree: vi.fn(),
|
|
},
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Helper: execute triage on a task and capture createFnAgent call arguments.
|
|
*/
|
|
async function captureCreateFnAgentArgs(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;
|
|
mockCreateFnAgent.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 createFnAgent with correct projectRootDir", async () => {
|
|
const args = await captureCreateFnAgentArgs({
|
|
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 captureCreateFnAgentArgs({
|
|
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;
|
|
mockCreateFnAgent.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 captureCreateFnAgentArgs({
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ── Agent Delegation Tool Tests ──────────────────────────────────────
|
|
|
|
describe("TriageProcessor delegation tools", () => {
|
|
function createMockAgentStore() {
|
|
return {
|
|
listAgents: vi.fn().mockResolvedValue([]),
|
|
getAgent: vi.fn().mockResolvedValue(null),
|
|
};
|
|
}
|
|
|
|
function createMockStore() {
|
|
return {
|
|
listTasks: vi.fn().mockResolvedValue([]),
|
|
searchTasks: vi.fn().mockResolvedValue([]),
|
|
getTask: vi.fn().mockResolvedValue({
|
|
id: "FN-TRIAGE",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "triage",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
status: "planning",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}),
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 10000,
|
|
defaultProvider: "openai",
|
|
defaultModelId: "gpt-4o",
|
|
}),
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
|
updateTask: vi.fn().mockResolvedValue({}),
|
|
on: vi.fn(),
|
|
emit: vi.fn(),
|
|
};
|
|
}
|
|
|
|
it("createTriageTools returns fn_task_list, fn_task_search, fn_task_get, fn_task_create (no delegation tools — those are in customTools)", () => {
|
|
const store = createMockStore();
|
|
const processor = new TriageProcessor(store as any, "/tmp/root");
|
|
|
|
const tools = (processor as any).createTriageTools({
|
|
parentTaskId: "FN-TRIAGE",
|
|
allowTaskCreate: true,
|
|
createdSubtasksRef: { current: [] },
|
|
});
|
|
|
|
const toolNames = tools.map((t: any) => t.name);
|
|
expect(toolNames).toContain("fn_task_list");
|
|
expect(toolNames).toContain("fn_task_search");
|
|
expect(toolNames).toContain("fn_task_get");
|
|
expect(toolNames).toContain("fn_task_create");
|
|
// fn_list_agents and fn_delegate_task are added in customTools, not createTriageTools
|
|
expect(toolNames).not.toContain("fn_list_agents");
|
|
expect(toolNames).not.toContain("fn_delegate_task");
|
|
});
|
|
|
|
it("fn_task_search includes done tasks by default and searches with includeArchived true", async () => {
|
|
const store = createMockStore();
|
|
(store.searchTasks as any).mockResolvedValue([
|
|
{
|
|
id: "FN-100",
|
|
title: "Fix rebase merge truncation",
|
|
description: "desc",
|
|
column: "done",
|
|
dependencies: [],
|
|
},
|
|
{
|
|
id: "FN-101",
|
|
title: "Another task",
|
|
description: "desc",
|
|
column: "todo",
|
|
dependencies: ["FN-100"],
|
|
},
|
|
]);
|
|
const processor = new TriageProcessor(store as any, "/tmp/root");
|
|
const tools = (processor as any).createTriageTools({
|
|
parentTaskId: "FN-TRIAGE",
|
|
allowTaskCreate: true,
|
|
createdSubtasksRef: { current: [] },
|
|
});
|
|
|
|
const taskSearchTool = tools.find((t: any) => t.name === "fn_task_search");
|
|
const result = await taskSearchTool.execute("call-1", { query: "rebase diff" });
|
|
const text = result.content[0].text;
|
|
|
|
expect(store.searchTasks).toHaveBeenCalledWith("rebase diff", {
|
|
slim: true,
|
|
includeArchived: true,
|
|
limit: 20,
|
|
});
|
|
expect(text).toContain('Search results for "rebase diff" (2):');
|
|
expect(text).toContain("FN-100 (done): Fix rebase merge truncation");
|
|
});
|
|
|
|
it("fn_task_search filters done tasks when includeDone is false", async () => {
|
|
const store = createMockStore();
|
|
(store.searchTasks as any).mockResolvedValue([
|
|
{ id: "FN-100", title: "Done task", description: "desc", column: "done", dependencies: [] },
|
|
{ id: "FN-101", title: "Todo task", description: "desc", column: "todo", dependencies: [] },
|
|
]);
|
|
const processor = new TriageProcessor(store as any, "/tmp/root");
|
|
const tools = (processor as any).createTriageTools({
|
|
parentTaskId: "FN-TRIAGE",
|
|
allowTaskCreate: true,
|
|
createdSubtasksRef: { current: [] },
|
|
});
|
|
|
|
const taskSearchTool = tools.find((t: any) => t.name === "fn_task_search");
|
|
const result = await taskSearchTool.execute("call-1", {
|
|
query: "task",
|
|
includeDone: false,
|
|
});
|
|
const text = result.content[0].text;
|
|
|
|
expect(store.searchTasks).toHaveBeenCalled();
|
|
expect(text).toContain("FN-101 (todo): Todo task");
|
|
expect(text).not.toContain("FN-100 (done): Done task");
|
|
});
|
|
|
|
it("fn_task_search returns no-match text when search results are empty", async () => {
|
|
const store = createMockStore();
|
|
(store.searchTasks as any).mockResolvedValue([]);
|
|
const processor = new TriageProcessor(store as any, "/tmp/root");
|
|
const tools = (processor as any).createTriageTools({
|
|
parentTaskId: "FN-TRIAGE",
|
|
allowTaskCreate: true,
|
|
createdSubtasksRef: { current: [] },
|
|
});
|
|
|
|
const taskSearchTool = tools.find((t: any) => t.name === "fn_task_search");
|
|
const result = await taskSearchTool.execute("call-1", { query: "missing" });
|
|
|
|
expect(result.content[0].text).toBe("No tasks matched.");
|
|
});
|
|
|
|
it("delegation tools are accessible when agentStore is available", () => {
|
|
const mockAgentStore = createMockAgentStore();
|
|
const store = createMockStore();
|
|
const processor = new TriageProcessor(store as any, "/tmp/root", {
|
|
agentStore: mockAgentStore as any,
|
|
});
|
|
|
|
// Verify agentStore is injected into processor options
|
|
expect((processor as any).options.agentStore).toBe(mockAgentStore);
|
|
});
|
|
});
|
|
|
|
// ── FN-4774 Regression: Duplicate detection over done/archived tasks (recovered under FN-4827; supersedes FN-4815) ───
|
|
|
|
describe("FN-4774 regression: triage duplicate detection over done/archived tasks", () => {
|
|
function createMockStore() {
|
|
return {
|
|
listTasks: vi.fn().mockResolvedValue([]),
|
|
searchTasks: vi.fn().mockResolvedValue([]),
|
|
getTask: vi.fn().mockResolvedValue({
|
|
id: "FN-TRIAGE",
|
|
title: "Test",
|
|
description: "Test task",
|
|
column: "triage",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
status: "planning",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}),
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 10000,
|
|
defaultProvider: "openai",
|
|
defaultModelId: "gpt-4o",
|
|
}),
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
|
updateTask: vi.fn().mockResolvedValue({}),
|
|
on: vi.fn(),
|
|
emit: vi.fn(),
|
|
};
|
|
}
|
|
|
|
// Regression: FN-4774 (FN-4827 recovery; supersedes FN-4815) — see docs/triage-duplicate-detection-postmortem.md
|
|
it("fn_task_search tool is registered with includeDone and includeArchived parameters", () => {
|
|
const store = createMockStore();
|
|
const processor = new TriageProcessor(store as any, "/tmp/root");
|
|
|
|
const tools = (processor as any).createTriageTools({
|
|
parentTaskId: "FN-TRIAGE",
|
|
allowTaskCreate: true,
|
|
createdSubtasksRef: { current: [] },
|
|
});
|
|
|
|
const taskSearchTool = tools.find((t: any) => t.name === "fn_task_search");
|
|
expect(taskSearchTool).toBeDefined();
|
|
expect(taskSearchTool.name).toBe("fn_task_search");
|
|
|
|
// Verify includeDone and includeArchived are present in the parameter schema
|
|
const props = taskSearchTool.parameters.properties;
|
|
expect(props).toHaveProperty("includeDone");
|
|
expect(props).toHaveProperty("includeArchived");
|
|
});
|
|
|
|
// Regression: FN-4774 (FN-4827 recovery; supersedes FN-4815) — see docs/triage-duplicate-detection-postmortem.md
|
|
it("canonical triage policy prompt guides agents to search done/archived before creating", () => {
|
|
// Standard prompt mentions fn_task_search in duplicate-check guidance
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("fn_task_search");
|
|
// The tool bullet list explicitly states it covers done and archived
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("including done and archived tasks");
|
|
// Duplicate-check section co-locates fn_task_search with done/archived references
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("done");
|
|
expect(TRIAGE_POLICY_PROMPT).toContain("archived");
|
|
// Defensive regex: duplicate-check guidance must cross-reference fn_task_search with done/archived
|
|
expect(
|
|
/Duplicate check[\s\S]{0,600}fn_task_search[\s\S]{0,400}(done|archived)/i.test(
|
|
TRIAGE_POLICY_PROMPT,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
// Regression: FN-4774 (FN-4827 recovery; supersedes FN-4815) — see docs/triage-duplicate-detection-postmortem.md
|
|
it("FAST_TRIAGE_SYSTEM_PROMPT guides agents to search done/archived before creating", () => {
|
|
// Fast prompt mentions fn_task_search
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("fn_task_search");
|
|
// Duplicate-check section references done and archived
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("done");
|
|
expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("archived");
|
|
// Defensive regex: duplicate-check guidance must cross-reference fn_task_search with done/archived
|
|
expect(
|
|
/Duplicate check[\s\S]{0,600}fn_task_search[\s\S]{0,400}(done|archived)/i.test(
|
|
FAST_TRIAGE_SYSTEM_PROMPT,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
// Regression: FN-4774 (FN-4827 recovery; supersedes FN-4815) — see docs/triage-duplicate-detection-postmortem.md
|
|
it("fn_task_search returns done-column results by default", async () => {
|
|
const store = createMockStore();
|
|
(store.searchTasks as any).mockResolvedValue([
|
|
{
|
|
id: "FN-DONE",
|
|
title: "Fix rebase truncation bug",
|
|
description: "desc",
|
|
column: "done",
|
|
dependencies: [],
|
|
},
|
|
]);
|
|
const processor = new TriageProcessor(store as any, "/tmp/root");
|
|
const tools = (processor as any).createTriageTools({
|
|
parentTaskId: "FN-TRIAGE",
|
|
allowTaskCreate: true,
|
|
createdSubtasksRef: { current: [] },
|
|
});
|
|
|
|
const taskSearchTool = tools.find((t: any) => t.name === "fn_task_search");
|
|
const result = await taskSearchTool.execute("call-1", {
|
|
query: "rebase truncation",
|
|
});
|
|
const text = result.content[0].text;
|
|
|
|
// searchTasks is called with includeArchived: true (includeDone is a post-filter, not passed to searchTasks)
|
|
expect(store.searchTasks).toHaveBeenCalledWith("rebase truncation", {
|
|
slim: true,
|
|
includeArchived: true,
|
|
limit: 20,
|
|
});
|
|
// Done results surface in output with the (done): column label
|
|
expect(text).toContain("FN-DONE");
|
|
expect(text).toContain("(done):");
|
|
});
|
|
});
|