test(FN-4657): complete Step 3 — add refinement routing regressions
Fusion-Task-Id: FN-4657 Fusion-Task-Lineage: 2bab2850-5616-4f2e-b512-906daa12debf
This commit is contained in:
190
packages/engine/src/__tests__/triage-refinement-routing.test.ts
Normal file
190
packages/engine/src/__tests__/triage-refinement-routing.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { TriageProcessor } from "../triage.js";
|
||||
|
||||
function withStoreEvents<T extends Record<string, unknown>>(store: T): T & { on: () => void; off: () => void } {
|
||||
return {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
...store,
|
||||
};
|
||||
}
|
||||
|
||||
function createTriageTask(overrides: Partial<Task> & Pick<Task, "id">): Task {
|
||||
const now = "2026-05-15T12:00:00.000Z";
|
||||
return {
|
||||
id: overrides.id,
|
||||
title: overrides.title ?? overrides.id,
|
||||
description: overrides.description ?? overrides.id,
|
||||
priority: overrides.priority ?? "normal",
|
||||
column: "triage",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
dependencies: [],
|
||||
createdAt: overrides.createdAt ?? now,
|
||||
updatedAt: overrides.updatedAt ?? now,
|
||||
columnMovedAt: overrides.columnMovedAt ?? now,
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
describe("refinement routing from triage", () => {
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function createRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "fusion-triage-refine-"));
|
||||
roots.push(root);
|
||||
await mkdir(join(root, ".fusion", "tasks"), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
it("promotes a refinement to todo within bounded polls under same-priority backlog", async () => {
|
||||
const rootDir = await createRoot();
|
||||
const refinement = createTriageTask({
|
||||
id: "FN-R1",
|
||||
sourceType: "task_refine",
|
||||
sourceParentTaskId: "FN-123",
|
||||
createdAt: "2026-05-15T12:00:00.000Z",
|
||||
});
|
||||
|
||||
const tasks: Task[] = [
|
||||
refinement,
|
||||
...Array.from({ length: 8 }, (_, i) => createTriageTask({
|
||||
id: `FN-B${i + 1}`,
|
||||
createdAt: `2026-05-15T11:${String(10 + i).padStart(2, "0")}:00.000Z`,
|
||||
})),
|
||||
];
|
||||
|
||||
const store: any = withStoreEvents({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxTriageConcurrent: 2,
|
||||
pollIntervalMs: 10_000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
}),
|
||||
listTasks: vi.fn().mockImplementation(async () => tasks.map((t) => ({ ...t }))),
|
||||
});
|
||||
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const specifySpy = vi.spyOn(processor, "specifyTask").mockImplementation(async (task) => {
|
||||
const idx = tasks.findIndex((t) => t.id === task.id);
|
||||
if (idx >= 0) tasks[idx] = { ...tasks[idx], column: "todo" };
|
||||
});
|
||||
|
||||
(processor as any).running = true;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await (processor as any).poll();
|
||||
if (tasks.find((t) => t.id === refinement.id)?.column === "todo") break;
|
||||
}
|
||||
|
||||
expect(tasks.find((t) => t.id === refinement.id)?.column).toBe("todo");
|
||||
expect(specifySpy.mock.calls.some(([task]) => task.id === refinement.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves approval gate for refinements when plan approval is required", async () => {
|
||||
const rootDir = await createRoot();
|
||||
const promptPath = join(rootDir, ".fusion", "tasks", "FN-R2", "PROMPT.md");
|
||||
await mkdir(join(rootDir, ".fusion", "tasks", "FN-R2"), { recursive: true });
|
||||
await writeFile(promptPath, "# FN-R2\n\n## File Scope\n- packages/engine/src/triage.ts\n");
|
||||
|
||||
const updates: Array<Record<string, unknown>> = [];
|
||||
const moves: string[] = [];
|
||||
const store: any = withStoreEvents({
|
||||
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
updateTask: vi.fn().mockImplementation(async (_id: string, update: Record<string, unknown>) => {
|
||||
updates.push(update);
|
||||
}),
|
||||
moveTask: vi.fn().mockImplementation(async (_id: string, to: string) => {
|
||||
moves.push(to);
|
||||
}),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const task = createTriageTask({ id: "FN-R2", sourceType: "task_refine", sourceParentTaskId: "FN-001" });
|
||||
|
||||
await (processor as any).finalizeApprovedTask(
|
||||
task,
|
||||
"# FN-R2\n\n## File Scope\n- packages/engine/src/triage.ts\n",
|
||||
{ requirePlanApproval: true },
|
||||
);
|
||||
|
||||
expect(updates.some((u) => u.status === "awaiting-approval")).toBe(true);
|
||||
expect(moves).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps spec prompt present before move-to-todo on refinement finalize", async () => {
|
||||
const rootDir = await createRoot();
|
||||
const taskId = "FN-R3";
|
||||
const taskDir = join(rootDir, ".fusion", "tasks", taskId);
|
||||
const promptPath = join(taskDir, "PROMPT.md");
|
||||
await mkdir(taskDir, { recursive: true });
|
||||
await writeFile(promptPath, "# FN-R3\n\n## File Scope\n- packages/engine/src/triage.ts\n");
|
||||
|
||||
const store: any = withStoreEvents({
|
||||
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
moveTask: vi.fn().mockImplementation(async () => {
|
||||
const prompt = await readFile(promptPath, "utf8");
|
||||
expect(prompt.trim().length).toBeGreaterThan(0);
|
||||
expect(prompt).toContain("## File Scope");
|
||||
}),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const task = createTriageTask({ id: taskId, sourceType: "task_refine", sourceParentTaskId: "FN-002" });
|
||||
|
||||
await (processor as any).finalizeApprovedTask(
|
||||
task,
|
||||
"# FN-R3\n\n## File Scope\n- packages/engine/src/triage.ts\n",
|
||||
{ requirePlanApproval: false },
|
||||
);
|
||||
|
||||
expect(store.moveTask).toHaveBeenCalledWith(taskId, "todo");
|
||||
});
|
||||
|
||||
it("retains baseline ordering for non-refinement triage tasks", async () => {
|
||||
const rootDir = await createRoot();
|
||||
const tasks: Task[] = [
|
||||
createTriageTask({ id: "FN-101", priority: "urgent", createdAt: "2026-05-15T10:00:00.000Z" }),
|
||||
createTriageTask({ id: "FN-102", priority: "high", createdAt: "2026-05-15T10:02:00.000Z" }),
|
||||
createTriageTask({ id: "FN-103", priority: "high", createdAt: "2026-05-15T10:01:00.000Z" }),
|
||||
createTriageTask({ id: "FN-100", priority: "normal", createdAt: "2026-05-15T09:00:00.000Z" }),
|
||||
];
|
||||
|
||||
const store: any = withStoreEvents({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 10,
|
||||
maxTriageConcurrent: 10,
|
||||
pollIntervalMs: 10_000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
}),
|
||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||
});
|
||||
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const specifySpy = vi.spyOn(processor, "specifyTask").mockResolvedValue(undefined);
|
||||
|
||||
(processor as any).running = true;
|
||||
await (processor as any).poll();
|
||||
|
||||
expect(specifySpy.mock.calls.map(([task]) => task.id)).toEqual([
|
||||
"FN-101",
|
||||
"FN-103",
|
||||
"FN-102",
|
||||
"FN-100",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user