import { describe, expect, it } from "vitest"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { loadEnv } from "../src/config/env.js"; import { compareAutoModeProjectRows, createProjectBrainMcpServer } from "../src/mcp/autonomyServer.js"; import { autonomyRetryDelayMinutes, canResumeAutonomyLease, classifyAutonomyBlocker, enqueueAutonomyTaskInState, evaluateAutonomyRunBudget, isAutonomyLeaseOwner, normalizeAutonomyState, parseRoadmapMarkdown, releaseActiveAutonomyLease, reorderAutonomyQueueInState, resetAutonomyQueueOrderInState, retryAutonomyBlockerIfDue } from "../src/repositories/autonomyRepository.js"; import { autonomyHeartbeatSlotId, compactAutonomyState, resolveAutonomyHeartbeat, summarizeAutonomyProject } from "../src/services/autonomyRuntime.js"; const env = loadEnv({ NODE_ENV: "test", DATABASE_URL: "postgresql://example.invalid/projectbrain", PROJECTBRAIN_DATA_SOURCE: "database", PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", PROJECTBRAIN_MCP_ENABLED: "true", PROJECTBRAIN_PUBLIC_URL: "https://projectbrain.example.com", PROJECTBRAIN_GITEA_URL: "https://gitea.example.com", PROJECTBRAIN_GITEA_TOKEN: "a-token-with-at-least-twenty-characters", }); describe("Auto Mode roadmap parser", () => { it("turns markdown phases and list items into ordered durable work", () => { const roadmap = parseRoadmapMarkdown(`# Product roadmap ## Fundament Maak de deliverylaag hervatbaar. - [x] Bestaande status importeren - [ ] Leasecontract toevoegen ## Product Voltooi de gebruikersflow. 1. Auto toggle bouwen 2. Voortgang tonen`); expect(roadmap.title).toBe("Product roadmap"); expect(roadmap.phases).toHaveLength(2); expect(roadmap.phases[0]).toMatchObject({ title: "Fundament", objective: "Maak de deliverylaag hervatbaar.", status: "active" }); expect(roadmap.phases[0].items.map((item) => [item.title, item.status])).toEqual([ ["Bestaande status importeren", "completed"], ["Leasecontract toevoegen", "pending"], ]); expect(roadmap.phases[1].status).toBe("pending"); expect(roadmap.phases[1].items.map((item) => item.title)).toEqual(["Auto toggle bouwen", "Voortgang tonen"]); }); it("accepts a flat task list by creating one execution phase", () => { const roadmap = parseRoadmapMarkdown("- Analyseer repo\n- Bouw oplossing\n- Valideer resultaat", "MCP automation"); expect(roadmap.title).toBe("MCP automation"); expect(roadmap.phases).toHaveLength(1); expect(roadmap.phases[0].title).toBe("Execution"); expect(roadmap.phases[0].items).toHaveLength(3); }); it("allows an empty phase so the brainstorm loop can discover its work", () => { const roadmap = parseRoadmapMarkdown("# Roadmap\n\n## Quality convergence\nZoek autonoom naar resterende gaten."); expect(roadmap.phases[0].items).toEqual([]); expect(roadmap.phases[0].brainstormConverged).toBe(false); expect(roadmap.phases[0].objective).toBe("Zoek autonoom naar resterende gaten."); }); }); describe("Auto Mode state safety", () => { it("keeps an hourly ChatGPT Chat executor fresh between scheduled runs", () => { expect(env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS).toBe(5_400); }); it("stores a user assignment durably even while Auto Mode is off", () => { const current = normalizeAutonomyState({}, "owner"); const queued = enqueueAutonomyTaskInState(current, { instruction: "Verbeter de mobiele navigatie\nmet regressietests." }, "owner"); expect(queued.state.status).toBe("off"); expect(queued.task).toMatchObject({ title: "Verbeter de mobiele navigatie", instruction: "Verbeter de mobiele navigatie\nmet regressietests.", source: "user_assignment", status: "pending", }); expect(queued.state.roadmap?.phases[0].items).toEqual([queued.task]); expect(queued.state.events.at(-1)?.type).toBe("autonomy.task.enqueued"); }); it("prioritizes a new user assignment in the current claimable phase instead of a phase already passed", () => { const roadmap = parseRoadmapMarkdown("## Eerder\n- [ ] Eerste opdracht\n\n## Huidig\n- [ ] Lopend werk"); roadmap.phases[0].id = "projectbrain-opdrachten-old"; roadmap.phases[0].status = "completed"; roadmap.phases[1].status = "active"; roadmap.currentPhaseIndex = 1; const current = normalizeAutonomyState({ enabled: true, status: "running", roadmap }, "owner"); const queued = enqueueAutonomyTaskInState(current, { instruction: "Nieuwe opdracht" }, "owner"); expect(queued.state.status).toBe("running"); expect(queued.state.roadmap?.phases).toHaveLength(2); expect(queued.state.roadmap?.phases[0].status).toBe("completed"); expect(queued.state.roadmap?.phases[1].items[0]).toEqual(queued.task); expect(queued.state.roadmap?.phases[1].items[1].title).toBe("Lopend werk"); }); it("persists an exact owner-defined order for pending executable work only", () => { const roadmap = parseRoadmapMarkdown("## Fase\n- [ ] A\n- [ ] B\n- [ ] C"); roadmap.phases[0].items[2].scope = "future"; const current = normalizeAutonomyState({ enabled: true, roadmap }, "owner"); const [a, b] = current.roadmap!.phases[0].items; const reordered = reorderAutonomyQueueInState(current, [b.id, a.id], "owner"); expect(reordered.roadmap?.phases[0].items[0].manualOrder).toBe(1); expect(reordered.roadmap?.phases[0].items[1].manualOrder).toBe(0); expect(reordered.roadmap?.phases[0].items[2].manualOrder).toBeUndefined(); expect(reordered.events.at(-1)?.type).toBe("autonomy.queue.reordered"); expect(() => reorderAutonomyQueueInState(current, [a.id], "owner")).toThrow("AUTO_MODE_QUEUE_CHANGED"); const automatic = resetAutonomyQueueOrderInState(reordered, "owner"); expect(automatic.roadmap?.phases.flatMap((phase) => phase.items).every((item) => item.manualOrder === undefined)).toBe(true); expect(automatic.events.at(-1)?.type).toBe("autonomy.queue.order_reset"); }); it("derives the public status from durable facts instead of stale stored copy", () => { expect(normalizeAutonomyState({ enabled: true, status: "running" }).status).toBe("waiting_roadmap"); expect(normalizeAutonomyState({}).profile).toBe("normal"); expect(normalizeAutonomyState({ profile: "unsupported" }).profile).toBe("normal"); const disabled = normalizeAutonomyState({ enabled: false, status: "running", activeLease: { token: "old", kind: "implement", phaseId: "phase" } }); expect(disabled.status).toBe("off"); expect(disabled.activeLease).toBeUndefined(); }); it("releases in-progress work safely when Auto Mode is disabled", () => { const roadmap = parseRoadmapMarkdown("## Fase\n- [ ] Werk"); roadmap.phases[0].status = "executing"; roadmap.phases[0].items[0].status = "in_progress"; const released = releaseActiveAutonomyLease({ version: 1, profile: "normal", enabled: true, status: "running", autoBrainstorm: true, maxBrainstormIterations: 5, maxConsecutiveFailures: 3, leaseMinutes: 30, maxWorkItemsPerRun: 8, maxRunMinutes: 45, runs: [], roadmap, activeLease: { token: "lease", kind: "implement", actor: "codex", roadmapId: roadmap.id, phaseId: roadmap.phases[0].id, itemId: roadmap.phases[0].items[0].id, createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString() }, completedLeases: [], events: [], updatedAt: new Date().toISOString(), updatedBy: "owner", }, "owner"); expect(released.activeLease).toBeUndefined(); expect(released.roadmap?.phases[0]).toMatchObject({ status: "active", items: [expect.objectContaining({ status: "pending" })] }); expect(released.events.at(-1)?.type).toBe("autonomy.lease.released"); }); it("separates claimable work from a fresh or expired executor lease", () => { const roadmap = parseRoadmapMarkdown("## Fase\n- [ ] Werk"); const base = normalizeAutonomyState({ enabled: true, roadmap }); const fresh = summarizeAutonomyProject({ projectId: "project-id", projectSlug: "project", projectName: "Project", autonomy: { ...base, activeLease: { token: "lease", kind: "implement", actor: "executor", roadmapId: roadmap.id, phaseId: roadmap.phases[0].id, itemId: roadmap.phases[0].items[0].id, createdAt: "2026-09-02T10:00:00.000Z", expiresAt: "2026-09-02T10:30:00.000Z" } }, }, Date.parse("2026-09-02T10:15:00.000Z")); const expired = summarizeAutonomyProject({ projectId: "project-id", projectSlug: "project", projectName: "Project", autonomy: { ...base, activeLease: { token: "lease", kind: "implement", actor: "executor", roadmapId: roadmap.id, phaseId: roadmap.phases[0].id, itemId: roadmap.phases[0].items[0].id, createdAt: "2026-09-02T10:00:00.000Z", expiresAt: "2026-09-02T10:30:00.000Z" } }, }, Date.parse("2026-09-02T10:31:00.000Z")); expect(fresh).toMatchObject({ activeWorkKind: "implement", pendingWorkCount: 1, lifecycle: "running" }); expect(expired.activeWorkKind).toBeUndefined(); expect(expired.lifecycle).toBe("ready"); }); it("defaults and bounds the continuous executor run budget", () => { expect(normalizeAutonomyState({})).toMatchObject({ maxWorkItemsPerRun: 8, maxRunMinutes: 45 }); expect(normalizeAutonomyState({ maxWorkItemsPerRun: 100, maxRunMinutes: 2 })).toMatchObject({ maxWorkItemsPerRun: 20, maxRunMinutes: 5 }); }); it("only lets the owning scheduled run resume a live lease", () => { const lease = { token: "lease", kind: "implement" as const, actor: "chatgpt-web", runId: "run-1", roadmapId: "roadmap", phaseId: "phase", createdAt: "2026-09-02T10:00:00.000Z", expiresAt: "2026-09-02T10:30:00.000Z", }; expect(canResumeAutonomyLease(lease, "chatgpt-web", "run-1")).toBe(true); expect(canResumeAutonomyLease(lease, "chatgpt-web", "run-2")).toBe(false); expect(canResumeAutonomyLease(lease, "another-executor", "run-1")).toBe(false); expect(canResumeAutonomyLease(lease, "chatgpt-web")).toBe(false); expect(isAutonomyLeaseOwner(lease, "chatgpt-web")).toBe(true); expect(isAutonomyLeaseOwner(lease, "another-executor")).toBe(false); }); it("enforces item and elapsed-time budgets from durable run state", () => { const base = normalizeAutonomyState({ maxWorkItemsPerRun: 2, maxRunMinutes: 10, runs: [{ id: "item-run", actor: "chatgpt-web", startedAt: "2026-09-03T10:00:00.000Z", reportedWorkItems: 2 }], }); expect(evaluateAutonomyRunBudget(base, "chatgpt-web", "item-run", Date.parse("2026-09-03T10:01:00.000Z"))).toMatchObject({ allowed: false, reason: "item_limit" }); const timed = normalizeAutonomyState({ maxWorkItemsPerRun: 8, maxRunMinutes: 10, runs: [{ id: "time-run", actor: "chatgpt-web", startedAt: "2026-09-03T10:00:00.000Z", reportedWorkItems: 1 }], }); expect(evaluateAutonomyRunBudget(timed, "chatgpt-web", "time-run", Date.parse("2026-09-03T10:10:00.000Z"))).toMatchObject({ allowed: false, reason: "time_limit" }); expect(evaluateAutonomyRunBudget(timed, "other-actor", "time-run", Date.parse("2026-09-03T10:01:00.000Z"))).toMatchObject({ allowed: false, reason: "owner_mismatch" }); }); it("classifies technical failures as retryable and reserves human blockers for real owner input", () => { expect(classifyAutonomyBlocker("Untrusted non-HTTPS Gitea server URL")).toBe("retryable"); expect(classifyAutonomyBlocker("Runner unavailable during validation")).toBe("retryable"); expect(classifyAutonomyBlocker("Missing credential required for production deployment")).toBe("human"); expect(classifyAutonomyBlocker("Owner decision required between two product directions")).toBe("human"); expect(classifyAutonomyBlocker("Temporary provider failure", "human")).toBe("retryable"); }); it("backs off repeated technical failures without requiring a human decision", () => { expect(autonomyRetryDelayMinutes(1)).toBe(15); expect(autonomyRetryDelayMinutes(2)).toBe(30); expect(autonomyRetryDelayMinutes(3)).toBe(60); expect(autonomyRetryDelayMinutes(20)).toBeLessThanOrEqual(1_440); }); it("makes a due retryable blocker claimable without clearing failure counters", () => { const roadmap = parseRoadmapMarkdown("## Fase\n- [ ] Werk"); roadmap.status = "blocked"; roadmap.phases[0].status = "blocked"; roadmap.phases[0].items[0].status = "blocked"; roadmap.phases[0].items[0].attempts = 2; const blocked = normalizeAutonomyState({ enabled: true, roadmap, blocker: { at: "2026-09-03T10:00:00.000Z", retryAt: "2026-09-03T11:00:00.000Z", kind: "retryable", phaseId: roadmap.phases[0].id, itemId: roadmap.phases[0].items[0].id, reason: "Temporary provider failure" }, }); const retried = retryAutonomyBlockerIfDue(blocked, Date.parse("2026-09-03T11:00:00.000Z")); expect(retried).toMatchObject({ status: "running", roadmap: { status: "active", phases: [{ status: "active", items: [{ status: "pending", attempts: 2 }] }] } }); expect(retried.blocker).toBeUndefined(); expect(retried.events.at(-1)?.type).toBe("autonomy.blocker.retry_ready"); }); it("fences heartbeat ownership between overlapping scheduled runs", () => { const current = { runId: "new-run", status: "working", startedAt: new Date("2026-09-03T10:00:00.000Z") }; expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "old-run", status: "idle" })).toMatchObject({ accepted: false }); expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", status: "degraded" })).toMatchObject({ accepted: false, reason: "run_owner_mismatch" }); expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "new-run", status: "idle" })).toMatchObject({ accepted: true, startedAt: current.startedAt }); expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "next-run", status: "working" }, new Date("2026-09-03T10:10:00.000Z"))).toMatchObject({ accepted: false, reason: "active_run" }); expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "next-run", status: "working" }, new Date("2026-09-03T11:00:00.000Z"))).toMatchObject({ accepted: true, startedAt: new Date("2026-09-03T11:00:00.000Z") }); expect(resolveAutonomyHeartbeat({ ...current, status: "idle" }, { instanceId: "ordinary-chat", runId: "next-run", status: "working" }, new Date("2026-09-03T10:10:00.000Z"))).toMatchObject({ accepted: true, startedAt: new Date("2026-09-03T10:10:00.000Z") }); expect(resolveAutonomyHeartbeat({ ...current, activeProjectId: "project-a" }, { instanceId: "ordinary-chat", runId: "new-run", status: "working", activeProjectId: "project-b" })).toMatchObject({ accepted: false, reason: "active_project" }); }); it("allows takeover after a short project's configured run window plus grace", () => { const current = { runId: "old-run", status: "working", startedAt: new Date("2026-09-03T10:00:00.000Z") }; const takeoverAfterMs = 25 * 60_000; expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "next-run", status: "working" }, new Date("2026-09-03T10:24:00.000Z"), takeoverAfterMs)).toMatchObject({ accepted: false, reason: "active_run" }); expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "next-run", status: "working" }, new Date("2026-09-03T10:26:00.000Z"), takeoverAfterMs)).toMatchObject({ accepted: true }); }); it("isolates concurrent ordinary ChatGPT executors by project", () => { expect(autonomyHeartbeatSlotId("mediarr")).toBe("project:mediarr"); expect(autonomyHeartbeatSlotId("rombuilder")).toBe("project:rombuilder"); expect(autonomyHeartbeatSlotId("mediarr")).not.toBe(autonomyHeartbeatSlotId("rombuilder")); expect(autonomyHeartbeatSlotId()).toBe("primary"); }); it("projects large durable roadmaps into bounded MCP context", () => { const roadmap = parseRoadmapMarkdown(`## Current\n${Array.from({ length: 80 }, (_, index) => `- [ ] Item ${index + 1} ${"detail ".repeat(30)}`).join("\n")}\n## Later\n${Array.from({ length: 80 }, (_, index) => `- [ ] Later ${index + 1}`).join("\n")}`); const state = normalizeAutonomyState({ enabled: true, roadmap, events: Array.from({ length: 30 }, (_, index) => ({ id: `event-${index}`, at: "2026-09-04T10:00:00.000Z", type: "test", actor: "chatgpt", summary: "evidence ".repeat(200) })), }); for (const item of state.roadmap!.phases[0].items.slice(0, 40)) item.status = "completed"; const compact = compactAutonomyState(state, { itemLimit: 10, eventLimit: 3 }); const serialized = JSON.stringify(compact); expect(compact.roadmap?.currentPhase?.items).toHaveLength(10); expect(compact.roadmap?.currentPhase).toMatchObject({ totalItems: 80, itemWindowStart: 37, itemsReturned: 10, hasMoreItems: true }); expect(compact.roadmap?.currentPhase?.items[3]).toMatchObject({ title: expect.stringContaining("Item 41"), status: "pending" }); expect(compact.events).toHaveLength(3); expect(serialized).not.toContain("sourceMarkdown"); expect(serialized.length).toBeLessThan(12_000); expect(serialized.length).toBeLessThan(JSON.stringify(state).length / 5); }); }); describe("Auto Mode MCP surface", () => { it("orders claimable projects fairly before considering backlog size", () => { const projects = [ { name: "huge-recent", status: "running", pending_work_items: 201, last_run_at: "2026-09-05T09:00:00.000Z" }, { name: "small-old", status: "running", pending_work_items: 4, last_run_at: "2026-09-04T09:00:00.000Z" }, { name: "never-run", status: "running", pending_work_items: 2 }, { name: "busy", status: "running", pending_work_items: 50, active_work_kind: "implement", last_run_at: "2026-09-01T09:00:00.000Z" }, { name: "blocked", status: "blocked", pending_work_items: 500, last_run_at: "2026-08-01T09:00:00.000Z" }, ].sort(compareAutoModeProjectRows); expect(projects.map((project) => project.name)).toEqual(["never-run", "small-old", "huge-recent", "busy", "blocked"]); }); it("advertises the complete durable roadmap controller to MCP clients", async () => { const server = createProjectBrainMcpServer(env, "auto-contract-test"); const client = new Client({ name: "auto-contract-test", version: "1.0.0" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); try { await server.connect(serverTransport); await client.connect(clientTransport); const advertisedTools = (await client.listTools()).tools; const tools = advertisedTools.map((tool) => tool.name); expect(tools).toEqual(expect.arrayContaining([ "list_auto_mode_projects", "heartbeat_auto_executor", "get_auto_mode_state", "get_runner_claim_health", "configure_auto_mode", "import_auto_roadmap", "reorder_auto_queue", "reset_auto_queue_order", "resume_auto_mode", "claim_auto_work", "report_auto_work", ])); expect(advertisedTools.find((tool) => tool.name === "claim_auto_work")?.inputSchema).toMatchObject({ required: expect.arrayContaining(["project_id", "run_id"]), }); expect(advertisedTools.find((tool) => tool.name === "reorder_auto_queue")?.inputSchema).toMatchObject({ required: expect.arrayContaining(["project_id", "item_ids"]), }); expect(advertisedTools.find((tool) => tool.name === "configure_auto_mode")?.inputSchema).toMatchObject({ properties: expect.objectContaining({ max_work_items_per_run: expect.any(Object), max_run_minutes: expect.any(Object), max_recovery_minutes: expect.any(Object), pause_after_current: expect.any(Object), scheduling_policy: expect.any(Object) }), }); expect(advertisedTools.find((tool) => tool.name === "get_auto_mode_state")?.inputSchema).toMatchObject({ properties: expect.objectContaining({ view: expect.any(Object), item_limit: expect.any(Object), event_limit: expect.any(Object) }), }); expect(advertisedTools.find((tool) => tool.name === "report_auto_work")?.inputSchema).toMatchObject({ properties: expect.objectContaining({ blocker_type: expect.any(Object), retry_after_minutes: expect.any(Object) }), }); } finally { await client.close(); await server.close(); } }); });