Files
fusion/packages/dashboard/src/__tests__/routes-agents.test.ts
gsxdsm 31d955c9ce chore(test): split dashboard api/routes monoliths and document core fork-only constraint
Split packages/dashboard/app/__tests__/api.test.ts (~6.4k lines) into per-area files (auth, git, missions, projects, settings, tasks), and src/__tests__/routes.test.ts (~20k lines) into per-area files (agents, auth, automation, git, github, planning, settings, system, tasks, tasks-ops). The monolith files were dominating wall-clock for the dashboard suite under file-parallel execution.

Also document in packages/core/vitest.config.ts why the core suite cannot move to "threads": vitest-setup gates per-worker cwd on isMainThread (false in worker_threads, so isolation breaks), and setup-test-isolation writes process.env.HOME unconditionally (threads share env, so concurrent workers race).

Drop an unused execFileSync import from scripts/test-with-lock.mjs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:25:40 -07:00

3901 lines
133 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 { 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,
})),
});
});
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(),
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),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
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;
}
import { DEFAULT_SETTINGS } from "@fusion/core";
afterEach(() => {
resetDiagnosticsSink();
});
describe("GET /workflow-steps", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns empty array when no workflow steps exist", async () => {
const res = await GET(buildApp(), "/api/workflow-steps");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns workflow steps", async () => {
const steps = [
{ id: "WS-001", name: "Docs", description: "Check docs", prompt: "Review docs", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
];
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce(steps);
const res = await GET(buildApp(), "/api/workflow-steps");
expect(res.status).toBe(200);
expect(res.body).toEqual(steps);
});
});
describe("POST /workflow-steps", () => {
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 workflow step", async () => {
const created = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Docs",
description: "Check docs",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(res.body.id).toBe("WS-001");
expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Docs",
description: "Check docs",
mode: "prompt",
phase: undefined,
prompt: undefined,
scriptName: undefined,
enabled: undefined,
defaultOn: false,
});
});
it("returns 400 when name is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
description: "Check docs",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("name");
});
it("returns 400 when description is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Docs",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("description");
});
it("returns 409 when name already exists", async () => {
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{ id: "WS-001", name: "Docs", description: "Check docs", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
]);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Docs",
description: "Another docs step",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(409);
expect(res.body.error).toContain("already exists");
});
it("creates a workflow step with model override", async () => {
const created = { id: "WS-002", name: "Security", description: "Security audit", prompt: "", enabled: true, modelProvider: "anthropic", modelId: "claude-sonnet-4-5", createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Security",
description: "Security audit",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Security",
description: "Security audit",
mode: "prompt",
phase: undefined,
prompt: undefined,
scriptName: undefined,
enabled: undefined,
defaultOn: false,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
});
it("returns 400 when model provider is set without modelId", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Security",
description: "Security audit",
modelProvider: "anthropic",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("must include both provider and modelId");
});
it("returns 400 when modelId is set without model provider", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Security",
description: "Security audit",
modelId: "claude-sonnet-4-5",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("must include both provider and modelId");
});
it("creates a workflow step without model fields when both empty strings", async () => {
const created = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Docs",
description: "Check docs",
modelProvider: "",
modelId: "",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Docs",
description: "Check docs",
mode: "prompt",
phase: undefined,
prompt: undefined,
scriptName: undefined,
enabled: undefined,
defaultOn: false,
modelProvider: undefined,
modelId: undefined,
});
});
it("creates a script-mode workflow step with valid scriptName", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
scripts: { test: "pnpm test", lint: "pnpm lint" },
});
const created = { id: "WS-001", name: "Run Tests", description: "Execute tests", mode: "script", scriptName: "test", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Run Tests",
description: "Execute tests",
mode: "script",
scriptName: "test",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Run Tests",
description: "Execute tests",
mode: "script",
phase: undefined,
prompt: undefined,
scriptName: "test",
enabled: undefined,
defaultOn: false,
});
});
it("returns 400 for script mode without scriptName", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Run Tests",
description: "Execute tests",
mode: "script",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("scriptName is required");
});
it("returns 400 for script mode with scriptName not in project scripts", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
scripts: { lint: "pnpm lint" },
});
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Run Tests",
description: "Execute tests",
mode: "script",
scriptName: "nonexistent",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("not found in project settings");
});
it("returns 400 for invalid mode value", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Test",
description: "Test",
mode: "invalid",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("mode must be");
});
it("creates a workflow step with 'post-merge' phase", async () => {
const created = { id: "WS-001", name: "Post Merge", description: "After merge", mode: "prompt", phase: "post-merge", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Post Merge",
description: "After merge",
phase: "post-merge",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Post Merge",
description: "After merge",
mode: "prompt",
phase: "post-merge",
prompt: undefined,
scriptName: undefined,
enabled: undefined,
defaultOn: false,
});
});
it("returns 400 for invalid phase value", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Test",
description: "Test",
phase: "during-merge",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("phase must be");
});
it("creates a workflow step with defaultOn true", async () => {
const created = { id: "WS-010", name: "Auto Step", description: "Auto-enabled", mode: "prompt", prompt: "", enabled: true, defaultOn: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Auto Step",
description: "Auto-enabled",
defaultOn: true,
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Auto Step",
description: "Auto-enabled",
mode: "prompt",
phase: undefined,
prompt: undefined,
scriptName: undefined,
enabled: undefined,
defaultOn: true,
});
});
it("defaults defaultOn to false when not specified", async () => {
const created = { id: "WS-011", name: "Manual Step", description: "Manual only", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Manual Step",
description: "Manual only",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(store.createWorkflowStep).toHaveBeenCalledWith(
expect.objectContaining({ defaultOn: false })
);
});
it("returns 400 when defaultOn is not a boolean", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Bad Step",
description: "Bad defaultOn",
defaultOn: "yes",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("defaultOn");
});
});
describe("PATCH /workflow-steps/:id", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("updates a workflow step", async () => {
const updated = { id: "WS-001", name: "Updated", description: "Updated desc", prompt: "Updated prompt", enabled: false, createdAt: "2026-01-01", updatedAt: "2026-01-02" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
name: "Updated",
enabled: false,
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(res.body.name).toBe("Updated");
});
it("returns 404 for non-existent step", async () => {
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Workflow step 'WS-999' not found"));
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-999", JSON.stringify({
name: "Nope",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("updates a workflow step with model override", async () => {
const updated = { id: "WS-001", name: "Security", description: "Audit", prompt: "", enabled: true, modelProvider: "anthropic", modelId: "claude-sonnet-4-5", createdAt: "2026-01-01", updatedAt: "2026-01-02" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
}));
});
it("returns 400 when updating with only modelProvider", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
modelProvider: "anthropic",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("must include both provider and modelId");
});
it("returns 400 when updating with only modelId", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
modelId: "claude-sonnet-4-5",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("must include both provider and modelId");
});
it("returns 400 when updating scriptName to nonexistent on existing script-mode step", async () => {
// Simulate an existing script-mode step
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: "WS-001",
name: "Run Tests",
description: "Test runner",
mode: "script",
scriptName: "test",
prompt: "",
enabled: true,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
scripts: { test: "pnpm test", lint: "pnpm lint" },
});
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
scriptName: "nonexistent",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("not found in project settings");
// Should NOT have called updateWorkflowStep since validation failed
expect(store.updateWorkflowStep).not.toHaveBeenCalled();
});
it("returns 400 when updating script-mode step without scriptName (resulting state)", async () => {
// Simulate an existing script-mode step with scriptName cleared
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: "WS-001",
name: "Run Tests",
description: "Test runner",
mode: "script",
scriptName: "",
prompt: "",
enabled: true,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
});
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
name: "Updated Name",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("scriptName is required when mode is 'script'");
});
it("updates a workflow step phase", async () => {
const updated = { id: "WS-001", name: "Post Merge", description: "After merge", phase: "post-merge", createdAt: "2026-01-01", updatedAt: "2026-01-02" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: "WS-001", name: "Pre Merge", description: "Before merge", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01",
});
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
phase: "post-merge",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({
phase: "post-merge",
}));
});
it("returns 400 for invalid phase value on update", async () => {
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: "WS-001", name: "Test", description: "Test", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01",
});
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
phase: "during-merge",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("phase must be");
});
it("updates defaultOn to true", async () => {
const updated = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, defaultOn: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
defaultOn: true,
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({ defaultOn: true }));
});
it("updates defaultOn to false", async () => {
const updated = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, defaultOn: false, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
defaultOn: false,
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({ defaultOn: false }));
});
it("returns 400 when defaultOn is not a boolean in PATCH", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
defaultOn: "yes",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("defaultOn");
});
});
describe("DELETE /workflow-steps/:id", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("deletes a workflow step", async () => {
(store.deleteWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
const res = await REQUEST(buildApp(), "DELETE", "/api/workflow-steps/WS-001", undefined, {});
expect(res.status).toBe(204);
});
it("returns 404 for non-existent step", async () => {
(store.deleteWorkflowStep as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Workflow step 'WS-999' not found"));
const res = await REQUEST(buildApp(), "DELETE", "/api/workflow-steps/WS-999", undefined, {});
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
});
describe("POST /workflow-steps/:id/refine", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
afterEach(() => {
__setCreateFnAgentForRefine(undefined);
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 404 when workflow step not found", async () => {
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-999/refine", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("returns 400 when workflow step has no description", async () => {
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: "WS-001", name: "Empty", description: " ", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01",
});
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("no description");
});
it("returns 400 when workflow step is in script mode", async () => {
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: "WS-001", name: "Run Tests", description: "Execute test suite", mode: "script", scriptName: "test", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01",
});
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Cannot refine prompt for script-mode");
});
it("returns AI-refined prompt when engine is available", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
});
const updatedWs = { ...ws, prompt: "Refined prompt from AI" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
let onText: ((delta: string) => void) | undefined;
const session = {
on: vi.fn((event: string, cb: (delta: string) => void) => {
if (event === "text") {
onText = cb;
}
}),
prompt: vi.fn(async () => {
onText?.("Refined ");
onText?.("prompt from AI");
}),
dispose: vi.fn(),
};
const createFnAgentMock = vi.fn(async () => ({ session }));
__setCreateFnAgentForRefine(createFnAgentMock);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.prompt).toBe("Refined prompt from AI");
expect(res.body.workflowStep.prompt).toBe("Refined prompt from AI");
expect(createFnAgentMock).toHaveBeenCalledTimes(1);
expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining("Name: Docs"));
expect(session.dispose).toHaveBeenCalledTimes(1);
expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", { prompt: "Refined prompt from AI" });
});
it("falls back to description when AI is unavailable", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({});
const updatedWs = { ...ws, prompt: "Check docs" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
__setCreateFnAgentForRefine(async () => {
throw new Error("AI unavailable");
});
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.prompt).toBe("Check docs");
expect(res.body.workflowStep.prompt).toBe("Check docs");
expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", { prompt: "Check docs" });
});
it("uses custom prompt from promptOverrides when provided", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
const customPrompt = "CUSTOM WORKFLOW STEP REFINE PROMPT";
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
promptOverrides: {
"workflow-step-refine": customPrompt,
},
});
const updatedWs = { ...ws, prompt: "Refined prompt from AI" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
let capturedSystemPrompt: string | undefined;
const session = {
on: vi.fn((event: string, cb: (delta: string) => void) => {
if (event === "text") {
cb("Refined ");
cb("prompt from AI");
}
}),
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
};
const createFnAgentMock = vi.fn(async (options: { cwd: string; systemPrompt: string; tools: string }) => {
capturedSystemPrompt = options.systemPrompt;
return { session };
});
__setCreateFnAgentForRefine(createFnAgentMock);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(createFnAgentMock).toHaveBeenCalledTimes(1);
// Verify the custom prompt was passed
expect(capturedSystemPrompt).toBe(customPrompt);
});
it("uses default prompt when promptOverrides does not contain workflow-step-refine", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
// Settings with other overrides but not workflow-step-refine
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
promptOverrides: {
"executor-welcome": "Some other prompt",
},
});
const updatedWs = { ...ws, prompt: "Refined prompt from AI" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
let capturedSystemPrompt: string | undefined;
const session = {
on: vi.fn((event: string, cb: (delta: string) => void) => {
if (event === "text") {
cb("Refined ");
cb("prompt from AI");
}
}),
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
};
const createFnAgentMock = vi.fn(async (options: { cwd: string; systemPrompt: string; tools: string }) => {
capturedSystemPrompt = options.systemPrompt;
return { session };
});
__setCreateFnAgentForRefine(createFnAgentMock);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(createFnAgentMock).toHaveBeenCalledTimes(1);
// Should use the default prompt (contains "You are an expert at creating")
expect(capturedSystemPrompt).toContain("You are an expert at creating");
expect(capturedSystemPrompt).toContain("workflow steps");
});
// ── Lane Precedence Regression Tests ────────────────────────────────────────
// Tests for FN-1730: ensure model resolution follows the documented hierarchy:
// 1. Project settings planningProvider + planningModelId (project lane)
// 2. Global settings planningGlobalProvider + planningGlobalModelId (global lane)
// 3. Default settings defaultProvider + defaultModelId (default fallback)
it("uses project planning lane when configured", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
});
const updatedWs = { ...ws, prompt: "Refined prompt from AI" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
let capturedModel: { defaultProvider?: string; defaultModelId?: string } = {};
const session = {
on: vi.fn(),
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
};
const createFnAgentMock = vi.fn(async (options: { defaultProvider?: string; defaultModelId?: string }) => {
capturedModel = options;
return { session };
});
__setCreateFnAgentForRefine(createFnAgentMock);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(createFnAgentMock).toHaveBeenCalledTimes(1);
expect(capturedModel.defaultProvider).toBe("anthropic");
expect(capturedModel.defaultModelId).toBe("claude-sonnet-4-5");
});
it("falls back to global planning lane when project lane unset", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
planningGlobalProvider: "openai",
planningGlobalModelId: "gpt-4o",
});
const updatedWs = { ...ws, prompt: "Refined prompt from AI" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
let capturedModel: { defaultProvider?: string; defaultModelId?: string } = {};
const session = {
on: vi.fn(),
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
};
const createFnAgentMock = vi.fn(async (options: { defaultProvider?: string; defaultModelId?: string }) => {
capturedModel = options;
return { session };
});
__setCreateFnAgentForRefine(createFnAgentMock);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(createFnAgentMock).toHaveBeenCalledTimes(1);
expect(capturedModel.defaultProvider).toBe("openai");
expect(capturedModel.defaultModelId).toBe("gpt-4o");
});
it("falls back to default lane when all planning lanes unset", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
defaultProvider: "mistral",
defaultModelId: "mistral-large",
});
const updatedWs = { ...ws, prompt: "Refined prompt from AI" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
let capturedModel: { defaultProvider?: string; defaultModelId?: string } = {};
const session = {
on: vi.fn(),
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
};
const createFnAgentMock = vi.fn(async (options: { defaultProvider?: string; defaultModelId?: string }) => {
capturedModel = options;
return { session };
});
__setCreateFnAgentForRefine(createFnAgentMock);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(createFnAgentMock).toHaveBeenCalledTimes(1);
expect(capturedModel.defaultProvider).toBe("mistral");
expect(capturedModel.defaultModelId).toBe("mistral-large");
});
it("falls back to the project default override before the global default lane", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
defaultProvider: "mistral",
defaultModelId: "mistral-large",
});
const updatedWs = { ...ws, prompt: "Refined prompt from AI" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
let capturedModel: { defaultProvider?: string; defaultModelId?: string } = {};
const session = {
on: vi.fn(),
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
};
const createFnAgentMock = vi.fn(async (options: { defaultProvider?: string; defaultModelId?: string }) => {
capturedModel = options;
return { session };
});
__setCreateFnAgentForRefine(createFnAgentMock);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(capturedModel.defaultProvider).toBe("openai");
expect(capturedModel.defaultModelId).toBe("gpt-4o");
});
it("ignores partial project lane (provider only, no modelId)", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
// Partial project lane: provider only, no modelId
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
planningProvider: "anthropic",
// missing planningModelId
planningGlobalProvider: "openai",
planningGlobalModelId: "gpt-4o",
});
const updatedWs = { ...ws, prompt: "Refined prompt from AI" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
let capturedModel: { defaultProvider?: string; defaultModelId?: string } = {};
const session = {
on: vi.fn(),
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
};
const createFnAgentMock = vi.fn(async (options: { defaultProvider?: string; defaultModelId?: string }) => {
capturedModel = options;
return { session };
});
__setCreateFnAgentForRefine(createFnAgentMock);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
// Partial project lane should be ignored, falls through to global lane
expect(capturedModel.defaultProvider).toBe("openai");
expect(capturedModel.defaultModelId).toBe("gpt-4o");
});
});
// ── Workflow Step Refine with Scoped Settings (projectId) ──────────────────
describe("POST /workflow-steps/:id/refine with projectId scoping", () => {
const projectId = "proj-refine-scoped";
let defaultStore: TaskStore;
let scopedStore: TaskStore;
beforeEach(() => {
defaultStore = createMockStore();
scopedStore = createMockStore();
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore);
});
afterEach(() => {
vi.restoreAllMocks();
__setCreateFnAgentForRefine(undefined);
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(defaultStore));
return app;
}
it("uses scoped settings from project store when projectId is provided", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(scopedStore.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
const customPrompt = "CUSTOM SCOPED WORKFLOW REFINE PROMPT";
(scopedStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
promptOverrides: {
"workflow-step-refine": customPrompt,
},
});
const updatedWs = { ...ws, prompt: "Refined prompt from AI" };
(scopedStore.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
let capturedSystemPrompt: string | undefined;
const session = {
on: vi.fn((event: string, cb: (delta: string) => void) => {
if (event === "text") {
cb("Refined ");
cb("prompt from AI");
}
}),
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
};
const createFnAgentMock = vi.fn(async (options: { cwd: string; systemPrompt: string; tools: string }) => {
capturedSystemPrompt = options.systemPrompt;
return { session };
});
__setCreateFnAgentForRefine(createFnAgentMock);
const res = await REQUEST(
buildApp(),
"POST",
`/api/workflow-steps/WS-001/refine?projectId=${projectId}`,
JSON.stringify({}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
expect(scopedStore.getWorkflowStep).toHaveBeenCalledWith("WS-001");
expect(scopedStore.getSettings).toHaveBeenCalled();
expect(scopedStore.updateWorkflowStep).toHaveBeenCalledWith("WS-001", { prompt: "Refined prompt from AI" });
// Verify the custom prompt from scoped settings was used
expect(capturedSystemPrompt).toBe(customPrompt);
});
it("uses default prompt from scoped settings when no workflow-step-refine override", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(scopedStore.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
// Scoped settings with other overrides but not workflow-step-refine
(scopedStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
promptOverrides: {
"executor-welcome": "Some other prompt",
},
});
const updatedWs = { ...ws, prompt: "Refined prompt from AI" };
(scopedStore.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
let capturedSystemPrompt: string | undefined;
const session = {
on: vi.fn((event: string, cb: (delta: string) => void) => {
if (event === "text") {
cb("Refined ");
cb("prompt from AI");
}
}),
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
};
const createFnAgentMock = vi.fn(async (options: { cwd: string; systemPrompt: string; tools: string }) => {
capturedSystemPrompt = options.systemPrompt;
return { session };
});
__setCreateFnAgentForRefine(createFnAgentMock);
const res = await REQUEST(
buildApp(),
"POST",
`/api/workflow-steps/WS-001/refine?projectId=${projectId}`,
JSON.stringify({}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
// Should use the default prompt from scoped settings
expect(capturedSystemPrompt).toContain("You are an expert at creating");
expect(capturedSystemPrompt).toContain("workflow steps");
});
});
// ── Agent Generation Routes ────────────────────────────────────────────────
describe("POST /api/agents/generate/* diagnostics", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
function captureDiagnostics(): LogEntry[] {
const entries: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
entries.push({
level,
scope,
message,
context,
timestamp: new Date(),
});
});
return entries;
}
it("emits structured diagnostics when /agents/generate/start fails unexpectedly", async () => {
const diagnostics = captureDiagnostics();
vi.spyOn(agentGenerationModule, "startAgentGeneration").mockRejectedValueOnce(new Error("start failed"));
const res = await REQUEST(
buildApp(),
"POST",
"/api/agents/generate/start",
JSON.stringify({ role: "Senior frontend reviewer" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect(res.body.error).toContain("start failed");
expect(diagnostics).toContainEqual(
expect.objectContaining({
level: "error",
scope: "agent-generation",
message: "Error starting session",
context: expect.objectContaining({
operation: "generate-start",
error: expect.objectContaining({ message: "start failed" }),
}),
}),
);
});
it("emits structured diagnostics when /agents/generate/spec fails unexpectedly", async () => {
const diagnostics = captureDiagnostics();
vi.spyOn(agentGenerationModule, "generateAgentSpec").mockRejectedValueOnce(new Error("spec failed"));
const res = await REQUEST(
buildApp(),
"POST",
"/api/agents/generate/spec",
JSON.stringify({ sessionId: "session-123" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect(res.body.error).toContain("spec failed");
expect(diagnostics).toContainEqual(
expect.objectContaining({
level: "error",
scope: "agent-generation",
message: "Error generating spec",
context: expect.objectContaining({
operation: "generate-spec",
error: expect.objectContaining({ message: "spec failed" }),
}),
}),
);
});
});
describe("POST /agents/generate/spec with projectId scoping", () => {
const projectId = "proj-agent-gen-scoped";
let defaultStore: TaskStore;
let scopedStore: TaskStore;
beforeEach(() => {
defaultStore = createMockStore();
scopedStore = createMockStore({
getAgentGenerationSession: vi.fn().mockImplementation((sessionId: string) => {
if (sessionId === "test-session-id") {
return {
id: "test-session-id",
roleDescription: "Test role",
createdAt: new Date(),
updatedAt: new Date(),
};
}
return undefined;
}),
});
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore);
});
afterEach(() => {
vi.restoreAllMocks();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(defaultStore));
return app;
}
it("uses scoped settings for prompt resolution when projectId is provided", async () => {
const customPrompt = "CUSTOM SCOPED AGENT GENERATION PROMPT";
(scopedStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
promptOverrides: {
"agent-generation-system": customPrompt,
},
});
let capturedSystemPrompt: string | undefined;
const mockSession = {
state: { messages: [] },
prompt: vi.fn(async () => {
capturedSystemPrompt = "mock-prompt-captured";
}),
dispose: vi.fn(),
};
const mockAgent = {
session: mockSession,
};
// Mock createFnAgent at the module level for agent-generation
vi.doMock("@fusion/engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/engine")>();
return {
...actual,
createFnAgent: vi.fn(async () => mockAgent),
};
});
const res = await REQUEST(
buildApp(),
"POST",
`/api/agents/generate/spec?projectId=${projectId}`,
JSON.stringify({ sessionId: "test-session-id" }),
{ "Content-Type": "application/json" }
);
// Should use scoped store's settings for prompt resolution
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
expect(scopedStore.getSettings).toHaveBeenCalled();
vi.restoreAllMocks();
});
it("falls back to default prompt when scoped settings has no agent-generation-system override", async () => {
// Scoped settings with other overrides but not agent-generation-system
(scopedStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
promptOverrides: {
"executor-welcome": "Some other prompt",
},
});
const mockSession = {
state: { messages: [] },
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
};
const mockAgent = {
session: mockSession,
};
// Mock createFnAgent at the module level for agent-generation
vi.doMock("@fusion/engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/engine")>();
return {
...actual,
createFnAgent: vi.fn(async () => mockAgent),
};
});
const res = await REQUEST(
buildApp(),
"POST",
`/api/agents/generate/spec?projectId=${projectId}`,
JSON.stringify({ sessionId: "test-session-id" }),
{ "Content-Type": "application/json" }
);
// Should use scoped store's settings (which will fall back to default)
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
expect(scopedStore.getSettings).toHaveBeenCalled();
vi.restoreAllMocks();
});
});
// ── Workflow Step Template Tests ──────────────────────────────────────────
describe("GET /workflow-step-templates", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns all built-in templates", async () => {
const res = await GET(buildApp(), "/api/workflow-step-templates");
expect(res.status).toBe(200);
expect(res.body.templates).toBeDefined();
expect(Array.isArray(res.body.templates)).toBe(true);
expect(res.body.templates.length).toBeGreaterThanOrEqual(5);
// Check that templates have required fields
for (const template of res.body.templates) {
expect(template.id).toBeDefined();
expect(template.name).toBeDefined();
expect(template.description).toBeDefined();
expect(template.category).toBeDefined();
expect(template.prompt).toBeDefined();
}
});
it("includes expected template IDs", async () => {
const res = await GET(buildApp(), "/api/workflow-step-templates");
expect(res.status).toBe(200);
const ids = res.body.templates.map((t: { id: string }) => t.id);
expect(ids).toContain("documentation-review");
expect(ids).toContain("qa-check");
expect(ids).toContain("security-audit");
expect(ids).toContain("performance-review");
expect(ids).toContain("accessibility-check");
expect(ids).toContain("browser-verification");
expect(ids).toContain("frontend-ux-design");
});
});
describe("POST /workflow-step-templates/:id/create", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("creates workflow step from template", async () => {
const created = {
id: "WS-001",
name: "Documentation Review",
description: "Verify all public APIs, functions, and complex logic have appropriate documentation",
prompt: expect.stringContaining("documentation reviewer"),
enabled: true,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
};
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/documentation-review/create", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(201);
expect(res.body.id).toBe("WS-001");
expect(res.body.name).toBe("Documentation Review");
expect(store.createWorkflowStep).toHaveBeenCalledWith({
templateId: "documentation-review",
name: "Documentation Review",
description: "Verify all public APIs, functions, and complex logic have appropriate documentation",
prompt: expect.stringContaining("documentation reviewer"),
toolMode: "readonly",
enabled: true,
});
});
it("creates workflow step from qa-check template", async () => {
const created = {
id: "WS-002",
name: "QA Check",
description: "Run lint, tests, and typecheck; verify they pass and check for obvious bugs",
prompt: expect.stringContaining("QA tester"),
enabled: true,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
};
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/qa-check/create", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(201);
expect(res.body.name).toBe("QA Check");
expect(store.createWorkflowStep).toHaveBeenCalledWith({
templateId: "qa-check",
name: "QA Check",
description: "Run lint, tests, and typecheck; verify they pass and check for obvious bugs",
prompt: expect.stringContaining("QA tester"),
toolMode: "coding",
enabled: true,
});
});
it("creates workflow step from frontend-ux-design template", async () => {
const created = {
id: "WS-003",
name: "Frontend UX Design",
description: "Verify visual polish and consistency with existing UI patterns and design tokens",
prompt: expect.stringContaining("UX design reviewer"),
toolMode: "readonly",
enabled: true,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
};
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/frontend-ux-design/create", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(201);
expect(res.body.name).toBe("Frontend UX Design");
expect(res.body.toolMode).toBe("readonly");
expect(store.createWorkflowStep).toHaveBeenCalledWith({
templateId: "frontend-ux-design",
name: "Frontend UX Design",
description: "Verify visual polish and consistency with existing UI patterns and design tokens",
prompt: expect.stringContaining("UX design reviewer"),
toolMode: "readonly",
enabled: true,
});
});
it("returns 404 for non-existent template", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/nonexistent/create", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("returns 409 when workflow step with same name already exists", async () => {
const existingSteps = [
{ id: "WS-001", name: "Documentation Review", description: "Check docs", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
];
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce(existingSteps);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/documentation-review/create", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(409);
expect(res.body.error).toContain("already exists");
});
});
describe("Agent create/update routes", () => {
let tempDir: string;
let fusionDir: string;
let agentId: string;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agents-fields-"));
fusionDir = join(tempDir, ".fusion");
mkdirSync(fusionDir, { recursive: true });
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const agent = await agentStore.createAgent({
name: "Initial Agent",
role: "executor",
});
agentId = agent.id;
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function buildAgentApp() {
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("POST /api/agents accepts all AgentCreateInput fields", async () => {
const res = await REQUEST(
buildAgentApp(),
"POST",
"/api/agents",
JSON.stringify({
name: "Full Agent",
role: "reviewer",
metadata: { team: "qa" },
title: "QA Reviewer",
icon: "🧪",
reportsTo: agentId,
runtimeConfig: { heartbeatIntervalMs: 60000 },
permissions: { read: true },
instructionsPath: "docs/reviewer.md",
instructionsText: "Check test quality.",
soul: "Analytical and thorough.",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
name: "Full Agent",
role: "reviewer",
metadata: { team: "qa" },
title: "QA Reviewer",
icon: "🧪",
reportsTo: agentId,
runtimeConfig: { heartbeatIntervalMs: 60000 },
permissions: { read: true },
instructionsPath: "docs/reviewer.md",
instructionsText: "Check test quality.",
soul: "Analytical and thorough.",
});
});
it("PATCH /api/agents/:id accepts all AgentUpdateInput fields", async () => {
const res = await REQUEST(
buildAgentApp(),
"PATCH",
`/api/agents/${agentId}`,
JSON.stringify({
name: "Updated Agent",
role: "engineer",
metadata: { area: "infra" },
title: "Infra Engineer",
icon: "⚙️",
reportsTo: "agent-parent",
runtimeConfig: { heartbeatTimeoutMs: 120000 },
pauseReason: "manual",
permissions: { deploy: true },
totalInputTokens: 42,
totalOutputTokens: 21,
instructionsPath: "agents/infra.md",
instructionsText: "Focus on reliability.",
soul: "Pragmatic and efficient.",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
id: agentId,
name: "Updated Agent",
role: "engineer",
metadata: { area: "infra" },
title: "Infra Engineer",
icon: "⚙️",
reportsTo: "agent-parent",
runtimeConfig: { heartbeatTimeoutMs: 120000 },
pauseReason: "manual",
permissions: { deploy: true },
totalInputTokens: 42,
totalOutputTokens: 21,
instructionsPath: "agents/infra.md",
instructionsText: "Focus on reliability.",
soul: "Pragmatic and efficient.",
});
});
it("POST /api/agents returns 400 when soul exceeds 10,000 characters", async () => {
const longSoul = "x".repeat(10001);
const res = await REQUEST(
buildAgentApp(),
"POST",
"/api/agents",
JSON.stringify({
name: "Soul Test Agent",
role: "executor",
soul: longSoul,
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("soul must be at most 10,000 characters");
});
it("PATCH /api/agents/:id returns 400 when soul exceeds 10,000 characters", async () => {
const longSoul = "x".repeat(10001);
const res = await REQUEST(
buildAgentApp(),
"PATCH",
`/api/agents/${agentId}`,
JSON.stringify({
soul: longSoul,
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("soul must be at most 10,000 characters");
});
it("POST /api/agents/:id/state pauses successfully without heartbeat monitor wiring", async () => {
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.updateAgentState(agentId, "active");
const res = await REQUEST(
buildAgentApp(),
"POST",
`/api/agents/${agentId}/state`,
JSON.stringify({ state: "paused" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: agentId, state: "paused" });
const updatedAgent = await agentStore.getAgent(agentId);
expect(updatedAgent?.state).toBe("paused");
});
it("POST /api/agents/:id/state returns 400 for invalid state transitions", async () => {
const res = await REQUEST(
buildAgentApp(),
"POST",
`/api/agents/${agentId}/state`,
JSON.stringify({ state: "terminated" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid state transition");
});
});
describe("POST /api/agents/:id/runs", () => {
let tempDir: string;
let fusionDir: string;
let agentId: string;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agent-runs-"));
fusionDir = join(tempDir, ".fusion");
mkdirSync(fusionDir, { recursive: true });
// Create a real agent in the temp directory so AgentStore can find it
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const agent = await agentStore.createAgent({
name: "Test Agent",
role: "executor",
});
agentId = agent.id;
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function buildApp() {
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 201 with created run for valid agent", async () => {
const res = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
id: expect.stringMatching(/^run-/),
agentId,
status: "active",
endedAt: null,
invocationSource: "on_demand",
});
expect(res.body.startedAt).toBeTruthy();
});
it("persists the run via saveRun", async () => {
await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
// Verify run was persisted to filesystem
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const runs = await agentStore.getRecentRuns(agentId);
expect(runs).toHaveLength(1);
expect(runs[0].invocationSource).toBe("on_demand");
});
it("returns 404 for non-existent agent", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/agents/agent-nonexistent/runs");
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("uses default invocationSource when no body provided", async () => {
const res = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
expect(res.status).toBe(201);
expect(res.body.invocationSource).toBe("on_demand");
});
it("uses custom source and triggerDetail from body", async () => {
const res = await REQUEST(
buildApp(),
"POST",
`/api/agents/${agentId}/runs`,
JSON.stringify({ source: "timer", triggerDetail: "cron schedule" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(res.body.invocationSource).toBe("timer");
expect(res.body.triggerDetail).toBe("cron schedule");
});
it("returns 500 on store error", async () => {
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue("/nonexistent/path/that/does/not/exist"),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// This should hit an error because the agent doesn't exist in that path
const res = await REQUEST(app, "POST", `/api/agents/${agentId}/runs`);
expect(res.status).toBe(500);
});
it("accepts taskId in body and includes it in contextSnapshot", async () => {
const res = await REQUEST(
buildApp(),
"POST",
`/api/agents/${agentId}/runs`,
JSON.stringify({ source: "on_demand", triggerDetail: "manual", taskId: "FN-001" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(res.body.contextSnapshot).toMatchObject({
wakeReason: "on_demand",
triggerDetail: "manual",
taskId: "FN-001",
});
});
it("accepts triggering comment wake fields and persists them in contextSnapshot", async () => {
const res = await REQUEST(
buildApp(),
"POST",
`/api/agents/${agentId}/runs`,
JSON.stringify({
source: "on_demand",
triggerDetail: "task-comment",
taskId: "FN-001",
triggeringCommentIds: ["c1", "c2"],
triggeringCommentType: "task",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(res.body.contextSnapshot).toMatchObject({
wakeReason: "on_demand",
triggerDetail: "task-comment",
taskId: "FN-001",
triggeringCommentIds: ["c1", "c2"],
triggeringCommentType: "task",
});
});
it("returns 400 when triggeringCommentIds is not an array", async () => {
const res = await REQUEST(
buildApp(),
"POST",
`/api/agents/${agentId}/runs`,
JSON.stringify({ triggeringCommentIds: "not-an-array" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("triggeringCommentIds must be an array of strings");
});
it("returns 400 when triggeringCommentIds contains non-string values", async () => {
const res = await REQUEST(
buildApp(),
"POST",
`/api/agents/${agentId}/runs`,
JSON.stringify({ triggeringCommentIds: ["c1", 42] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("triggeringCommentIds must be an array of strings");
});
it("returns 400 when triggeringCommentType is invalid", async () => {
const res = await REQUEST(
buildApp(),
"POST",
`/api/agents/${agentId}/runs`,
JSON.stringify({ triggeringCommentType: "invalid" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("triggeringCommentType must be one of: steering, task, pr");
});
it("includes wake context without taskId when not provided", async () => {
const res = await REQUEST(
buildApp(),
"POST",
`/api/agents/${agentId}/runs`,
JSON.stringify({ source: "timer", triggerDetail: "scheduled" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(res.body.contextSnapshot).toMatchObject({
wakeReason: "timer",
triggerDetail: "scheduled",
});
expect(res.body.contextSnapshot.taskId).toBeUndefined();
});
it("returns 409 when agent already has an active run", async () => {
// Create first run
const res1 = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
expect(res1.status).toBe(201);
// Try to create second run — should conflict
const res2 = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
expect(res2.status).toBe(409);
expect(res2.body.error).toContain("active run");
expect(res2.body.details?.runId).toBeTruthy();
});
it("returns 201 again after a prior run is completed via stop", async () => {
// Create first run
const res1 = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
expect(res1.status).toBe(201);
const runId1 = res1.body.id;
// Stop the run
const stopRes = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs/stop`);
expect(stopRes.status).toBe(200);
expect(stopRes.body.runId).toBe(runId1);
// Create second run — should succeed now that first is complete
const res2 = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
expect(res2.status).toBe(201);
expect(res2.body.id).not.toBe(runId1);
expect(res2.body.status).toBe("active");
});
it("returns 201 again after a prior run is completed via AgentStore.endHeartbeatRun", async () => {
// Create first run
const res1 = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
expect(res1.status).toBe(201);
const runId1 = res1.body.id;
// Complete the run directly via AgentStore
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.endHeartbeatRun(runId1, "completed");
// Verify run is completed
const activeRun = await agentStore.getActiveHeartbeatRun(agentId);
expect(activeRun).toBeNull();
// Create second run — should succeed now that first is complete
const res2 = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
expect(res2.status).toBe(201);
expect(res2.body.id).not.toBe(runId1);
expect(res2.body.status).toBe("active");
});
});
describe("GET /api/agents/:id/runs/:runId/logs", () => {
let tempDir: string;
let fusionDir: string;
let agentId: string;
let taskId: string;
let runId: string;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agent-run-logs-"));
fusionDir = join(tempDir, ".fusion");
mkdirSync(fusionDir, { recursive: true });
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const agent = await agentStore.createAgent({
name: "Test Agent",
role: "executor",
});
agentId = agent.id;
// Start a run, complete it, and record its ID
const run = await agentStore.startHeartbeatRun(agentId);
runId = run.id;
// End the run with a context snapshot containing a taskId
run.endedAt = new Date().toISOString();
run.status = "completed";
run.contextSnapshot = { taskId: "FN-001", projectId: "test-project" };
await agentStore.saveRun(run);
taskId = "FN-001";
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function buildApp() {
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([
{
timestamp: "2024-01-01T00:01:00.000Z",
taskId: "FN-001",
text: "Starting task execution",
type: "text",
},
{
timestamp: "2024-01-01T00:02:00.000Z",
taskId: "FN-001",
text: "Read file: src/index.ts",
type: "tool",
},
]),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns logs for a valid run with contextSnapshot.taskId", async () => {
const res = await REQUEST(buildApp(), "GET", `/api/agents/${agentId}/runs/${runId}/logs`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.body[0]).toMatchObject({ taskId: "FN-001", type: "text" });
expect(res.body[1]).toMatchObject({ taskId: "FN-001", type: "tool" });
});
it("returns synthesized logs for run without contextSnapshot.taskId", async () => {
// Create a run without contextSnapshot
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const run = await agentStore.startHeartbeatRun(agentId);
run.endedAt = new Date().toISOString();
run.status = "completed";
run.stdoutExcerpt = "Ambient heartbeat completed";
// No contextSnapshot
await agentStore.saveRun(run);
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
const res = await REQUEST(app, "GET", `/api/agents/${agentId}/runs/${run.id}/logs`);
expect(res.status).toBe(200);
expect(res.body).toEqual([
expect.objectContaining({
taskId: "agent-run",
type: "text",
text: "Ambient heartbeat completed",
}),
]);
});
it("returns 404 for non-existent run", async () => {
const res = await REQUEST(buildApp(), "GET", `/api/agents/${agentId}/runs/run-nonexistent/logs`);
expect(res.status).toBe(404);
expect(res.body.error).toContain("Run not found");
});
it("returns 404 for non-existent agent", async () => {
const res = await REQUEST(buildApp(), "GET", `/api/agents/agent-nonexistent/runs/${runId}/logs`);
expect(res.status).toBe(404);
});
it("handles store errors gracefully", async () => {
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue("/nonexistent/path"),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
const res = await REQUEST(app, "GET", `/api/agents/${agentId}/runs/${runId}/logs`);
// Either 404 or 500 depending on where the error occurs
expect([404, 500]).toContain(res.status);
});
});
describe("Agent Budget routes", () => {
let tempDir: string;
let fusionDir: string;
let agentId: string;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-budget-"));
fusionDir = join(tempDir, ".fusion");
mkdirSync(fusionDir, { recursive: true });
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const agent = await agentStore.createAgent({
name: "Budget Test Agent",
role: "executor",
});
agentId = agent.id;
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function buildApp() {
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
describe("GET /api/agents/:id/budget", () => {
it("returns budget status with no-limit when budget not configured", async () => {
const res = await GET(buildApp(), `/api/agents/${agentId}/budget`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
agentId,
currentUsage: 0,
budgetLimit: null,
usagePercent: null,
thresholdPercent: null,
isOverBudget: false,
isOverThreshold: false,
lastResetAt: null,
nextResetAt: null,
});
});
it("returns budget status with values when budget is configured", async () => {
// Configure budget via PATCH
const patchRes = await REQUEST(
buildApp(),
"PATCH",
`/api/agents/${agentId}`,
JSON.stringify({
runtimeConfig: {
budgetConfig: {
tokenBudget: 100000,
usageThreshold: 0.8,
budgetPeriod: "lifetime",
},
},
}),
{ "Content-Type": "application/json" },
);
expect(patchRes.status).toBe(200);
const res = await GET(buildApp(), `/api/agents/${agentId}/budget`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
agentId,
currentUsage: 0,
budgetLimit: 100000,
usagePercent: 0,
thresholdPercent: 80, // stored as percentage (0.8 * 100)
isOverBudget: false,
isOverThreshold: false,
lastResetAt: null,
nextResetAt: null,
});
});
it("returns 404 for nonexistent agent", async () => {
const res = await GET(buildApp(), "/api/agents/nonexistent/budget");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Agent not found");
});
it("reflects over-threshold status correctly", async () => {
// Configure budget and set tokens to 85% usage
await REQUEST(
buildApp(),
"PATCH",
`/api/agents/${agentId}`,
JSON.stringify({
runtimeConfig: {
budgetConfig: {
tokenBudget: 100000,
usageThreshold: 0.8,
budgetPeriod: "lifetime",
},
},
totalInputTokens: 42500,
totalOutputTokens: 42500,
}),
{ "Content-Type": "application/json" },
);
const res = await GET(buildApp(), `/api/agents/${agentId}/budget`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
agentId,
currentUsage: 85000,
budgetLimit: 100000,
usagePercent: 85,
thresholdPercent: 80, // stored as percentage (0.8 * 100)
isOverBudget: false,
isOverThreshold: true, // 85% >= 80%
});
});
it("reflects over-budget status correctly", async () => {
// Configure budget and set tokens to 110% usage
await REQUEST(
buildApp(),
"PATCH",
`/api/agents/${agentId}`,
JSON.stringify({
runtimeConfig: {
budgetConfig: {
tokenBudget: 100000,
usageThreshold: 0.8,
budgetPeriod: "lifetime",
},
},
totalInputTokens: 55000,
totalOutputTokens: 55000,
}),
{ "Content-Type": "application/json" },
);
const res = await GET(buildApp(), `/api/agents/${agentId}/budget`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
agentId,
currentUsage: 110000,
budgetLimit: 100000,
usagePercent: 100, // Clamped to 100
thresholdPercent: 80, // stored as percentage (0.8 * 100)
isOverBudget: true, // 110000 >= 100000
isOverThreshold: true,
});
});
it("computes nextResetAt for daily budget period", async () => {
await REQUEST(
buildApp(),
"PATCH",
`/api/agents/${agentId}`,
JSON.stringify({
runtimeConfig: {
budgetConfig: {
tokenBudget: 100000,
usageThreshold: 0.8,
budgetPeriod: "daily",
},
},
}),
{ "Content-Type": "application/json" },
);
const res = await GET(buildApp(), `/api/agents/${agentId}/budget`);
expect(res.status).toBe(200);
expect(res.body.nextResetAt).toBeTruthy();
expect(new Date(res.body.nextResetAt).getTime()).toBeGreaterThan(Date.now());
});
});
describe("POST /api/agents/:id/budget/reset", () => {
it("resets token counters and sets budgetResetAt", async () => {
// First set some tokens
await REQUEST(
buildApp(),
"PATCH",
`/api/agents/${agentId}`,
JSON.stringify({
runtimeConfig: {
budgetConfig: {
tokenBudget: 100000,
usageThreshold: 0.8,
},
},
totalInputTokens: 50000,
totalOutputTokens: 30000,
}),
{ "Content-Type": "application/json" },
);
// Verify pre-reset state
const preRes = await GET(buildApp(), `/api/agents/${agentId}/budget`);
expect(preRes.body.currentUsage).toBe(80000);
// Reset budget
const resetRes = await REQUEST(
buildApp(),
"POST",
`/api/agents/${agentId}/budget/reset`,
undefined,
{},
);
expect(resetRes.status).toBe(200);
expect(resetRes.body).toEqual({ success: true });
// Verify post-reset state
const postRes = await GET(buildApp(), `/api/agents/${agentId}/budget`);
expect(postRes.body.currentUsage).toBe(0);
expect(postRes.body.lastResetAt).toBeTruthy();
expect(new Date(postRes.body.lastResetAt).getTime()).toBeGreaterThanOrEqual(
Date.now() - 5000, // Allow 5 second tolerance
);
});
it("returns 404 for nonexistent agent", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/agents/nonexistent/budget/reset",
undefined,
{},
);
expect(res.status).toBe(404);
expect(res.body.error).toContain("Agent not found");
});
it("allows reset when agent has no budget configured", async () => {
const res = await REQUEST(
buildApp(),
"POST",
`/api/agents/${agentId}/budget/reset`,
undefined,
{},
);
// Reset should succeed even without budget configured
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
});
it("clears usage percent back to zero after reset", async () => {
// Set up with budget and usage
await REQUEST(
buildApp(),
"PATCH",
`/api/agents/${agentId}`,
JSON.stringify({
runtimeConfig: {
budgetConfig: {
tokenBudget: 100000,
usageThreshold: 0.8,
},
},
totalInputTokens: 90000,
totalOutputTokens: 10000,
}),
{ "Content-Type": "application/json" },
);
await REQUEST(
buildApp(),
"POST",
`/api/agents/${agentId}/budget/reset`,
undefined,
{},
);
const res = await GET(buildApp(), `/api/agents/${agentId}/budget`);
expect(res.body.usagePercent).toBe(0);
expect(res.body.isOverThreshold).toBe(false);
expect(res.body.isOverBudget).toBe(false);
});
});
});
describe("Agent Reflection routes", () => {
let tempDir: string;
let fusionDir: string;
let agentId: string;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-reflection-"));
fusionDir = join(tempDir, ".fusion");
mkdirSync(fusionDir, { recursive: true });
// Create a real agent in the temp directory
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const agent = await agentStore.createAgent({
name: "Test Agent",
role: "executor",
});
agentId = agent.id;
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function buildApp() {
const store = createMockStore({
getRootDir: vi.fn().mockReturnValue(tempDir),
getFusionDir: vi.fn().mockReturnValue(fusionDir),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
afterEach(async () => {
const engine = await import("@fusion/engine");
const reflectionService =
"AgentReflectionService" in engine && typeof engine.AgentReflectionService === "function"
? (engine.AgentReflectionService as unknown as Parameters<typeof __setAgentReflectionServiceForTests>[0])
: undefined;
__setAgentReflectionServiceForTests(reflectionService);
});
describe("GET /api/agents/:id/reflections", () => {
it("returns 200 for valid agent (uses real stores)", async () => {
const res = await GET(buildApp(), `/api/agents/${agentId}/reflections`);
// The route uses real stores, so it should return an empty array (no reflections created yet)
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it("returns 404 for non-existent agent", async () => {
const res = await GET(buildApp(), "/api/agents/nonexistent-agent/reflections");
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("accepts limit query param", async () => {
const res = await GET(buildApp(), `/api/agents/${agentId}/reflections?limit=10`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
});
describe("GET /api/agents/:id/reflections/latest", () => {
it("returns 404 when no reflections exist", async () => {
const res = await GET(buildApp(), `/api/agents/${agentId}/reflections/latest`);
expect(res.status).toBe(404);
expect(res.body.error).toContain("No reflections found");
});
it("returns 404 for non-existent agent", async () => {
const res = await GET(buildApp(), "/api/agents/nonexistent-agent/reflections/latest");
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
});
describe("POST /api/agents/:id/reflections", () => {
it("returns 500 when reflection generation fails for an existing agent", async () => {
const res = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/reflections`);
expect(res.status).toBe(500);
expect(res.body.error).toMatch(/Unable to generate reflection|Reflection service unavailable/i);
}, 15000);
it("returns 500 with a clear message when reflection generation returns null", async () => {
const engine = await import("@fusion/engine");
__setAgentReflectionServiceForTests(engine.AgentReflectionService);
const generateReflectionSpy = vi
.spyOn(engine.AgentReflectionService.prototype, "generateReflection")
.mockResolvedValueOnce(null);
const res = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/reflections`);
expect(res.status).toBe(500);
expect(res.body.error).toContain("Unable to generate reflection");
generateReflectionSpy.mockRestore();
});
it("returns 404 for a non-existent agent", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/agents/nonexistent-agent/reflections");
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
});
describe("GET /api/agents/:id/performance", () => {
it("returns 200 with performance summary for valid agent", async () => {
const res = await GET(buildApp(), `/api/agents/${agentId}/performance`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("agentId");
expect(res.body).toHaveProperty("totalTasksCompleted");
expect(res.body).toHaveProperty("successRate");
});
it("accepts windowMs query param", async () => {
const res = await GET(buildApp(), `/api/agents/${agentId}/performance?windowMs=604800000`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("agentId");
});
it("returns 404 for non-existent agent", async () => {
const res = await GET(buildApp(), "/api/agents/nonexistent-agent/performance");
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
});
describe("GET /api/agents/:id/reflection-context", () => {
it("returns 503 when reflection service binding is unavailable", async () => {
__setAgentReflectionServiceForTests(undefined);
const res = await GET(buildApp(), `/api/agents/${agentId}/reflection-context`);
expect(res.status).toBe(503);
expect(res.body.error).toContain("Reflection service not available");
});
it("returns 200 when reflection context is available, otherwise 500/503", async () => {
const res = await GET(buildApp(), `/api/agents/${agentId}/reflection-context`);
expect([200, 500, 503]).toContain(res.status);
});
it("returns 404 or 500 for non-existent agent", async () => {
const res = await GET(buildApp(), "/api/agents/nonexistent-agent/reflection-context");
// The route either returns 404 (agent not found) or 500 (reflection service error)
expect([404, 500]).toContain(res.status);
});
});
it("does not create fusion.db or agents directory in project root for reflection endpoints", async () => {
const app = buildApp();
const rootDbPath = join(tempDir, "fusion.db");
const rootDbWalPath = join(tempDir, "fusion.db-wal");
const rootDbShmPath = join(tempDir, "fusion.db-shm");
const rootAgentsDir = join(tempDir, "agents");
expect(existsSync(rootDbPath)).toBe(false);
expect(existsSync(rootDbWalPath)).toBe(false);
expect(existsSync(rootDbShmPath)).toBe(false);
expect(existsSync(rootAgentsDir)).toBe(false);
const postRes = await REQUEST(app, "POST", `/api/agents/${agentId}/reflections`);
expect(postRes.status).toBe(500);
const contextRes = await GET(app, `/api/agents/${agentId}/reflection-context`);
expect([200, 500, 503]).toContain(contextRes.status);
expect(existsSync(rootDbPath)).toBe(false);
expect(existsSync(rootDbWalPath)).toBe(false);
expect(existsSync(rootDbShmPath)).toBe(false);
expect(existsSync(rootAgentsDir)).toBe(false);
});
});
// ── AI Refine Text Route with Scoped Settings ────────────────────────────────
describe("POST /api/ai/refine-text with projectId scoping", () => {
const projectId = "proj-refine-test";
let defaultStore: TaskStore;
let scopedStore: TaskStore;
beforeEach(() => {
defaultStore = createMockStore();
scopedStore = createMockStore();
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore);
});
afterEach(() => {
vi.restoreAllMocks();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(defaultStore));
return app;
}
it("uses scoped store when projectId is provided", async () => {
(scopedStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
promptOverrides: {
"ai-refine-system": "Custom AI refine prompt",
},
});
// The route will call refineText which requires AI engine
// We verify that scoped store is correctly used by checking settings was called
const res = await REQUEST(
buildApp(),
"POST",
`/api/ai/refine-text?projectId=${projectId}`,
JSON.stringify({ text: "Task description", type: "clarify" }),
{ "Content-Type": "application/json" }
);
// The route should call scoped store's getSettings (it may fail on AI call but settings was checked)
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
expect(scopedStore.getSettings).toHaveBeenCalled();
expect(scopedStore.getRootDir).toHaveBeenCalled();
});
it("returns 400 for missing text field", async () => {
const res = await REQUEST(
buildApp(),
"POST",
`/api/ai/refine-text?projectId=${projectId}`,
JSON.stringify({ type: "clarify" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("text is required");
});
it("returns 400 for missing type field", async () => {
const res = await REQUEST(
buildApp(),
"POST",
`/api/ai/refine-text?projectId=${projectId}`,
JSON.stringify({ text: "Some text" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("type is required");
});
it("returns 422 for invalid refinement type", async () => {
const res = await REQUEST(
buildApp(),
"POST",
`/api/ai/refine-text?projectId=${projectId}`,
JSON.stringify({ text: "Some text", type: "invalid-type" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(422);
expect(res.body.error).toContain("type must be one of");
});
it("returns 400 when text is empty", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/ai/refine-text",
JSON.stringify({ text: "", type: "clarify" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("at least 1 character");
});
it("returns 400 when text exceeds 2000 characters", async () => {
const longText = "a".repeat(2001);
const res = await REQUEST(
buildApp(),
"POST",
"/api/ai/refine-text",
JSON.stringify({ text: longText, type: "clarify" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("not exceed 2000 characters");
});
});
describe("Messaging Routes", () => {
let rootDir: string;
let store: TaskStore;
let app: express.Express;
let messageStore: import("@fusion/core").MessageStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "kb-message-routes-"));
const { TaskStore, MessageStore } = await import("@fusion/core");
store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"), { inMemoryDb: true });
await store.init();
messageStore = new MessageStore(store.getDatabase());
app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
it("uses the engine MessageStore when available", async () => {
const message = {
id: "msg-runtime-1",
fromId: "dashboard",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "runtime store message",
type: "user-to-agent",
read: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const runtimeMessageStore = {
sendMessage: vi.fn().mockReturnValue(message),
};
const runtimeEngine = {
getMessageStore: vi.fn().mockReturnValue(runtimeMessageStore),
};
const runtimeApp = express();
runtimeApp.use(express.json());
runtimeApp.use("/api", createApiRoutes(store, { engine: runtimeEngine as any }));
const res = await REQUEST(
runtimeApp,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-1",
toType: "agent",
content: "runtime store message",
type: "user-to-agent",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(runtimeEngine.getMessageStore).toHaveBeenCalled();
expect(runtimeMessageStore.sendMessage).toHaveBeenCalledWith({
fromId: "dashboard",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "runtime store message",
type: "user-to-agent",
metadata: undefined,
});
expect(res.body.id).toBe("msg-runtime-1");
});
it("GET /api/messages/inbox returns dashboard inbox messages", async () => {
const inboxMessage = messageStore.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "Hello dashboard",
type: "agent-to-user",
});
messageStore.sendMessage({
fromId: "agent-2",
fromType: "agent",
toId: "someone-else",
toType: "user",
content: "not for dashboard",
type: "agent-to-user",
});
const res = await GET(app, "/api/messages/inbox");
expect(res.status).toBe(200);
expect(res.body.messages).toHaveLength(1);
expect(res.body.messages[0].id).toBe(inboxMessage.id);
expect(res.body.unreadCount).toBe(1);
});
it("GET /api/messages/outbox returns dashboard sent messages", async () => {
const sent = await REQUEST(
app,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-7",
toType: "agent",
content: "Can you review this?",
type: "user-to-agent",
}),
{ "Content-Type": "application/json" },
);
expect(sent.status).toBe(201);
const res = await GET(app, "/api/messages/outbox");
expect(res.status).toBe(200);
expect(res.body.messages).toHaveLength(1);
expect(res.body.messages[0].id).toBe(sent.body.id);
expect(res.body.messages[0].fromId).toBe("dashboard");
});
it("GET /api/messages/unread-count returns the unread count", async () => {
const unread = messageStore.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "Unread",
type: "agent-to-user",
});
const read = messageStore.sendMessage({
fromId: "agent-2",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "Read",
type: "agent-to-user",
});
messageStore.markAsRead(read.id);
const res = await GET(app, "/api/messages/unread-count");
expect(unread).toBeDefined();
expect(res.status).toBe(200);
expect(res.body.unreadCount).toBe(1);
});
it("POST /api/messages validates required fields and creates messages", async () => {
const created = await REQUEST(
app,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-3",
toType: "agent",
content: "Need your help",
type: "user-to-agent",
}),
{ "Content-Type": "application/json" },
);
expect(created.status).toBe(201);
expect(created.body.toId).toBe("agent-3");
expect(created.body.fromId).toBe("dashboard");
const invalidCases = [
{ body: { toType: "agent", content: "x", type: "user-to-agent" }, message: "toId is required" },
{ body: { toId: "agent-1", content: "x", type: "user-to-agent", toType: "bad" }, message: "toType must be one of" },
{ body: { toId: "agent-1", toType: "agent", content: "", type: "user-to-agent" }, message: "content is required" },
{ body: { toId: "agent-1", toType: "agent", content: "a".repeat(2001), type: "user-to-agent" }, message: "content is required" },
{ body: { toId: "agent-1", toType: "agent", content: "x", type: "bad-type" }, message: "type must be one of" },
{ body: { toId: "agent-1", toType: "agent", content: "x", type: "user-to-agent", metadata: "bad" }, message: "metadata must be an object" },
{
body: {
toId: "agent-1",
toType: "agent",
content: "x",
type: "user-to-agent",
metadata: { replyTo: { messageId: "" } },
},
message: "metadata.replyTo.messageId must be a non-empty string",
},
];
for (const testCase of invalidCases) {
const res = await REQUEST(
app,
"POST",
"/api/messages",
JSON.stringify(testCase.body),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(String(res.body.error)).toContain(testCase.message);
}
});
it("preserves reply metadata for dashboard-sent messages", async () => {
const original = messageStore.sendMessage({
fromId: "agent-3",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "Lookup me",
type: "agent-to-user",
});
const created = await REQUEST(
app,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-3",
toType: "agent",
content: "Replying now",
type: "user-to-agent",
metadata: { replyTo: { messageId: original.id } },
}),
{ "Content-Type": "application/json" },
);
expect(created.status).toBe(201);
expect(created.body.metadata).toEqual({ replyTo: { messageId: original.id } });
const outbox = await GET(app, "/api/messages/outbox");
const sentReply = outbox.body.messages.find((msg: { id: string }) => msg.id === created.body.id);
expect(sentReply?.metadata).toEqual({ replyTo: { messageId: original.id } });
});
it("GET /api/messages/:id returns a message and 404 when missing", async () => {
const msg = messageStore.sendMessage({
fromId: "agent-3",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "Lookup me",
type: "agent-to-user",
});
const found = await GET(app, `/api/messages/${msg.id}`);
const missing = await GET(app, "/api/messages/msg-missing");
expect(found.status).toBe(200);
expect(found.body.id).toBe(msg.id);
expect(missing.status).toBe(404);
});
it("POST /api/messages/:id/read marks the message as read", async () => {
const msg = messageStore.sendMessage({
fromId: "agent-4",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "Mark me as read",
type: "agent-to-user",
});
const res = await REQUEST(app, "POST", `/api/messages/${msg.id}/read`);
expect(res.status).toBe(200);
expect(res.body.read).toBe(true);
expect(messageStore.getMessage(msg.id)?.read).toBe(true);
});
it("DELETE /api/messages/:id deletes the message", async () => {
const msg = messageStore.sendMessage({
fromId: "agent-5",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "Delete me",
type: "agent-to-user",
});
const res = await REQUEST(app, "DELETE", `/api/messages/${msg.id}`);
expect(res.status).toBe(204);
expect(messageStore.getMessage(msg.id)).toBeNull();
});
it("POST /api/messages/read-all marks all dashboard inbox messages as read", async () => {
messageStore.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "1",
type: "agent-to-user",
});
messageStore.sendMessage({
fromId: "agent-2",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "2",
type: "agent-to-user",
});
const res = await REQUEST(app, "POST", "/api/messages/read-all");
const unread = await GET(app, "/api/messages/unread-count");
expect(res.status).toBe(200);
expect(res.body.markedAsRead).toBe(2);
expect(unread.body.unreadCount).toBe(0);
});
it("GET /api/messages/conversation/:participantType/:participantId returns the conversation thread", async () => {
const outbound = messageStore.sendMessage({
fromId: "dashboard",
fromType: "user",
toId: "agent-convo",
toType: "agent",
content: "Question",
type: "user-to-agent",
});
const inbound = messageStore.sendMessage({
fromId: "agent-convo",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "Answer",
type: "agent-to-user",
});
messageStore.sendMessage({
fromId: "agent-other",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "Other thread",
type: "agent-to-user",
});
const res = await GET(app, "/api/messages/conversation/agent/agent-convo");
expect(res.status).toBe(200);
const ids = res.body.map((m: { id: string }) => m.id);
expect(ids).toContain(outbound.id);
expect(ids).toContain(inbound.id);
expect(ids).toHaveLength(2);
});
it("GET /api/agents/:id/mailbox returns mailbox summary and inbox messages", async () => {
const agentId = "agent-mailbox";
const msg = messageStore.sendMessage({
fromId: "dashboard",
fromType: "user",
toId: agentId,
toType: "agent",
content: "Ping",
type: "user-to-agent",
});
const res = await GET(app, `/api/agents/${agentId}/mailbox`);
expect(res.status).toBe(200);
expect(res.body.ownerId).toBe(agentId);
expect(res.body.ownerType).toBe("agent");
expect(res.body.unreadCount).toBe(1);
expect(res.body.messages).toHaveLength(1);
expect(res.body.messages[0].id).toBe(msg.id);
});
});
// Note: Project pause/resume route tests are in src/__tests__/project-pause-resume-routes.test.ts
// to avoid test isolation issues with vi.restoreAllMocks() from other tests in routes.test.ts
describe("Agent stale task-link sanitization", () => {
let tempDir: string;
let fusionDir: string;
let agentId: string;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agent-stale-"));
fusionDir = join(tempDir, ".fusion");
mkdirSync(fusionDir, { recursive: true });
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const agent = await agentStore.createAgent({
name: "Test Agent",
role: "executor",
});
agentId = agent.id;
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function buildAgentApp() {
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("GET /api/agents omits taskId when linked task is done", async () => {
const doneTaskId = "FN-DONE";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockResolvedValue({
id: doneTaskId,
column: "done",
description: "Completed task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Done\n\nDone task",
}),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Assign done task to agent
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.assignTask(agentId, doneTaskId);
const res = await GET(app, "/api/agents");
expect(res.status).toBe(200);
const agents = Array.isArray(res.body) ? res.body : [res.body];
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
expect(testAgent).toBeDefined();
expect(testAgent).not.toHaveProperty("taskId");
});
it("GET /api/agents omits taskId when linked task is archived", async () => {
const archivedTaskId = "FN-ARCHIVED";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockResolvedValue({
id: archivedTaskId,
column: "archived",
description: "Archived task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Archived\n\nArchived task",
}),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Assign archived task to agent
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.assignTask(agentId, archivedTaskId);
const res = await GET(app, "/api/agents");
expect(res.status).toBe(200);
const agents = Array.isArray(res.body) ? res.body : [res.body];
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
expect(testAgent).toBeDefined();
expect(testAgent).not.toHaveProperty("taskId");
});
it("GET /api/agents preserves taskId for non-terminal linked tasks", async () => {
const activeTaskId = "FN-ACTIVE";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockResolvedValue({
id: activeTaskId,
column: "in-progress",
description: "Active task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Active\n\nActive task",
}),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Assign active task to agent
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.assignTask(agentId, activeTaskId);
const res = await GET(app, "/api/agents");
expect(res.status).toBe(200);
const agents = Array.isArray(res.body) ? res.body : [res.body];
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
expect(testAgent).toBeDefined();
expect(testAgent.taskId).toBe(activeTaskId);
});
it("GET /api/agents/:id omits taskId when linked task is done", async () => {
const doneTaskId = "FN-DONE-DETAIL";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockResolvedValue({
id: doneTaskId,
column: "done",
description: "Done task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Done\n\nDone task",
}),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Assign done task to agent
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.assignTask(agentId, doneTaskId);
const res = await GET(app, `/api/agents/${agentId}`);
expect(res.status).toBe(200);
expect(res.body).toBeDefined();
expect(res.body.id).toBe(agentId);
expect(res.body).not.toHaveProperty("taskId");
});
it("GET /api/agents/:id omits taskId when linked task is archived", async () => {
const archivedTaskId = "FN-ARCHIVED-DETAIL";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockResolvedValue({
id: archivedTaskId,
column: "archived",
description: "Archived task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Archived\n\nArchived task",
}),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Assign archived task to agent
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.assignTask(agentId, archivedTaskId);
const res = await GET(app, `/api/agents/${agentId}`);
expect(res.status).toBe(200);
expect(res.body).toBeDefined();
expect(res.body.id).toBe(agentId);
expect(res.body).not.toHaveProperty("taskId");
});
it("GET /api/agents/:id preserves taskId for in-review linked tasks", async () => {
const inReviewTaskId = "FN-IN-REVIEW";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockResolvedValue({
id: inReviewTaskId,
column: "in-review",
description: "In review task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# In Review\n\nIn review task",
}),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Assign in-review task to agent
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.assignTask(agentId, inReviewTaskId);
const res = await GET(app, `/api/agents/${agentId}`);
expect(res.status).toBe(200);
expect(res.body).toBeDefined();
expect(res.body.id).toBe(agentId);
expect(res.body.taskId).toBe(inReviewTaskId);
});
it("GET /api/agents/stats excludes terminal task links from assignedTaskCount", async () => {
const doneTaskId = "FN-STATS-DONE";
const activeTaskId = "FN-STATS-ACTIVE";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockImplementation(async (taskId: string) => {
if (taskId === doneTaskId) {
return {
id: doneTaskId,
column: "done",
description: "Done task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Done\n\nDone task",
};
}
if (taskId === activeTaskId) {
return {
id: activeTaskId,
column: "in-progress",
description: "Active task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Active\n\nActive task",
};
}
return null;
}),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Create two agents: one with done task, one with active task
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const agent1 = await agentStore.createAgent({ name: "Agent 1", role: "executor" });
const agent2 = await agentStore.createAgent({ name: "Agent 2", role: "executor" });
await agentStore.assignTask(agent1.id, doneTaskId);
await agentStore.assignTask(agent2.id, activeTaskId);
const res = await GET(app, "/api/agents/stats");
expect(res.status).toBe(200);
// Only agent2 should count (active task), agent1 should be excluded (done task)
expect(res.body.assignedTaskCount).toBe(1);
});
it("GET /api/agents/stats preserves taskId for agents with no linked task", async () => {
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Agent already created in beforeEach with no task assigned
const res = await GET(app, "/api/agents/stats");
expect(res.status).toBe(200);
// Agent has no task, so assignedTaskCount should be 0
expect(res.body.assignedTaskCount).toBe(0);
});
it("GET /api/agents handles task lookup failure gracefully", async () => {
const taskId = "FN-LOOKUP-FAIL";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockRejectedValue(new Error("Database error")),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Assign task to agent
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.assignTask(agentId, taskId);
// Should not throw, taskId should be preserved on lookup failure
const res = await GET(app, "/api/agents");
expect(res.status).toBe(200);
const agents = Array.isArray(res.body) ? res.body : [res.body];
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
expect(testAgent).toBeDefined();
// On lookup failure, taskId should be preserved (treated as non-terminal)
expect(testAgent.taskId).toBe(taskId);
});
});
describe("GET /api/chat/sessions lookup=resume", () => {
function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id" | "agentId">): ChatSession {
const now = new Date().toISOString();
return {
id: overrides.id,
agentId: overrides.agentId,
title: overrides.title ?? null,
status: overrides.status ?? "active",
projectId: overrides.projectId ?? null,
modelProvider: overrides.modelProvider ?? null,
modelId: overrides.modelId ?? null,
createdAt: overrides.createdAt ?? now,
updatedAt: overrides.updatedAt ?? now,
};
}
function buildChatApp(overrides?: {
matchedSession?: ChatSession;
lastMessage?: Pick<ChatMessage, "sessionId" | "content" | "createdAt">;
}) {
const store = createMockStore();
const matchedSession = overrides?.matchedSession;
const chatStore = {
listSessions: vi.fn().mockReturnValue([]),
findLatestActiveSessionForTarget: vi.fn().mockReturnValue(matchedSession),
getLastMessageForSessions: vi.fn().mockImplementation((sessionIds: string[]) => {
const map = new Map<string, ChatMessage>();
if (overrides?.lastMessage && sessionIds.includes(overrides.lastMessage.sessionId)) {
const now = new Date().toISOString();
map.set(overrides.lastMessage.sessionId, {
id: "msg-1",
sessionId: overrides.lastMessage.sessionId,
role: "assistant",
content: overrides.lastMessage.content,
thinkingOutput: null,
metadata: null,
createdAt: overrides.lastMessage.createdAt ?? now,
});
}
return map;
}),
};
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { chatStore } as any));
return { app, chatStore };
}
it("returns only the targeted matched session when lookup=resume", async () => {
const matchedSession = makeSession({
id: "chat-match",
agentId: "agent-1",
projectId: "proj-1",
modelProvider: "openai",
modelId: "gpt-4o",
});
const { app, chatStore } = buildChatApp({
matchedSession,
lastMessage: {
sessionId: matchedSession.id,
content: "Most recent assistant reply",
createdAt: new Date().toISOString(),
},
});
const res = await GET(
app,
"/api/chat/sessions?lookup=resume&projectId=proj-1&agentId=agent-1&modelProvider=openai&modelId=gpt-4o",
);
expect(res.status).toBe(200);
expect(chatStore.findLatestActiveSessionForTarget).toHaveBeenCalledWith({
projectId: "proj-1",
agentId: "agent-1",
modelProvider: "openai",
modelId: "gpt-4o",
});
expect(chatStore.listSessions).not.toHaveBeenCalled();
expect(res.body.sessions).toHaveLength(1);
expect(res.body.sessions[0].id).toBe("chat-match");
expect(res.body.sessions[0].lastMessagePreview).toBe("Most recent assistant reply");
});
it("returns 400 when modelProvider/modelId are not both provided", async () => {
const { app } = buildChatApp();
const res = await GET(app, "/api/chat/sessions?lookup=resume&projectId=proj-1&agentId=agent-1&modelProvider=openai");
expect(res.status).toBe(400);
expect(res.body.error).toContain("Both modelProvider and modelId must be provided together");
});
it("returns 400 when lookup=resume is missing agentId", async () => {
const { app } = buildChatApp();
const res = await GET(app, "/api/chat/sessions?lookup=resume&projectId=proj-1");
expect(res.status).toBe(400);
expect(res.body.error).toContain("agentId is required when lookup=resume");
});
it("preserves list behavior when lookup parameter is absent", async () => {
const store = createMockStore();
const listedSession = makeSession({ id: "chat-listed", agentId: "agent-1" });
const chatStore = {
listSessions: vi.fn().mockReturnValue([listedSession]),
findLatestActiveSessionForTarget: vi.fn(),
getLastMessageForSessions: vi.fn().mockReturnValue(new Map()),
};
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { chatStore } as any));
const res = await GET(app, "/api/chat/sessions?status=active&agentId=agent-1");
expect(res.status).toBe(200);
expect(chatStore.listSessions).toHaveBeenCalledWith({ status: "active", agentId: "agent-1" });
expect(chatStore.findLatestActiveSessionForTarget).not.toHaveBeenCalled();
expect(res.body.sessions).toHaveLength(1);
expect(res.body.sessions[0].id).toBe("chat-listed");
});
});
describe("remote access auth login-url endpoints", () => {
let store: TaskStore;
const remoteAccessSettings = {
enabled: true,
activeProvider: "cloudflare",
providers: {
tailscale: {
enabled: false,
hostname: "tail.example.ts.net",
targetPort: 4040,
acceptRoutes: false,
},
cloudflare: {
enabled: true,
quickTunnel: false,
tunnelName: "tunnel",
tunnelToken: "cf-secret",
ingressUrl: "https://remote.example.com",
},
},
tokenStrategy: {
persistent: {
enabled: true,
token: null,
},
shortLived: {
enabled: true,
ttlMs: 120000,
maxTtlMs: 86400000,
},
},
lifecycle: {
rememberLastRunning: false,
wasRunningOnShutdown: false,
lastRunningProvider: null,
},
};
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
beforeEach(() => {
store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
remoteAccess: remoteAccessSettings,
});
});
it("creates persistent login URL payload and persists generated fallback token", async () => {
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
remoteAccess: {
...remoteAccessSettings,
tokenStrategy: {
...remoteAccessSettings.tokenStrategy,
persistent: {
...remoteAccessSettings.tokenStrategy.persistent,
token: "frt_generated_persistent",
},
},
},
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/remote-access/auth/login-url",
JSON.stringify({ mode: "persistent" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.tokenType).toBe("persistent");
const persistentLoginUrl = new URL(String(res.body.loginUrl));
expect(persistentLoginUrl.protocol).toBe("https:");
expect(persistentLoginUrl.host).toBe("remote.example.com");
expect(persistentLoginUrl.pathname).toBe("/remote-login");
expect(persistentLoginUrl.searchParams.get("rt")).toMatch(/^frt_[A-Za-z0-9_-]+$/);
expect(res.body.expiresAt).toBeUndefined();
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
remoteAccess: expect.objectContaining({
tokenStrategy: expect.objectContaining({
persistent: expect.objectContaining({
token: expect.any(String),
}),
}),
}),
}));
});
it("creates short-lived login URL payload with expiresAt", async () => {
const withPersistentToken = {
...remoteAccessSettings,
tokenStrategy: {
...remoteAccessSettings.tokenStrategy,
persistent: {
...remoteAccessSettings.tokenStrategy.persistent,
token: "frt_persistent",
},
},
};
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
remoteAccess: withPersistentToken,
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/remote-access/auth/login-url",
JSON.stringify({ mode: "short-lived" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.tokenType).toBe("short-lived");
expect(res.body.expiresAt).toEqual(expect.any(String));
const shortLivedUrl = new URL(String(res.body.loginUrl));
expect(shortLivedUrl.protocol).toBe("https:");
expect(shortLivedUrl.host).toBe("remote.example.com");
expect(shortLivedUrl.pathname).toBe("/remote-login");
expect(shortLivedUrl.searchParams.get("rt")).toMatch(/^frt_[A-Za-z0-9_-]+$/);
expect(Date.parse(String(res.body.expiresAt))).not.toBeNaN();
expect(JSON.stringify(res.body)).not.toContain("cf-secret");
expect(JSON.stringify(res.body)).not.toContain("frt_persistent");
});
it("rejects invalid login-url mode", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/remote-access/auth/login-url",
JSON.stringify({ mode: "legacy" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("mode must be");
expect(res.body.details).toEqual({ code: "INVALID_REMOTE_AUTH_MODE" });
});
});