Files
fusion/packages/dashboard/src/__tests__/routes-tasks.test.ts
Fusion 287ebaf102 feat(FN-4053): unify task ID allocation with store-owned distributed alloca
Unifies task ID allocation under a single store-owned distributed allocator, removing redundant ID-generation logic from dashboard route handlers and simplifying the overall flow; Step 1 merges the allocation authority into the store, Step 3 removes the now-unnecessary mixed-path routing, and tests

Fusion-Task-Id: FN-4053
2026-05-11 21:48:26 -07:00

2391 lines
81 KiB
TypeScript

// @vitest-environment node
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from "vitest";
import express from "express";
import http from "node:http";
import { EventEmitter } from "node:events";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { createHmac } from "node:crypto";
import { createApiRoutes } from "../routes.js";
import {
getProjectIdFromRequest as getProjectIdFromRouteRequest,
getProjectContext as resolveRouteProjectContext,
getScopedStore as resolveRouteScopedStore,
} from "../routes/context.js";
import { GitHubClient } from "../github.js";
import * as resolveDiffBaseModule from "../routes/resolve-diff-base.js";
import { githubRateLimiter } from "../github-poll.js";
import type { TaskStore, TaskAttachment, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult, ChatSession, ChatMessage } from "@fusion/core";
import type { TaskDetail } from "@fusion/core";
import type { AuthStorageLike, ModelRegistryLike } from "../routes.js";
import { __resetBatchImportRateLimiter, __setCreateFnAgentForRefine } from "../routes.js";
import * as agentGenerationModule from "../agent-generation.js";
import { __resetPlanningState, __setCreateFnAgent, planningStreamManager } from "../planning.js";
import * as planningModule from "../planning.js";
import { __resetSubtaskBreakdownState, subtaskStreamManager } from "../subtask-breakdown.js";
import * as subtaskBreakdownModule from "../subtask-breakdown.js";
import { SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "../ai-session-store.js";
import * as usageModule from "../usage.js";
import * as claudeCliProbeModule from "../claude-cli-probe.js";
import * as droidCliProbeModule from "../droid-cli-probe.js";
import * as projectStoreResolver from "../project-store-resolver.js";
import * as terminalServiceModule from "../terminal-service.js";
import { get as performGet, request as performRequest } from "../test-request.js";
import { resetRuntimeLogSink, setRuntimeLogSink } from "../runtime-logger.js";
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.js";
import * as updateCheckModule from "../update-check.js";
import { __setAgentReflectionServiceForTests } from "../routes/register-agent-reflection-rating-routes.js";
// Mock @fusion/core for gh CLI auth checks
const mockCentralListProjects = vi.fn().mockResolvedValue([]);
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
const mockCentralClose = vi.fn().mockResolvedValue(undefined);
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
const mockCentralGetLocalNode = vi.fn().mockResolvedValue({ id: "node-local" });
const mockCentralListNodes = vi.fn().mockResolvedValue([]);
const { mockPerformUpdateCheck, mockClearUpdateCheckCache, mockExecSync, mockExecFile } = vi.hoisted(() => ({
mockPerformUpdateCheck: vi.fn(),
mockClearUpdateCheckCache: vi.fn(),
mockExecSync: vi.fn(),
mockExecFile: vi.fn(),
}));
vi.mock("../update-check.js", async () => {
const actual = await vi.importActual<typeof import("../update-check.js")>("../update-check.js");
return {
...actual,
performUpdateCheck: mockPerformUpdateCheck,
clearUpdateCheckCache: mockClearUpdateCheckCache,
};
});
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
mockExecSync.mockImplementation(((...args: Parameters<typeof actual.execSync>) => actual.execSync(...args)) as typeof actual.execSync);
// Default execFile mock blocks host-process pgrep calls used by /kill-vitest
// but passes through all other commands (including git) to preserve route
// behavior for integration-style API tests in this file.
mockExecFile.mockImplementation((...callArgs: unknown[]) => {
const [file, argsOrCb, maybeOptions, maybeCb] = callArgs as [string, unknown, unknown, unknown];
const args = Array.isArray(argsOrCb) ? argsOrCb : [];
const cb =
typeof maybeCb === "function"
? (maybeCb as (err: unknown, stdout?: string, stderr?: string) => void)
: typeof maybeOptions === "function"
? (maybeOptions as (err: unknown, stdout?: string, stderr?: string) => void)
: typeof argsOrCb === "function"
? (argsOrCb as (err: unknown, stdout?: string, stderr?: string) => void)
: null;
if (file === "pgrep" && args[0] === "-f" && args[1] === "vitest") {
if (cb) queueMicrotask(() => cb(null, "", ""));
return;
}
return (actual.execFile as (...innerArgs: unknown[]) => unknown)(...callArgs);
});
return {
...actual,
execSync: mockExecSync,
execFile: mockExecFile,
};
});
vi.mock("@fusion/core", async (importOriginal) => {
const { createCoreMock } = await import("../test/mockCoreEngine.js");
return createCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
resolveGlobalDir: vi.fn().mockReturnValue("/tmp/fusion-test"),
isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(),
isQmdAvailable: vi.fn().mockResolvedValue(false),
CentralCore: vi.fn().mockImplementation(() => ({
init: mockCentralInit,
close: mockCentralClose,
listProjects: mockCentralListProjects,
reconcileProjectStatuses: mockCentralReconcileProjectStatuses,
getLocalNode: mockCentralGetLocalNode,
listNodes: mockCentralListNodes,
})),
});
});
vi.mock("@fusion/engine", async () => {
const { createEngineMock } = await import("../test/mockCoreEngine.js");
return createEngineMock({
createFnAgent: vi.fn(async (options?: { onText?: (delta: string) => void }) => ({
session: {
state: {
messages: [] as Array<{ role: string; content: string }>,
},
prompt: vi.fn(async function (this: { state?: { messages?: Array<{ role: string; content: string }> } }, message: string) {
options?.onText?.("mock-ai-output");
const messages = this.state?.messages ?? [];
messages.push({ role: "user", content: message });
messages.push({
role: "assistant",
content: JSON.stringify({
subtasks: [
{
id: "subtask-1",
title: "Mock subtask",
description: "Generated by the route test engine mock",
suggestedSize: "S",
dependsOn: [],
},
],
}),
});
}),
dispose: vi.fn(),
},
})),
promptWithFallback: vi.fn(async (session: { prompt: (message: string) => Promise<void> }, prompt: string) => {
await session.prompt(prompt);
}),
AgentReflectionService: class MockAgentReflectionService {
async generateReflection(): Promise<import("@fusion/core").AgentReflection | null> {
throw new Error("Reflection service unavailable in route tests");
}
async buildReflectionContext(): Promise<never> {
throw new Error("Reflection service unavailable in route tests");
}
},
});
});
import { AgentStore, Database, RoutineStore, isGhAvailable, isGhAuthenticated } from "@fusion/core";
import { createFnAgent } from "@fusion/engine";
const mockIsGhAvailable = vi.mocked(isGhAvailable);
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
function createMockGlobalSettingsStore() {
return {
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn().mockResolvedValue({}),
getSettingsPath: vi.fn().mockReturnValue("/fake/home/.fusion/settings.json"),
init: vi.fn().mockResolvedValue(false),
invalidateCache: vi.fn(),
};
}
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
searchTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
createTaskWithReservedId: undefined,
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
getSettingsFast: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
getAgentLogCount: vi.fn().mockResolvedValue(0),
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
addTaskComment: vi.fn(),
updateTaskComment: vi.fn(),
deleteTaskComment: vi.fn(),
getTaskDocuments: vi.fn().mockResolvedValue([]),
getTaskDocument: vi.fn().mockResolvedValue(null),
getTaskDocumentRevisions: vi.fn().mockResolvedValue([]),
getAllDocuments: vi.fn().mockResolvedValue([]),
upsertTaskDocument: vi.fn(),
deleteTaskDocument: vi.fn().mockResolvedValue(undefined),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
linkGithubIssue: vi.fn().mockResolvedValue(undefined),
recordActivity: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getDistributedTaskIdAllocator: vi.fn().mockReturnValue({
reserveDistributedTaskId: vi.fn().mockResolvedValue({ reservationId: "res-1", taskId: "FN-7001" }),
commitDistributedTaskIdReservation: vi.fn().mockResolvedValue({}),
abortDistributedTaskIdReservation: vi.fn().mockResolvedValue({}),
}),
listWorkflowSteps: vi.fn().mockResolvedValue([]),
createWorkflowStep: vi.fn(),
getWorkflowStep: vi.fn(),
updateWorkflowStep: vi.fn(),
deleteWorkflowStep: vi.fn(),
getMissionStore: vi.fn().mockReturnValue({
listMissions: vi.fn().mockReturnValue([]),
createMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
updateMission: vi.fn(),
getMission: vi.fn(),
deleteMission: vi.fn(),
listMilestonesByMission: vi.fn().mockReturnValue([]),
createMilestone: vi.fn(),
updateMilestone: vi.fn(),
getMilestone: vi.fn(),
deleteMilestone: vi.fn(),
listTasksByMilestone: vi.fn().mockReturnValue([]),
createMissionTask: vi.fn(),
updateMissionTask: vi.fn(),
getMissionTask: vi.fn(),
deleteMissionTask: vi.fn(),
}),
...overrides,
} as unknown as TaskStore;
}
const TASK_TOKEN_USAGE_FIXTURE = {
inputTokens: 1200,
outputTokens: 450,
cachedTokens: 210,
totalTokens: 1860,
firstUsedAt: "2026-04-24T09:00:00.000Z",
lastUsedAt: "2026-04-24T10:15:00.000Z",
};
const FAKE_TASK_DETAIL: TaskDetail = {
id: "FN-001",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
tokenUsage: TASK_TOKEN_USAGE_FIXTURE,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# KB-001\n\nTest task",
};
async function GET(app: express.Express, path: string): Promise<{ status: number; body: any }> {
const res = await performGet(app, path);
return { status: res.status, body: res.body };
}
async function REQUEST(
app: express.Express,
method: string,
path: string,
body?: Buffer | string,
headers?: Record<string, string>,
): Promise<{ status: number; body: any }> {
const res = await performRequest(app, method, path, body, headers);
return { status: res.status, body: res.body };
}
function collectOrderedRouteKeys(router: express.Router): string[] {
const stack = (router as unknown as {
stack?: Array<{ route?: { path?: string; methods?: Record<string, boolean> } }>;
}).stack ?? [];
const orderedKeys: string[] = [];
for (const layer of stack) {
const route = layer.route;
if (!route?.path || !route.methods) continue;
const method = Object.keys(route.methods).find((name) => route.methods?.[name]);
if (!method) continue;
orderedKeys.push(`${method.toUpperCase()} ${route.path}`);
}
return orderedKeys;
}
afterEach(() => {
resetDiagnosticsSink();
});
describe("GET /tasks", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns tasks with optional pagination params", async () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_TASK_DETAIL]);
const res = await GET(buildApp(), "/api/tasks?limit=10&offset=5");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(store.listTasks).toHaveBeenCalledWith({ limit: 10, offset: 5, slim: true, includeArchived: false });
});
it("returns tasks for search query", async () => {
(store.searchTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_TASK_DETAIL]);
const res = await GET(buildApp(), "/api/tasks?q=FN-001");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(store.searchTasks).toHaveBeenCalledWith("FN-001", {
limit: undefined,
offset: undefined,
slim: true,
includeArchived: false,
});
});
it("returns tasks for search query with limit", async () => {
(store.searchTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_TASK_DETAIL]);
const res = await GET(buildApp(), "/api/tasks?q=something&limit=5");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(store.searchTasks).toHaveBeenCalledWith("something", {
limit: 5,
offset: undefined,
slim: true,
includeArchived: false,
});
});
it("returns empty array for non-existent search query", async () => {
(store.searchTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
const res = await GET(buildApp(), "/api/tasks?q=nonexistent");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("falls back to listTasks for empty search query", async () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_TASK_DETAIL]);
const res = await GET(buildApp(), "/api/tasks?q=");
expect(res.status).toBe(200);
expect(store.listTasks).toHaveBeenCalled();
expect(store.searchTasks).not.toHaveBeenCalled();
});
it("returns 400 for invalid pagination params", async () => {
const res = await GET(buildApp(), "/api/tasks?limit=-1");
expect(res.status).toBe(400);
expect(res.body.error).toContain("limit");
});
});
describe("Standardized error responses", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 400 validation errors as { error } without success field", async () => {
const res = await GET(buildApp(), "/api/tasks?limit=-1");
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: expect.stringContaining("limit") });
expect(res.body).not.toHaveProperty("success");
});
it("returns 404 not-found errors as { error }", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValueOnce(Object.assign(new Error("Task NOPE not found"), { code: "ENOENT" }));
const res = await GET(buildApp(), "/api/tasks/NOPE");
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: expect.stringContaining("not found") });
});
it("returns 500 errors as { error } and logs to the runtime logger", async () => {
const runtimeEvents: Array<{ level: string; scope: string; message: string; context?: Record<string, unknown> }> = [];
setRuntimeLogSink((level, scope, message, context) => {
runtimeEvents.push({ level, scope, message, context });
});
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Config read failed"));
try {
const res = await GET(buildApp(), "/api/settings");
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: "Config read failed" });
expect(runtimeEvents).toContainEqual(
expect.objectContaining({
level: "error",
scope: "api:error",
message: "Request failed",
context: expect.objectContaining({
method: "GET",
path: "/api/settings",
statusCode: 500,
message: "Config read failed",
}),
}),
);
} finally {
resetRuntimeLogSink();
}
});
});
describe("GET /projects", () => {
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(createMockStore()));
return app;
}
beforeEach(() => {
mockCentralListProjects.mockReset().mockResolvedValue([]);
mockCentralInit.mockReset().mockResolvedValue(undefined);
mockCentralClose.mockReset().mockResolvedValue(undefined);
});
it("prioritizes the project for the current working directory", async () => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/workspace/current-project");
mockCentralListProjects.mockResolvedValueOnce([
{
id: "proj_other",
name: "Other Project",
path: "/workspace/other-project",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "proj_current",
name: "Current Project",
path: "/workspace/current-project",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
]);
const res = await GET(buildApp(), "/api/projects");
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect((res.body as Array<{ id: string }>).map((project) => project.id)).toEqual([
"proj_current",
"proj_other",
]);
cwdSpy.mockRestore();
});
it("prefers the deepest matching ancestor when cwd is nested inside a project", async () => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/workspace/current-project/packages/dashboard");
mockCentralListProjects.mockResolvedValueOnce([
{
id: "proj_parent",
name: "Parent",
path: "/workspace",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "proj_current",
name: "Current Project",
path: "/workspace/current-project",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "proj_other",
name: "Other Project",
path: "/workspace/other-project",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
]);
const res = await GET(buildApp(), "/api/projects");
expect(res.status).toBe(200);
expect((res.body as Array<{ id: string }>).map((project) => project.id)).toEqual([
"proj_current",
"proj_parent",
"proj_other",
]);
cwdSpy.mockRestore();
});
});
describe("GET /tasks/:id", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns task detail on success", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
const res = await GET(buildApp(), "/api/tasks/KB-001");
expect(res.status).toBe(200);
expect(res.body.id).toBe("FN-001");
expect(res.body.prompt).toBe("# KB-001\n\nTest task");
});
it("returns tokenUsage unchanged when task detail includes usage totals", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
const res = await GET(buildApp(), "/api/tasks/KB-001");
expect(res.status).toBe(200);
expect(res.body.tokenUsage).toEqual({
inputTokens: 1200,
outputTokens: 450,
cachedTokens: 210,
totalTokens: 1860,
firstUsedAt: "2026-04-24T09:00:00.000Z",
lastUsedAt: "2026-04-24T10:15:00.000Z",
});
});
it("leaves tokenUsage undefined when no task usage has been recorded", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
tokenUsage: undefined,
});
const res = await GET(buildApp(), "/api/tasks/KB-001");
expect(res.status).toBe(200);
expect(res.body.tokenUsage).toBeUndefined();
});
it("caps task detail activity logs to keep the modal payload bounded", async () => {
const log = Array.from({ length: 510 }, (_, index) => ({
timestamp: `2026-01-01T00:${String(index % 60).padStart(2, "0")}:00.000Z`,
action: `entry-${index}`,
}));
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
log,
});
const res = await GET(buildApp(), "/api/tasks/KB-001");
expect(res.status).toBe(200);
expect(res.body.log).toHaveLength(500);
expect(res.body.log[0].action).toBe("entry-10");
expect(res.body.log[499].action).toBe("entry-509");
expect(res.body.activityLogTotal).toBe(510);
expect(res.body.activityLogTruncatedCount).toBe(10);
});
it("returns 404 when task genuinely does not exist (ENOENT)", async () => {
const err: NodeJS.ErrnoException = new Error("ENOENT: no such file or directory");
err.code = "ENOENT";
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(err);
const res = await GET(buildApp(), "/api/tasks/KB-999");
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("returns 500 on transient/unexpected errors (non-ENOENT)", async () => {
const err = new Error("Unexpected end of JSON input");
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(err);
const res = await GET(buildApp(), "/api/tasks/KB-001");
expect(res.status).toBe(500);
expect(res.body.error).toContain("Unexpected end of JSON input");
});
});
describe("POST /tasks", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("creates a task and forwards breakIntoSubtasks", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
breakIntoSubtasks: true,
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Big initiative",
breakIntoSubtasks: true,
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: undefined,
description: "Big initiative",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: true,
summarize: false,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: undefined },
}),
);
});
it("attempts tracking issue creation for explicit task-level override when defaults are unset", async () => {
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({
owner: "task",
repo: "repo",
number: 42,
htmlUrl: "https://github.com/task/repo/issues/42",
createdAt: "2026-01-01T00:00:00.000Z",
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "tok" });
const linkGithubIssue = store.linkGithubIssue as ReturnType<typeof vi.fn>;
const recordActivity = store.recordActivity as ReturnType<typeof vi.fn>;
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
githubTracking: { enabled: true, repoOverride: "task/repo" },
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Track this task",
githubTracking: { enabled: true, repoOverride: "task/repo" },
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(createIssueSpy).toHaveBeenCalledWith(expect.objectContaining({ owner: "task", repo: "repo" }));
expect(linkGithubIssue).toHaveBeenCalledWith("FN-001", expect.objectContaining({ owner: "task", repo: "repo", number: 42 }));
expect(recordActivity).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ type: "github-issue-created" }) }));
createIssueSpy.mockRestore();
});
it("uses store.createTask for local task creation", async () => {
const createTask = vi.fn().mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "FN-7001",
column: "triage",
createdAt: "2026-05-05T00:00:00.000Z",
updatedAt: "2026-05-05T00:00:00.000Z",
nodeId: "node-target",
});
const storeWithCreate = createMockStore({ createTask });
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(storeWithCreate));
const res = await REQUEST(
app,
"POST",
"/api/tasks",
JSON.stringify({ description: "Big initiative", nodeId: "node-target" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(createTask).toHaveBeenCalledWith(
expect.objectContaining({ description: "Big initiative", nodeId: "node-target" }),
expect.objectContaining({ settings: { autoSummarizeTitles: undefined } }),
);
expect((storeWithCreate.getDistributedTaskIdAllocator as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
});
it("returns 400 when nodeId is not a string", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({ description: "Task", nodeId: 123 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("nodeId must be a string");
});
it("returns 500 when store.createTask throws", async () => {
const createTask = vi.fn().mockRejectedValue(new Error("Task ID already exists: FN-7001"));
const storeWithCreate = createMockStore({ createTask });
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(storeWithCreate));
const res = await REQUEST(
app,
"POST",
"/api/tasks",
JSON.stringify({ description: "Big initiative" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect(res.body.error).toContain("Task ID already exists: FN-7001");
});
it("forwards branch and baseBranch on create", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
branch: "fusion/fn-branch",
baseBranch: "main",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Task with branch fields",
branch: " fusion/fn-branch ",
baseBranch: " main ",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
branch: "fusion/fn-branch",
baseBranch: "main",
}),
expect.any(Object),
);
});
it("returns 400 when create branch payload is not a string", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({ description: "Task", branch: 10 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("branch must be a string");
expect(store.createTask).not.toHaveBeenCalled();
});
it("forwards model overrides when both provider and id are supplied", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Use explicit models",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: undefined,
description: "Use explicit models",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: undefined,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
summarize: false,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: undefined },
}),
);
});
it("normalizes partial model overrides back to defaults", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Ignore partial model selection",
modelProvider: "anthropic",
validatorModelId: "gpt-4o",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: undefined,
description: "Ignore partial model selection",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: undefined,
modelProvider: undefined,
modelId: undefined,
validatorModelProvider: undefined,
validatorModelId: undefined,
summarize: false,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: undefined },
}),
);
});
it("returns 400 when model fields are not strings", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Invalid model payload",
modelProvider: ["anthropic"],
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("modelProvider must be a string");
expect(store.createTask).not.toHaveBeenCalled();
});
it("returns 400 when description is missing", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({ breakIntoSubtasks: true }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("description is required");
expect(store.createTask).not.toHaveBeenCalled();
});
it("returns 400 when breakIntoSubtasks is not a boolean", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({ description: "Big initiative", breakIntoSubtasks: "yes" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("breakIntoSubtasks must be a boolean");
expect(store.createTask).not.toHaveBeenCalled();
});
it("forwards thinkingLevel when provided", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
thinkingLevel: "high",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Deep reasoning task",
thinkingLevel: "high",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
description: "Deep reasoning task",
thinkingLevel: "high",
}),
expect.objectContaining({
settings: { autoSummarizeTitles: undefined },
}),
);
});
it("returns 400 for invalid thinkingLevel value", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Bad thinking level",
thinkingLevel: "ultra",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("thinkingLevel must be one of");
expect(store.createTask).not.toHaveBeenCalled();
});
it("forwards reviewLevel when provided", async () => {
const createdTask = { ...FAKE_TASK_DETAIL, column: "triage" };
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({ description: "Test task", reviewLevel: 2 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({ reviewLevel: 2 }),
expect.any(Object),
);
});
it("accepts reviewLevel 0 (None) via POST", async () => {
const createdTask = { ...FAKE_TASK_DETAIL, column: "triage", reviewLevel: 0 };
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({ description: "Test task", reviewLevel: 0 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({ reviewLevel: 0 }),
expect.any(Object),
);
});
it("returns 400 for invalid reviewLevel value via POST", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({ description: "Test task", reviewLevel: 5 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("reviewLevel must be an integer between 0 and 3");
expect(store.createTask).not.toHaveBeenCalled();
});
it("returns 400 for non-integer reviewLevel via POST", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({ description: "Test task", reviewLevel: 1.5 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("reviewLevel must be an integer between 0 and 3");
expect(store.createTask).not.toHaveBeenCalled();
});
it("forwards priority when provided", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
priority: "high" as const,
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Priority task",
priority: "high",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
description: "Priority task",
priority: "high",
}),
expect.any(Object),
);
});
it("returns 400 for invalid priority value", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Bad priority",
priority: "medium",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("priority must be one of");
expect(store.createTask).not.toHaveBeenCalled();
});
it("forwards executionMode when provided with 'fast'", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
executionMode: "fast",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Fast task",
executionMode: "fast",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
description: "Fast task",
executionMode: "fast",
}),
expect.any(Object),
);
});
it("forwards executionMode when provided with 'standard'", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
executionMode: "standard",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Standard task",
executionMode: "standard",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
description: "Standard task",
executionMode: "standard",
}),
expect.any(Object),
);
});
it("returns 400 for invalid executionMode value", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Bad execution mode",
executionMode: "turbo",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("executionMode must be one of");
expect(store.createTask).not.toHaveBeenCalled();
});
it("forwards planningModelProvider and planningModelId when provided", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Use planning model",
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
description: "Use planning model",
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
}),
expect.objectContaining({
settings: { autoSummarizeTitles: undefined },
}),
);
});
it("passes onSummarize callback when autoSummarizeTitles is enabled", async () => {
// Mock getSettingsFast to return autoSummarizeTitles: true
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValue({ autoSummarizeTitles: true });
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "x".repeat(300), // Long description > 200 chars
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
// Verify onSummarize callback is passed - the route should pass it when autoSummarizeTitles is enabled
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: undefined,
description: "x".repeat(300),
summarize: false,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: true },
onSummarize: expect.any(Function),
}),
);
});
it("does not create onSummarize callback when autoSummarizeTitles is disabled", async () => {
// Mock getSettingsFast to return autoSummarizeTitles: false (default)
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValue({ autoSummarizeTitles: false });
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "x".repeat(300), // Long description > 200 chars
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: undefined,
description: "x".repeat(300),
summarize: false,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: false },
onSummarize: undefined,
}),
);
});
it("passes onSummarize callback when summarize flag is explicitly true", async () => {
// Mock getSettingsFast to return autoSummarizeTitles: false
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValue({ autoSummarizeTitles: false });
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "x".repeat(300),
summarize: true,
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
// Verify onSummarize callback is passed - explicit summarize flag should trigger it even when auto is off
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
summarize: true,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: false },
onSummarize: expect.any(Function),
}),
);
});
it("forwards summarize field when provided in request body", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
summarize: true,
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Test task",
summarize: true,
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
summarize: true,
}),
expect.any(Object),
);
});
// ── Lane Precedence Regression Tests for onSummarize Callback ─────────────────
// Tests for FN-1730: ensure onSummarize callback resolves models following the hierarchy:
// 1. Project titleSummarizerProvider + titleSummarizerModelId (project lane)
// 2. Global titleSummarizerGlobalProvider + titleSummarizerGlobalModelId (global lane)
// 3. Default defaultProvider + defaultModelId (default fallback)
//
// Note: These tests verify that the route passes the correct settings to createTask
// and that the onSummarize callback is invoked with the expected settings.
// The actual AI summarization behavior is tested separately.
it("invokes onSummarize callback when autoSummarizeTitles is enabled", async () => {
// Mock project settings with autoSummarizeTitles: true (using getSettingsFast)
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
autoSummarizeTitles: true,
});
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "x".repeat(300),
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
summarize: false,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: true },
onSummarize: expect.any(Function),
}),
);
});
it("passes correct settings to onSummarize callback when project title lane is configured", async () => {
// Mock project settings with titleSummarizerProvider + titleSummarizerModelId (using getSettingsFast)
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
autoSummarizeTitles: true,
titleSummarizerProvider: "anthropic",
titleSummarizerModelId: "claude-sonnet-4-5",
});
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "x".repeat(300),
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
// Verify the callback was passed with autoSummarizeTitles setting
expect(store.createTask).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
settings: { autoSummarizeTitles: true },
onSummarize: expect.any(Function),
}),
);
});
it("passes correct settings to onSummarize callback when global title lane is configured", async () => {
// Mock project settings with global titleSummarizerGlobalProvider + titleSummarizerGlobalModelId (using getSettingsFast)
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
autoSummarizeTitles: true,
titleSummarizerGlobalProvider: "openai",
titleSummarizerGlobalModelId: "gpt-4o",
});
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "x".repeat(300),
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
settings: { autoSummarizeTitles: true },
onSummarize: expect.any(Function),
}),
);
});
it("passes correct settings to onSummarize callback when default lane is configured", async () => {
// Mock project settings with only defaultProvider + defaultModelId (using getSettingsFast)
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
autoSummarizeTitles: true,
defaultProvider: "mistral",
defaultModelId: "mistral-large",
});
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "x".repeat(300),
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
settings: { autoSummarizeTitles: true },
onSummarize: expect.any(Function),
}),
);
});
it("passes correct settings to onSummarize callback when project default override is configured", async () => {
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
autoSummarizeTitles: true,
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
defaultProvider: "mistral",
defaultModelId: "mistral-large",
});
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "x".repeat(300),
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
settings: { autoSummarizeTitles: true },
onSummarize: expect.any(Function),
}),
);
});
});
describe("PATCH /tasks/:id branch fields", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("forwards trimmed branch and baseBranch values", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
branch: "fusion/fn-123",
baseBranch: "main",
});
const res = await REQUEST(
buildApp(),
"PATCH",
"/api/tasks/FN-001",
JSON.stringify({ branch: " fusion/fn-123 ", baseBranch: " main " }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({
branch: "fusion/fn-123",
baseBranch: "main",
}));
});
it("treats empty-string patch values as clears (null)", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
branch: undefined,
baseBranch: undefined,
});
const res = await REQUEST(
buildApp(),
"PATCH",
"/api/tasks/FN-001",
JSON.stringify({ branch: " ", baseBranch: "" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({
branch: null,
baseBranch: null,
}));
});
it("returns 400 for invalid branch payload types", async () => {
const res = await REQUEST(
buildApp(),
"PATCH",
"/api/tasks/FN-001",
JSON.stringify({ branch: 42 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("branch must be a string or null");
expect(store.updateTask).not.toHaveBeenCalled();
});
});
describe("POST /subtasks/*", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
__resetPlanningState();
__resetSubtaskBreakdownState();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("starts a subtask streaming session and returns sessionId", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Break this feature into subtasks" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(typeof res.body.sessionId).toBe("string");
});
it("accepts projectId query param without error", async () => {
// Mock getOrCreateProjectStore for projectId scoping
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(store);
const res = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming?projectId=test-project-123",
JSON.stringify({ description: "Break this feature into subtasks" }),
{ "Content-Type": "application/json" },
);
// Should return 201 without throwing an error (projectId is forwarded)
expect(res.status).toBe(201);
expect(typeof res.body.sessionId).toBe("string");
});
it("retries a failed subtask session", async () => {
const retrySpy = vi.spyOn(subtaskBreakdownModule, "retrySubtaskSession").mockResolvedValue();
const res = await REQUEST(buildApp(), "POST", "/api/subtasks/session-123/retry");
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, sessionId: "session-123" });
expect(retrySpy).toHaveBeenCalledWith("session-123", "/fake/root", undefined);
});
it("returns 404 when subtask retry session does not exist", async () => {
vi.spyOn(subtaskBreakdownModule, "retrySubtaskSession").mockRejectedValueOnce(
new subtaskBreakdownModule.SessionNotFoundError("Subtask session not found"),
);
const res = await REQUEST(buildApp(), "POST", "/api/subtasks/session-404/retry");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Subtask session not found");
});
it("returns 400 when subtask retry session is not in error state", async () => {
vi.spyOn(subtaskBreakdownModule, "retrySubtaskSession").mockRejectedValueOnce(
new subtaskBreakdownModule.InvalidSessionStateError("Session is not in error state"),
);
const res = await REQUEST(buildApp(), "POST", "/api/subtasks/session-400/retry");
expect(res.status).toBe(400);
expect(res.body.error).toContain("not in error state");
});
it("replays buffered subtask events using lastEventId query param", async () => {
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Replay buffered subtask stream" }),
{ "Content-Type": "application/json" },
);
const sessionId = start.body.sessionId as string;
// Reset any initial stream manager state from background generation.
subtaskStreamManager.cleanupSession(sessionId);
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "second" });
setTimeout(() => {
subtaskStreamManager.broadcast(sessionId, { type: "complete" });
}, 0);
const streamRes = await REQUEST(
buildApp(),
"GET",
`/api/subtasks/${sessionId}/stream?lastEventId=1`,
);
expect(streamRes.status).toBe(200);
expect(streamRes.body).toContain("id: 2");
expect(streamRes.body).toContain("event: thinking");
expect(streamRes.body).toContain("event: complete");
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
});
it("replays buffered subtask events using Last-Event-ID header", async () => {
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Replay buffered subtask stream from header" }),
{ "Content-Type": "application/json" },
);
const sessionId = start.body.sessionId as string;
subtaskStreamManager.cleanupSession(sessionId);
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "second" });
setTimeout(() => {
subtaskStreamManager.broadcast(sessionId, { type: "complete" });
}, 0);
const streamRes = await REQUEST(
buildApp(),
"GET",
`/api/subtasks/${sessionId}/stream`,
undefined,
{ "Last-Event-ID": "1" },
);
expect(streamRes.status).toBe(200);
expect(streamRes.body).toContain("id: 2");
expect(streamRes.body).toContain("event: thinking");
expect(streamRes.body).toContain("event: complete");
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
});
it("skips subtask replay when Last-Event-ID is missing", async () => {
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "No subtask replay without header" }),
{ "Content-Type": "application/json" },
);
const sessionId = start.body.sessionId as string;
subtaskStreamManager.cleanupSession(sessionId);
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
setTimeout(() => {
subtaskStreamManager.broadcast(sessionId, { type: "complete" });
}, 0);
const streamRes = await REQUEST(
buildApp(),
"GET",
`/api/subtasks/${sessionId}/stream`,
);
expect(streamRes.status).toBe(200);
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
expect(streamRes.body).toContain("id: 2");
expect(streamRes.body).toContain("event: complete");
});
it("gracefully ignores invalid Last-Event-ID values for subtask streams", async () => {
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Invalid subtask last event id" }),
{ "Content-Type": "application/json" },
);
const sessionId = start.body.sessionId as string;
subtaskStreamManager.cleanupSession(sessionId);
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
setTimeout(() => {
subtaskStreamManager.broadcast(sessionId, { type: "complete" });
}, 0);
const streamRes = await REQUEST(
buildApp(),
"GET",
`/api/subtasks/${sessionId}/stream`,
undefined,
{ "Last-Event-ID": "not-a-number" },
);
expect(streamRes.status).toBe(200);
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
expect(streamRes.body).toContain("id: 2");
expect(streamRes.body).toContain("event: complete");
});
it("creates tasks from a breakdown and resolves dependencies", async () => {
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-101", title: "First", column: "triage" })
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-102", title: "Second", column: "triage" });
(store.updateTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-101", title: "First", column: "triage", size: "S" })
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-102", title: "Second", column: "triage", size: "M" })
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-102", title: "Second", column: "triage", dependencies: ["FN-101"] });
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Break this feature into subtasks" }),
{ "Content-Type": "application/json" },
);
const createRes = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/create-tasks",
JSON.stringify({
sessionId: start.body.sessionId,
subtasks: [
{ tempId: "subtask-1", title: "First", description: "Do first", size: "S", dependsOn: [] },
{ tempId: "subtask-2", title: "Second", description: "Do second", size: "M", dependsOn: ["subtask-1"] },
],
}),
{ "Content-Type": "application/json" },
);
expect(createRes.status).toBe(201);
expect(createRes.body.tasks).toHaveLength(2);
expect(store.createTask).toHaveBeenNthCalledWith(1, expect.objectContaining({ title: "First", dependencies: undefined }));
expect(store.createTask).toHaveBeenNthCalledWith(2, expect.objectContaining({ title: "Second", dependencies: undefined }));
expect(store.updateTask).toHaveBeenCalledWith("FN-102", { dependencies: ["FN-101"] });
});
it("subtask batch creation attempts tracking issue creation and links metadata", async () => {
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({
owner: "task",
repo: "repo",
number: 55,
htmlUrl: "https://github.com/task/repo/issues/55",
createdAt: "2026-01-01T00:00:00.000Z",
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
githubTrackingDefaultRepo: "task/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-103", title: "First", column: "triage", githubTracking: { enabled: true } });
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Break this feature into subtasks" }),
{ "Content-Type": "application/json" },
);
const createRes = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/create-tasks",
JSON.stringify({
sessionId: start.body.sessionId,
subtasks: [{ tempId: "subtask-1", title: "First", description: "Do first" }],
}),
{ "Content-Type": "application/json" },
);
expect(createRes.status).toBe(201);
expect(createIssueSpy).toHaveBeenCalledWith(expect.objectContaining({ owner: "task", repo: "repo" }));
expect(store.linkGithubIssue).toHaveBeenCalledWith("FN-103", expect.objectContaining({ owner: "task", repo: "repo", number: 55 }));
expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ type: "github-issue-created" }) }));
createIssueSpy.mockRestore();
});
it("subtask batch creation remains successful when tracking issue creation fails", async () => {
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockRejectedValue(new Error("boom"));
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
githubTrackingDefaultRepo: "task/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-104", title: "First", column: "triage", githubTracking: { enabled: true } });
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Break this feature into subtasks" }),
{ "Content-Type": "application/json" },
);
const createRes = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/create-tasks",
JSON.stringify({
sessionId: start.body.sessionId,
subtasks: [{ tempId: "subtask-1", title: "First", description: "Do first" }],
}),
{ "Content-Type": "application/json" },
);
expect(createRes.status).toBe(201);
expect(createRes.body.tasks).toHaveLength(1);
expect(createIssueSpy).toHaveBeenCalledTimes(1);
createIssueSpy.mockRestore();
});
it("subtask batch creation does not recreate tracking issue when task is already linked", async () => {
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue");
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
githubTrackingDefaultRepo: "task/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
...FAKE_TASK_DETAIL,
id: "FN-105",
title: "First",
column: "triage",
githubTracking: {
enabled: true,
issue: { owner: "task", repo: "repo", number: 9, url: "https://github.com/task/repo/issues/9" },
},
});
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Break this feature into subtasks" }),
{ "Content-Type": "application/json" },
);
const createRes = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/create-tasks",
JSON.stringify({
sessionId: start.body.sessionId,
subtasks: [{ tempId: "subtask-1", title: "First", description: "Do first" }],
}),
{ "Content-Type": "application/json" },
);
expect(createRes.status).toBe(201);
expect(createIssueSpy).not.toHaveBeenCalled();
createIssueSpy.mockRestore();
});
it("applies explicit branch selection to created subtasks", async () => {
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-201", title: "First", column: "triage" });
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Break this feature into subtasks" }),
{ "Content-Type": "application/json" },
);
const createRes = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/create-tasks",
JSON.stringify({
sessionId: start.body.sessionId,
branchSelection: { mode: "custom-new", branchName: "feature/planning", baseBranch: "main" },
subtasks: [
{ tempId: "subtask-1", title: "First", description: "Do first" },
],
}),
{ "Content-Type": "application/json" },
);
expect(createRes.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
branch: "feature/planning",
baseBranch: "main",
branchContext: {
groupId: `planning:${start.body.sessionId}`,
source: "planning",
assignmentMode: "shared",
inheritedBaseBranch: "main",
},
}));
});
it("derives per-task branches when requested", async () => {
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-301", title: "First", column: "triage" })
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-302", title: "Second", column: "triage" });
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Break this feature into subtasks" }),
{ "Content-Type": "application/json" },
);
const createRes = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/create-tasks",
JSON.stringify({
sessionId: start.body.sessionId,
branchSelection: { mode: "custom-new", branchName: "feature/planning" },
branchAssignment: { mode: "per-task-derived" },
subtasks: [
{ tempId: "subtask-1", title: "First Task", description: "Do first" },
{ tempId: "subtask-2", title: "Second Task", description: "Do second" },
],
}),
{ "Content-Type": "application/json" },
);
expect(createRes.status).toBe(201);
expect(store.createTask).toHaveBeenNthCalledWith(1, expect.objectContaining({
branch: "feature/planning/first-task",
branchContext: expect.objectContaining({
groupId: `planning:${start.body.sessionId}`,
source: "planning",
assignmentMode: "per-task-derived",
}),
}));
expect(store.createTask).toHaveBeenNthCalledWith(2, expect.objectContaining({
branch: "feature/planning/second-task",
branchContext: expect.objectContaining({
groupId: `planning:${start.body.sessionId}`,
source: "planning",
assignmentMode: "per-task-derived",
}),
}));
});
it("returns 404 for invalid subtask session during batch creation", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/create-tasks",
JSON.stringify({
sessionId: "missing-session",
subtasks: [{ tempId: "subtask-1", title: "First", description: "Do first" }],
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("inherits parent task model settings when creating subtasks", async () => {
const parentTask = {
...FAKE_TASK_DETAIL,
id: "FN-100",
title: "Parent Task",
column: "triage",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
};
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(parentTask);
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-101", title: "First", column: "triage" })
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-102", title: "Second", column: "triage" });
(store.updateTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-101", title: "First", column: "triage", size: "S" })
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-102", title: "Second", column: "triage", size: "M" });
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Break this feature into subtasks" }),
{ "Content-Type": "application/json" },
);
const createRes = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/create-tasks",
JSON.stringify({
sessionId: start.body.sessionId,
parentTaskId: "FN-100",
subtasks: [
{ tempId: "subtask-1", title: "First", description: "Do first", size: "S", dependsOn: [] },
{ tempId: "subtask-2", title: "Second", description: "Do second", size: "M", dependsOn: ["subtask-1"] },
],
}),
{ "Content-Type": "application/json" },
);
expect(createRes.status).toBe(201);
expect(store.getTask).toHaveBeenCalledWith("FN-100");
expect(store.createTask).toHaveBeenNthCalledWith(1, expect.objectContaining({
title: "First",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
}));
expect(store.createTask).toHaveBeenNthCalledWith(2, expect.objectContaining({
title: "Second",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
}));
});
it("handles missing parent task gracefully when creating subtasks", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Task not found"));
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-101", title: "First", column: "triage" });
(store.updateTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-101", title: "First", column: "triage", size: "S" });
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Break this feature into subtasks" }),
{ "Content-Type": "application/json" },
);
const createRes = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/create-tasks",
JSON.stringify({
sessionId: start.body.sessionId,
parentTaskId: "FN-NONEXISTENT",
subtasks: [
{ tempId: "subtask-1", title: "First", description: "Do first", size: "S", dependsOn: [] },
],
}),
{ "Content-Type": "application/json" },
);
expect(createRes.status).toBe(201);
expect(store.getTask).toHaveBeenCalledWith("FN-NONEXISTENT");
// Subtask created without model inheritance (undefined values)
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
title: "First",
modelProvider: undefined,
modelId: undefined,
validatorModelProvider: undefined,
validatorModelId: undefined,
}));
});
it("drops a subtask dependency that references the parent task being split", async () => {
// Regression: the AI/UI sometimes emits `dependsOn: ["<parentId>"]` on a
// child. Previously the child was created with a reference to the
// parent id (via an existing-task lookup), then the parent was deleted,
// leaving the child permanently blocked. We now drop parent-id deps and
// surface them in the response.
const parentTask = {
...FAKE_TASK_DETAIL,
id: "FN-PARENT",
title: "Parent",
column: "triage",
};
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(parentTask);
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_TASK_DETAIL,
id: "FN-CHILD",
title: "Child",
column: "triage",
});
(store.deleteTask as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Break this feature into subtasks" }),
{ "Content-Type": "application/json" },
);
const createRes = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/create-tasks",
JSON.stringify({
sessionId: start.body.sessionId,
parentTaskId: "FN-PARENT",
subtasks: [
{ tempId: "subtask-1", title: "Child", description: "Do it", dependsOn: ["FN-PARENT"] },
],
}),
{ "Content-Type": "application/json" },
);
expect(createRes.status).toBe(201);
// Dependencies must NOT contain the parent id.
// updateTask either isn't called for deps, or is called with an empty array.
const depUpdateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls
.filter((args: unknown[]) => {
const patch = args[1] as { dependencies?: string[] } | undefined;
return patch?.dependencies !== undefined;
});
for (const call of depUpdateCalls) {
expect((call[1] as { dependencies: string[] }).dependencies).not.toContain("FN-PARENT");
}
// The response surfaces the dropped dep instead of silently swallowing it.
expect(createRes.body.droppedDependencies).toEqual([
{ taskId: "FN-CHILD", dropped: ["FN-PARENT"] },
]);
});
it("surfaces parent close errors when deleteTask refuses due to live dependents", async () => {
// If a child still references the parent after the drop step (shouldn't
// happen post-fix, but could via race or caller mistake), store.deleteTask
// throws. The endpoint must not swallow that silently — parentTaskClosed
// is false AND parentTaskCloseError names the reason.
const parentTask = {
...FAKE_TASK_DETAIL,
id: "FN-STUBBORN",
title: "Parent",
column: "triage",
};
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(parentTask);
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_TASK_DETAIL,
id: "FN-CHILD",
title: "Child",
column: "triage",
});
(store.deleteTask as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Cannot delete task FN-STUBBORN: still referenced as a dependency by FN-OTHER."),
);
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Break this feature into subtasks" }),
{ "Content-Type": "application/json" },
);
const createRes = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/create-tasks",
JSON.stringify({
sessionId: start.body.sessionId,
parentTaskId: "FN-STUBBORN",
subtasks: [
{ tempId: "subtask-1", title: "Child", description: "Do it", dependsOn: [] },
],
}),
{ "Content-Type": "application/json" },
);
expect(createRes.status).toBe(201);
expect(createRes.body.parentTaskClosed).toBe(false);
expect(createRes.body.parentTaskCloseError).toContain("FN-OTHER");
});
});
describe("POST /tasks/:id/review/address", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore({ updateStep: vi.fn() } as unknown as Partial<TaskStore>);
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("resumes in-review tasks to in-progress using selected review payload", async () => {
const taskWithReview = {
...FAKE_TASK_DETAIL,
id: "FN-001",
column: "in-review",
status: "awaiting-user-review",
assignedAgentId: null,
steps: [{ id: "s1", title: "Step 1", status: "done" }],
reviewState: {
source: "reviewer-agent",
items: [{ id: "ri-1", body: "Fix tests", summary: "Fix tests", author: { login: "reviewer" }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }],
addressing: [],
},
};
const movedTask = { ...taskWithReview, column: "in-progress", status: null, sessionFile: null, assignedAgentId: null };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(taskWithReview).mockResolvedValueOnce({ ...taskWithReview, reviewState: { ...taskWithReview.reviewState, addressing: [{ itemId: "ri-1", status: "queued", selectedAt: new Date().toISOString() }] } });
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "sc-1" });
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/address", JSON.stringify({ selectedItems: [{ id: "ri-1", source: "reviewer-agent", summary: "Fix tests", body: "Fix tests" }] }), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress", { preserveProgress: true });
expect(store.updateStep).toHaveBeenCalledWith("FN-001", 0, "pending");
});
it("for in-progress tasks injects steering without moving task", async () => {
const taskWithReview = {
...FAKE_TASK_DETAIL,
id: "FN-001",
column: "in-progress",
sessionFile: "active.session.json",
reviewState: {
source: "pull-request",
items: [{ id: "ri-1", body: "Fix tests", summary: "Fix tests", author: { login: "reviewer" }, createdAt: new Date().toISOString(), path: "src/a.ts", line: 4 }],
addressing: [],
},
};
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(taskWithReview).mockResolvedValueOnce(taskWithReview);
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "sc-1" });
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/address", JSON.stringify({ selectedItems: [{ id: "ri-1", source: "pr-review", summary: "Fix tests", body: "Fix tests", filePath: "src/a.ts", lineNumber: 4 }] }), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(store.moveTask).not.toHaveBeenCalled();
});
it("rejects empty selection", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "FN-001", reviewState: { source: "reviewer-agent", items: [], addressing: [] } });
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/address", JSON.stringify({ selectedItems: [] }), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("selectedItems must be a non-empty array");
});
it("rejects unsupported review source", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "FN-001", reviewState: { source: "reviewer-agent", items: [{ id: "ri-1", body: "x", summary: "x", author: { login: "reviewer" }, createdAt: new Date().toISOString() }], addressing: [] } });
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/address", JSON.stringify({ selectedItems: [{ id: "ri-1", source: "other", summary: "x", body: "x" }] }), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("Unsupported review source");
});
it("rejects source mismatch and unknown item ids", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "FN-001",
reviewState: { source: "pull-request", items: [{ id: "ri-1", body: "x", summary: "x", author: { login: "reviewer" }, createdAt: new Date().toISOString() }], addressing: [] },
});
const mismatch = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/address", JSON.stringify({ selectedItems: [{ id: "ri-1", source: "reviewer-agent", summary: "x", body: "x" }] }), { "Content-Type": "application/json" });
expect(mismatch.status).toBe(400);
expect(mismatch.body.error).toContain("does not match task review mode");
const unknown = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/address", JSON.stringify({ selectedItems: [{ id: "ri-2", source: "pr-review", summary: "x", body: "x" }] }), { "Content-Type": "application/json" });
expect(unknown.status).toBe(400);
expect(unknown.body.error).toContain("must reference existing review items");
expect(store.createTask).not.toHaveBeenCalled();
});
});