Files
fusion/packages/engine/src/__tests__/triage.test.ts
gsxdsm 1a716f2f95 FN-6232: centralize triage planning prompt resolution
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
2026-06-11 09:21:23 -07:00

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):");
});
});