@os-eco/overstory-cli 0.9.3 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +49 -18
  2. package/agents/builder.md +9 -8
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +98 -82
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +211 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/overlay.test.ts +4 -4
  18. package/src/agents/overlay.ts +30 -8
  19. package/src/agents/turn-lock.test.ts +181 -0
  20. package/src/agents/turn-lock.ts +235 -0
  21. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  22. package/src/agents/turn-runner-dispatch.ts +105 -0
  23. package/src/agents/turn-runner.test.ts +1450 -0
  24. package/src/agents/turn-runner.ts +1166 -0
  25. package/src/commands/clean.ts +56 -1
  26. package/src/commands/completions.test.ts +4 -1
  27. package/src/commands/coordinator.test.ts +127 -0
  28. package/src/commands/coordinator.ts +205 -6
  29. package/src/commands/dashboard.test.ts +188 -0
  30. package/src/commands/dashboard.ts +13 -3
  31. package/src/commands/doctor.ts +94 -77
  32. package/src/commands/group.test.ts +94 -0
  33. package/src/commands/group.ts +49 -20
  34. package/src/commands/init.test.ts +8 -0
  35. package/src/commands/init.ts +8 -1
  36. package/src/commands/log.test.ts +56 -11
  37. package/src/commands/log.ts +134 -69
  38. package/src/commands/mail.test.ts +162 -0
  39. package/src/commands/mail.ts +64 -9
  40. package/src/commands/merge.test.ts +112 -1
  41. package/src/commands/merge.ts +17 -4
  42. package/src/commands/monitor.ts +2 -1
  43. package/src/commands/nudge.test.ts +351 -4
  44. package/src/commands/nudge.ts +356 -34
  45. package/src/commands/run.test.ts +43 -7
  46. package/src/commands/serve/build.test.ts +202 -0
  47. package/src/commands/serve/build.ts +206 -0
  48. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  49. package/src/commands/serve/coordinator-actions.ts +408 -0
  50. package/src/commands/serve/dev.test.ts +168 -0
  51. package/src/commands/serve/dev.ts +117 -0
  52. package/src/commands/serve/mail-actions.test.ts +312 -0
  53. package/src/commands/serve/mail-actions.ts +167 -0
  54. package/src/commands/serve/rest.test.ts +1323 -0
  55. package/src/commands/serve/rest.ts +708 -0
  56. package/src/commands/serve/static.ts +51 -0
  57. package/src/commands/serve/ws.test.ts +361 -0
  58. package/src/commands/serve/ws.ts +332 -0
  59. package/src/commands/serve.test.ts +459 -0
  60. package/src/commands/serve.ts +565 -0
  61. package/src/commands/sling.test.ts +85 -1
  62. package/src/commands/sling.ts +153 -64
  63. package/src/commands/status.test.ts +9 -0
  64. package/src/commands/status.ts +12 -4
  65. package/src/commands/stop.test.ts +174 -1
  66. package/src/commands/stop.ts +107 -8
  67. package/src/commands/supervisor.ts +2 -1
  68. package/src/commands/watch.test.ts +49 -4
  69. package/src/commands/watch.ts +153 -28
  70. package/src/commands/worktree.test.ts +319 -3
  71. package/src/commands/worktree.ts +86 -0
  72. package/src/config.test.ts +78 -0
  73. package/src/config.ts +43 -1
  74. package/src/doctor/consistency.test.ts +106 -0
  75. package/src/doctor/consistency.ts +50 -3
  76. package/src/doctor/serve.test.ts +95 -0
  77. package/src/doctor/serve.ts +86 -0
  78. package/src/doctor/types.ts +2 -1
  79. package/src/doctor/watchdog.ts +57 -1
  80. package/src/events/tailer.test.ts +234 -1
  81. package/src/events/tailer.ts +90 -0
  82. package/src/index.ts +53 -6
  83. package/src/json.ts +29 -0
  84. package/src/mail/client.ts +15 -2
  85. package/src/mail/store.test.ts +82 -0
  86. package/src/mail/store.ts +41 -4
  87. package/src/merge/lock.test.ts +149 -0
  88. package/src/merge/lock.ts +140 -0
  89. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  90. package/src/runtimes/claude.test.ts +791 -1
  91. package/src/runtimes/claude.ts +323 -1
  92. package/src/runtimes/connections.test.ts +141 -1
  93. package/src/runtimes/connections.ts +73 -4
  94. package/src/runtimes/headless-connection.test.ts +264 -0
  95. package/src/runtimes/headless-connection.ts +158 -0
  96. package/src/runtimes/types.ts +10 -0
  97. package/src/schema-consistency.test.ts +1 -0
  98. package/src/sessions/store.test.ts +390 -24
  99. package/src/sessions/store.ts +184 -19
  100. package/src/test-setup.test.ts +31 -0
  101. package/src/test-setup.ts +28 -0
  102. package/src/types.ts +56 -1
  103. package/src/utils/pid.test.ts +85 -1
  104. package/src/utils/pid.ts +86 -1
  105. package/src/utils/process-scan.test.ts +53 -0
  106. package/src/utils/process-scan.ts +76 -0
  107. package/src/watchdog/daemon.test.ts +1520 -411
  108. package/src/watchdog/daemon.ts +442 -83
  109. package/src/watchdog/health.test.ts +157 -0
  110. package/src/watchdog/health.ts +92 -25
  111. package/src/worktree/process.test.ts +71 -0
  112. package/src/worktree/process.ts +25 -5
  113. package/src/worktree/tmux.test.ts +39 -0
  114. package/src/worktree/tmux.ts +23 -3
  115. package/templates/CLAUDE.md.tmpl +19 -8
  116. package/templates/overlay.md.tmpl +3 -2
@@ -0,0 +1,1323 @@
1
+ /**
2
+ * Tests for REST endpoints registered by registerRestApi().
3
+ *
4
+ * Pattern mirrors serve.test.ts:
5
+ * - temp dir with .overstory/config.yaml
6
+ * - port 0 for automatic free-port assignment
7
+ * - _resetHandlers() in beforeEach for isolation
8
+ * - server stopped in afterEach
9
+ *
10
+ * Stores are seeded with real SQLite instances so cursor/pagination
11
+ * invariants are verified against actual data.
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { createEventStore } from "../../events/store.ts";
19
+ import type { MailStore } from "../../mail/store.ts";
20
+ import { createMailStore } from "../../mail/store.ts";
21
+ import type { SessionStore } from "../../sessions/store.ts";
22
+ import { createRunStore, createSessionStore } from "../../sessions/store.ts";
23
+ import type { EventStore, RunStore } from "../../types.ts";
24
+ import { _resetHandlers, createServeServer } from "../serve.ts";
25
+ import { registerRestApi } from "./rest.ts";
26
+ import { serveStatic } from "./static.ts";
27
+
28
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
29
+
30
+ interface TestContext {
31
+ tempDir: string;
32
+ runStore: RunStore;
33
+ sessionStore: SessionStore;
34
+ eventStore: EventStore;
35
+ mailStore: MailStore;
36
+ servers: ReturnType<typeof Bun.serve>[];
37
+ }
38
+
39
+ function makeSession(
40
+ overrides: Partial<{ agentName: string; runId: string | null; startedAt: string }> = {},
41
+ ) {
42
+ return {
43
+ id: `sess-${Math.random().toString(36).slice(2)}`,
44
+ agentName: overrides.agentName ?? `agent-${Math.random().toString(36).slice(2)}`,
45
+ capability: "builder" as const,
46
+ worktreePath: "/tmp/wt",
47
+ branchName: "my-branch",
48
+ taskId: "task-1",
49
+ tmuxSession: "tmux-sess",
50
+ state: "working" as const,
51
+ pid: null,
52
+ parentAgent: null,
53
+ depth: 0,
54
+ runId: overrides.runId ?? null,
55
+ startedAt: overrides.startedAt ?? new Date().toISOString(),
56
+ lastActivity: new Date().toISOString(),
57
+ escalationLevel: 0,
58
+ stalledSince: null,
59
+ transcriptPath: null,
60
+ };
61
+ }
62
+
63
+ function makeRun(
64
+ overrides: Partial<{
65
+ id: string;
66
+ startedAt: string;
67
+ status: "active" | "completed" | "failed";
68
+ }> = {},
69
+ ) {
70
+ const id = overrides.id ?? `run-${Math.random().toString(36).slice(2)}`;
71
+ return {
72
+ id,
73
+ startedAt: overrides.startedAt ?? new Date().toISOString(),
74
+ agentCount: 0,
75
+ coordinatorSessionId: null,
76
+ coordinatorName: null,
77
+ status: overrides.status ?? ("active" as const),
78
+ };
79
+ }
80
+
81
+ // ─── Test suite ───────────────────────────────────────────────────────────────
82
+
83
+ describe("registerRestApi", () => {
84
+ let ctx: TestContext;
85
+
86
+ beforeEach(() => {
87
+ const tempDir = mkdtempSync(join(tmpdir(), "overstory-rest-test-"));
88
+ mkdirSync(join(tempDir, ".overstory"), { recursive: true });
89
+ writeFileSync(
90
+ join(tempDir, ".overstory", "config.yaml"),
91
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
92
+ );
93
+
94
+ const sessionsDb = join(tempDir, ".overstory", "sessions.db");
95
+ const eventsDb = join(tempDir, ".overstory", "events.db");
96
+ const mailDb = join(tempDir, ".overstory", "mail.db");
97
+
98
+ ctx = {
99
+ tempDir,
100
+ runStore: createRunStore(sessionsDb),
101
+ sessionStore: createSessionStore(sessionsDb),
102
+ eventStore: createEventStore(eventsDb),
103
+ mailStore: createMailStore(mailDb),
104
+ servers: [],
105
+ };
106
+
107
+ _resetHandlers();
108
+ registerRestApi({
109
+ _runStore: ctx.runStore,
110
+ _sessionStore: ctx.sessionStore,
111
+ _eventStore: ctx.eventStore,
112
+ _mailStore: ctx.mailStore,
113
+ // Coordinator actions reuse the same stores as the test harness so
114
+ // seeded session/mail rows are visible across the API surface.
115
+ _coordinatorActionDeps: {
116
+ projectRoot: tempDir,
117
+ _sessionStore: ctx.sessionStore,
118
+ _mailStore: ctx.mailStore,
119
+ _askPollIntervalMs: 20,
120
+ },
121
+ });
122
+ });
123
+
124
+ afterEach(async () => {
125
+ for (const srv of ctx.servers) {
126
+ srv.stop(true);
127
+ }
128
+ ctx.runStore.close();
129
+ ctx.sessionStore.close();
130
+ ctx.eventStore.close();
131
+ ctx.mailStore.close();
132
+ _resetHandlers();
133
+ rmSync(ctx.tempDir, { recursive: true, force: true });
134
+ });
135
+
136
+ async function startServer(): Promise<ReturnType<typeof Bun.serve>> {
137
+ const origCwd = process.cwd;
138
+ process.cwd = () => ctx.tempDir;
139
+ // Force project-relative ui/dist resolution so tests asserting "no UI"
140
+ // don't accidentally serve the package-bundled fallback (overstory-916d).
141
+ const server = await createServeServer(
142
+ { port: 0, host: "127.0.0.1" },
143
+ {
144
+ _restDeps: false,
145
+ _resolveUiDistPath: (root) => join(root, "ui", "dist"),
146
+ },
147
+ );
148
+ process.cwd = origCwd;
149
+ ctx.servers.push(server);
150
+ return server;
151
+ }
152
+
153
+ async function get(server: ReturnType<typeof Bun.serve>, path: string): Promise<Response> {
154
+ return fetch(`http://127.0.0.1:${server.port}${path}`);
155
+ }
156
+
157
+ async function getJson<T>(
158
+ server: ReturnType<typeof Bun.serve>,
159
+ path: string,
160
+ ): Promise<{ status: number; body: T }> {
161
+ const res = await get(server, path);
162
+ const body = (await res.json()) as T;
163
+ return { status: res.status, body };
164
+ }
165
+
166
+ // ─── /healthz ─────────────────────────────────────────────────────────────
167
+
168
+ describe("/healthz polish", () => {
169
+ test("returns uptimeMs and version nested in data", async () => {
170
+ const server = await startServer();
171
+ const { status, body } = await getJson<{ data: Record<string, unknown> }>(server, "/healthz");
172
+ expect(status).toBe(200);
173
+ expect(typeof body.data.uptimeMs).toBe("number");
174
+ expect(body.data.uptimeMs).toBeGreaterThanOrEqual(0);
175
+ expect(typeof body.data.version).toBe("string");
176
+ expect(body.data.version).not.toBe("");
177
+ });
178
+ });
179
+
180
+ // ─── 503 JSON ─────────────────────────────────────────────────────────────
181
+
182
+ describe("503 JSON envelope", () => {
183
+ test("returns JSON envelope when ui/dist missing", async () => {
184
+ const server = await startServer();
185
+ const res = await get(server, "/");
186
+ expect(res.status).toBe(503);
187
+ const body = (await res.json()) as Record<string, unknown>;
188
+ expect(body.success).toBe(false);
189
+ expect(body.command).toBe("serve");
190
+ expect(typeof body.error).toBe("string");
191
+ });
192
+ });
193
+
194
+ // ─── Path traversal ────────────────────────────────────────────────────────
195
+
196
+ describe("path-traversal guard", () => {
197
+ test("serveStatic rejects ../etc/passwd style path with 403 JSON", async () => {
198
+ const uiDist = join(ctx.tempDir, "ui", "dist");
199
+ mkdirSync(uiDist, { recursive: true });
200
+ writeFileSync(join(uiDist, "index.html"), "<html></html>");
201
+
202
+ // HTTP clients normalize /../ to / before sending, so we test the guard directly.
203
+ // The raw path "/../../../etc/passwd" stripped of its leading / becomes
204
+ // "../../../etc/passwd" — resolve() escapes uiRoot → 403.
205
+ const res = await serveStatic(
206
+ "/../../../etc/passwd",
207
+ uiDist,
208
+ () => true as ReturnType<typeof import("node:fs").existsSync>,
209
+ );
210
+ expect(res.status).toBe(403);
211
+ const body = (await res.json()) as Record<string, unknown>;
212
+ expect(body.success).toBe(false);
213
+ expect(body.command).toBe("serve");
214
+ });
215
+
216
+ test("serveStatic allows normal paths", async () => {
217
+ const uiDist = join(ctx.tempDir, "ui", "dist");
218
+ mkdirSync(uiDist, { recursive: true });
219
+ writeFileSync(join(uiDist, "index.html"), "<html></html>");
220
+ writeFileSync(join(uiDist, "app.js"), "console.log('hi')");
221
+
222
+ const res = await serveStatic(
223
+ "/app.js",
224
+ uiDist,
225
+ () => true as ReturnType<typeof import("node:fs").existsSync>,
226
+ );
227
+ expect([200, 404]).toContain(res.status);
228
+ });
229
+ });
230
+
231
+ // ─── 405 Method Not Allowed ───────────────────────────────────────────────
232
+
233
+ describe("405 on non-GET /api/* routes", () => {
234
+ test("POST /api/runs returns 405", async () => {
235
+ const server = await startServer();
236
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/runs`, { method: "POST" });
237
+ expect(res.status).toBe(405);
238
+ const body = (await res.json()) as Record<string, unknown>;
239
+ expect(body.success).toBe(false);
240
+ });
241
+
242
+ test("DELETE /api/agents returns 405", async () => {
243
+ const server = await startServer();
244
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/agents`, { method: "DELETE" });
245
+ expect(res.status).toBe(405);
246
+ });
247
+ });
248
+
249
+ // ─── GET /api/runs ────────────────────────────────────────────────────────
250
+
251
+ describe("GET /api/runs", () => {
252
+ test("returns empty list when no runs", async () => {
253
+ const server = await startServer();
254
+ const { status, body } = await getJson<Record<string, unknown>>(server, "/api/runs");
255
+ expect(status).toBe(200);
256
+ expect(body.success).toBe(true);
257
+ expect(Array.isArray(body.data)).toBe(true);
258
+ expect((body.data as unknown[]).length).toBe(0);
259
+ });
260
+
261
+ test("returns runs sorted DESC by startedAt", async () => {
262
+ const r1 = makeRun({ id: "run-a", startedAt: "2024-01-01T00:00:00.000Z" });
263
+ const r2 = makeRun({ id: "run-b", startedAt: "2024-02-01T00:00:00.000Z" });
264
+ ctx.runStore.createRun(r1);
265
+ ctx.runStore.createRun(r2);
266
+
267
+ const server = await startServer();
268
+ const { status, body } = await getJson<{ data: Array<{ id: string }> }>(server, "/api/runs");
269
+ expect(status).toBe(200);
270
+ expect(body.data[0]?.id).toBe("run-b");
271
+ expect(body.data[1]?.id).toBe("run-a");
272
+ });
273
+
274
+ test("success envelope shape", async () => {
275
+ const server = await startServer();
276
+ const { body } = await getJson<Record<string, unknown>>(server, "/api/runs");
277
+ expect(body.success).toBe(true);
278
+ expect(body.command).toBe("serve");
279
+ expect("data" in body).toBe(true);
280
+ });
281
+
282
+ test("pagination: two pages, no duplicates, no gaps", async () => {
283
+ // Seed 3 runs
284
+ for (let i = 0; i < 3; i++) {
285
+ const ts = new Date(2024, 0, i + 1).toISOString();
286
+ ctx.runStore.createRun(makeRun({ id: `prun-${i}`, startedAt: ts }));
287
+ }
288
+
289
+ const server = await startServer();
290
+ const p1 = await getJson<{ data: Array<{ id: string }>; nextCursor: string | null }>(
291
+ server,
292
+ "/api/runs?limit=2",
293
+ );
294
+ expect(p1.status).toBe(200);
295
+ expect(p1.body.data.length).toBe(2);
296
+ expect(p1.body.nextCursor).not.toBeNull();
297
+
298
+ const p2 = await getJson<{ data: Array<{ id: string }>; nextCursor: string | null }>(
299
+ server,
300
+ `/api/runs?limit=2&cursor=${p1.body.nextCursor}`,
301
+ );
302
+ expect(p2.status).toBe(200);
303
+ expect(p2.body.data.length).toBe(1);
304
+ expect(p2.body.nextCursor ?? null).toBeNull();
305
+
306
+ const allIds = [...p1.body.data.map((r) => r.id), ...p2.body.data.map((r) => r.id)];
307
+ expect(new Set(allIds).size).toBe(3);
308
+ });
309
+
310
+ test("bad limit returns 400", async () => {
311
+ const server = await startServer();
312
+ const { status, body } = await getJson<Record<string, unknown>>(server, "/api/runs?limit=0");
313
+ expect(status).toBe(400);
314
+ expect(body.success).toBe(false);
315
+ });
316
+
317
+ test("limit > 500 returns 400", async () => {
318
+ const server = await startServer();
319
+ const { status } = await getJson<Record<string, unknown>>(server, "/api/runs?limit=501");
320
+ expect(status).toBe(400);
321
+ });
322
+
323
+ test("bad cursor returns 400", async () => {
324
+ const server = await startServer();
325
+ const { status } = await getJson<Record<string, unknown>>(
326
+ server,
327
+ "/api/runs?cursor=not-valid-base64",
328
+ );
329
+ expect(status).toBe(400);
330
+ });
331
+ });
332
+
333
+ // ─── GET /api/runs/:id ────────────────────────────────────────────────────
334
+
335
+ describe("GET /api/runs/:id", () => {
336
+ test("404 for unknown run", async () => {
337
+ const server = await startServer();
338
+ const { status, body } = await getJson<Record<string, unknown>>(server, "/api/runs/no-such");
339
+ expect(status).toBe(404);
340
+ expect(body.success).toBe(false);
341
+ });
342
+
343
+ test("returns run with agents sorted ASC", async () => {
344
+ const run = makeRun({ id: "run-detail" });
345
+ ctx.runStore.createRun(run);
346
+ const sess1 = makeSession({
347
+ agentName: "agent-1",
348
+ runId: "run-detail",
349
+ startedAt: "2024-01-02T00:00:00.000Z",
350
+ });
351
+ const sess2 = makeSession({
352
+ agentName: "agent-2",
353
+ runId: "run-detail",
354
+ startedAt: "2024-01-01T00:00:00.000Z",
355
+ });
356
+ ctx.sessionStore.upsert(sess1);
357
+ ctx.sessionStore.upsert(sess2);
358
+
359
+ const server = await startServer();
360
+ const { status, body } = await getJson<{
361
+ data: { id: string; agents: Array<{ agentName: string }> };
362
+ }>(server, "/api/runs/run-detail");
363
+ expect(status).toBe(200);
364
+ expect(body.data.id).toBe("run-detail");
365
+ expect(body.data.agents.length).toBe(2);
366
+ expect(body.data.agents[0]?.agentName).toBe("agent-2");
367
+ expect(body.data.agents[1]?.agentName).toBe("agent-1");
368
+ });
369
+ });
370
+
371
+ // ─── GET /api/agents ──────────────────────────────────────────────────────
372
+
373
+ describe("GET /api/agents", () => {
374
+ test("returns empty list when no agents", async () => {
375
+ const server = await startServer();
376
+ const { status, body } = await getJson<Record<string, unknown>>(server, "/api/agents");
377
+ expect(status).toBe(200);
378
+ expect(Array.isArray(body.data)).toBe(true);
379
+ });
380
+
381
+ test("returns all agents sorted ASC by startedAt", async () => {
382
+ ctx.sessionStore.upsert(
383
+ makeSession({ agentName: "ag-z", startedAt: "2024-01-03T00:00:00.000Z" }),
384
+ );
385
+ ctx.sessionStore.upsert(
386
+ makeSession({ agentName: "ag-a", startedAt: "2024-01-01T00:00:00.000Z" }),
387
+ );
388
+ ctx.sessionStore.upsert(
389
+ makeSession({ agentName: "ag-m", startedAt: "2024-01-02T00:00:00.000Z" }),
390
+ );
391
+
392
+ const server = await startServer();
393
+ const { body } = await getJson<{ data: Array<{ agentName: string }> }>(server, "/api/agents");
394
+ expect(body.data[0]?.agentName).toBe("ag-a");
395
+ expect(body.data[1]?.agentName).toBe("ag-m");
396
+ expect(body.data[2]?.agentName).toBe("ag-z");
397
+ });
398
+
399
+ test("?run= filters by run ID", async () => {
400
+ const run = makeRun({ id: "run-filter" });
401
+ ctx.runStore.createRun(run);
402
+ ctx.sessionStore.upsert(makeSession({ agentName: "in-run", runId: "run-filter" }));
403
+ ctx.sessionStore.upsert(makeSession({ agentName: "no-run" }));
404
+
405
+ const server = await startServer();
406
+ const { body } = await getJson<{ data: Array<{ agentName: string }> }>(
407
+ server,
408
+ "/api/agents?run=run-filter",
409
+ );
410
+ expect(body.data.length).toBe(1);
411
+ expect(body.data[0]?.agentName).toBe("in-run");
412
+ });
413
+
414
+ test("pagination: two pages, no duplicates", async () => {
415
+ for (let i = 0; i < 3; i++) {
416
+ const ts = new Date(2024, 0, i + 1).toISOString();
417
+ ctx.sessionStore.upsert(makeSession({ agentName: `pagent-${i}`, startedAt: ts }));
418
+ }
419
+
420
+ const server = await startServer();
421
+ const p1 = await getJson<{ data: Array<{ agentName: string }>; nextCursor: string | null }>(
422
+ server,
423
+ "/api/agents?limit=2",
424
+ );
425
+ expect(p1.body.data.length).toBe(2);
426
+ expect(p1.body.nextCursor).not.toBeNull();
427
+
428
+ const p2 = await getJson<{ data: Array<{ agentName: string }>; nextCursor: string | null }>(
429
+ server,
430
+ `/api/agents?limit=2&cursor=${p1.body.nextCursor}`,
431
+ );
432
+ expect(p2.body.data.length).toBe(1);
433
+ expect(p2.body.nextCursor ?? null).toBeNull();
434
+
435
+ const allNames = [
436
+ ...p1.body.data.map((a) => a.agentName),
437
+ ...p2.body.data.map((a) => a.agentName),
438
+ ];
439
+ expect(new Set(allNames).size).toBe(3);
440
+ });
441
+ });
442
+
443
+ // ─── GET /api/agents/:name ────────────────────────────────────────────────
444
+
445
+ describe("GET /api/agents/:name", () => {
446
+ test("404 for unknown agent", async () => {
447
+ const server = await startServer();
448
+ const { status, body } = await getJson<Record<string, unknown>>(
449
+ server,
450
+ "/api/agents/no-such",
451
+ );
452
+ expect(status).toBe(404);
453
+ expect(body.success).toBe(false);
454
+ });
455
+
456
+ test("returns agent by name", async () => {
457
+ ctx.sessionStore.upsert(makeSession({ agentName: "my-agent" }));
458
+
459
+ const server = await startServer();
460
+ const { status, body } = await getJson<{ data: { agentName: string } }>(
461
+ server,
462
+ "/api/agents/my-agent",
463
+ );
464
+ expect(status).toBe(200);
465
+ expect(body.data.agentName).toBe("my-agent");
466
+ });
467
+ });
468
+
469
+ // ─── GET /api/events ──────────────────────────────────────────────────────
470
+
471
+ describe("GET /api/events", () => {
472
+ function insertEvent(agentName: string, runId: string | null = null) {
473
+ return ctx.eventStore.insert({
474
+ agentName,
475
+ runId,
476
+ sessionId: null,
477
+ eventType: "tool_start",
478
+ toolName: "Bash",
479
+ toolArgs: null,
480
+ toolDurationMs: null,
481
+ level: "info",
482
+ data: null,
483
+ });
484
+ }
485
+
486
+ test("returns empty list when no events", async () => {
487
+ const server = await startServer();
488
+ const { status, body } = await getJson<Record<string, unknown>>(server, "/api/events");
489
+ expect(status).toBe(200);
490
+ expect(Array.isArray(body.data)).toBe(true);
491
+ });
492
+
493
+ test("returns events in ASC order", async () => {
494
+ insertEvent("agent-a");
495
+ insertEvent("agent-b");
496
+
497
+ const server = await startServer();
498
+ const { body } = await getJson<{ data: Array<{ agentName: string }> }>(server, "/api/events");
499
+ expect(body.data.length).toBe(2);
500
+ // Both inserted; id is monotonically increasing — first should be agent-a
501
+ expect(body.data[0]?.agentName).toBe("agent-a");
502
+ });
503
+
504
+ test("?agent= filters by agent", async () => {
505
+ insertEvent("my-agent");
506
+ insertEvent("other-agent");
507
+
508
+ const server = await startServer();
509
+ const { body } = await getJson<{ data: Array<{ agentName: string }> }>(
510
+ server,
511
+ "/api/events?agent=my-agent",
512
+ );
513
+ expect(body.data.every((e) => e.agentName === "my-agent")).toBe(true);
514
+ });
515
+
516
+ test("?run= filters by run", async () => {
517
+ const run = makeRun({ id: "ev-run" });
518
+ ctx.runStore.createRun(run);
519
+ insertEvent("ag", "ev-run");
520
+ insertEvent("ag", null);
521
+
522
+ const server = await startServer();
523
+ const { body } = await getJson<{ data: Array<{ runId: string | null }> }>(
524
+ server,
525
+ "/api/events?run=ev-run",
526
+ );
527
+ expect(body.data.every((e) => e.runId === "ev-run")).toBe(true);
528
+ });
529
+
530
+ test("?since= filters events after timestamp", async () => {
531
+ insertEvent("ag");
532
+ // small delay
533
+ await new Promise((r) => setTimeout(r, 5));
534
+ const since = new Date().toISOString();
535
+ await new Promise((r) => setTimeout(r, 5));
536
+ insertEvent("ag");
537
+
538
+ const server = await startServer();
539
+ const { body } = await getJson<{ data: unknown[] }>(
540
+ server,
541
+ `/api/events?since=${encodeURIComponent(since)}`,
542
+ );
543
+ expect(body.data.length).toBe(1);
544
+ });
545
+
546
+ test("bad ?since= returns 400", async () => {
547
+ const server = await startServer();
548
+ const { status } = await getJson<Record<string, unknown>>(server, "/api/events?since=NOPE");
549
+ expect(status).toBe(400);
550
+ });
551
+
552
+ test("pagination: two pages, no duplicates, no gaps", async () => {
553
+ for (let i = 0; i < 3; i++) {
554
+ insertEvent("pg-agent");
555
+ }
556
+
557
+ const server = await startServer();
558
+ const p1 = await getJson<{ data: unknown[]; nextCursor: string | null }>(
559
+ server,
560
+ "/api/events?limit=2",
561
+ );
562
+ expect(p1.body.data.length).toBe(2);
563
+ expect(p1.body.nextCursor).not.toBeNull();
564
+
565
+ const p2 = await getJson<{ data: unknown[]; nextCursor: string | null }>(
566
+ server,
567
+ `/api/events?limit=2&cursor=${p1.body.nextCursor}`,
568
+ );
569
+ expect(p2.body.data.length).toBe(1);
570
+ expect(p2.body.nextCursor ?? null).toBeNull();
571
+
572
+ const total = p1.body.data.length + p2.body.data.length;
573
+ expect(total).toBe(3);
574
+ });
575
+ });
576
+
577
+ // ─── GET /api/mail ────────────────────────────────────────────────────────
578
+
579
+ describe("GET /api/mail", () => {
580
+ function insertMsg(from: string, to: string, overrides: { read?: boolean } = {}) {
581
+ const msg = ctx.mailStore.insert({
582
+ id: `msg-${Math.random().toString(36).slice(2)}`,
583
+ from,
584
+ to,
585
+ subject: "Test",
586
+ body: "body",
587
+ type: "status" as const,
588
+ priority: "normal" as const,
589
+ threadId: null,
590
+ });
591
+ if (overrides.read === true) {
592
+ ctx.mailStore.markRead(msg.id);
593
+ }
594
+ return msg;
595
+ }
596
+
597
+ test("returns empty list when no messages", async () => {
598
+ const server = await startServer();
599
+ const { status, body } = await getJson<Record<string, unknown>>(server, "/api/mail");
600
+ expect(status).toBe(200);
601
+ expect(Array.isArray(body.data)).toBe(true);
602
+ });
603
+
604
+ test("returns messages sorted DESC by createdAt", async () => {
605
+ insertMsg("a", "b");
606
+ insertMsg("c", "d");
607
+
608
+ const server = await startServer();
609
+ const { body } = await getJson<{ data: Array<{ from: string }> }>(server, "/api/mail");
610
+ // Second message inserted later — should appear first (DESC)
611
+ expect(body.data.length).toBe(2);
612
+ });
613
+
614
+ test("?to= filters by recipient", async () => {
615
+ insertMsg("sender", "target");
616
+ insertMsg("sender", "other");
617
+
618
+ const server = await startServer();
619
+ const { body } = await getJson<{ data: Array<{ to: string }> }>(
620
+ server,
621
+ "/api/mail?to=target",
622
+ );
623
+ expect(body.data.every((m) => m.to === "target")).toBe(true);
624
+ });
625
+
626
+ test("?from= filters by sender", async () => {
627
+ insertMsg("mybot", "user");
628
+ insertMsg("other", "user");
629
+
630
+ const server = await startServer();
631
+ const { body } = await getJson<{ data: Array<{ from: string }> }>(
632
+ server,
633
+ "/api/mail?from=mybot",
634
+ );
635
+ expect(body.data.every((m) => m.from === "mybot")).toBe(true);
636
+ });
637
+
638
+ test("?unread=true filters unread messages", async () => {
639
+ insertMsg("a", "b");
640
+ insertMsg("a", "b", { read: true });
641
+
642
+ const server = await startServer();
643
+ const { body } = await getJson<{ data: Array<{ read: boolean }> }>(
644
+ server,
645
+ "/api/mail?unread=true",
646
+ );
647
+ expect(body.data.every((m) => m.read === false)).toBe(true);
648
+ expect(body.data.length).toBe(1);
649
+ });
650
+
651
+ test("pagination: two pages, no duplicates", async () => {
652
+ for (let i = 0; i < 3; i++) {
653
+ insertMsg("x", "y");
654
+ }
655
+
656
+ const server = await startServer();
657
+ const p1 = await getJson<{ data: unknown[]; nextCursor: string | null }>(
658
+ server,
659
+ "/api/mail?limit=2",
660
+ );
661
+ expect(p1.body.data.length).toBe(2);
662
+ expect(p1.body.nextCursor).not.toBeNull();
663
+
664
+ const p2 = await getJson<{ data: unknown[]; nextCursor: string | null }>(
665
+ server,
666
+ `/api/mail?limit=2&cursor=${p1.body.nextCursor}`,
667
+ );
668
+ expect(p2.body.data.length).toBe(1);
669
+ expect(p2.body.nextCursor ?? null).toBeNull();
670
+
671
+ expect(p1.body.data.length + p2.body.data.length).toBe(3);
672
+ });
673
+ });
674
+
675
+ // ─── GET /api/mail/:id ────────────────────────────────────────────────────
676
+
677
+ describe("GET /api/mail/:id", () => {
678
+ test("404 for unknown message", async () => {
679
+ const server = await startServer();
680
+ const { status, body } = await getJson<Record<string, unknown>>(server, "/api/mail/no-such");
681
+ expect(status).toBe(404);
682
+ expect(body.success).toBe(false);
683
+ });
684
+
685
+ test("returns message and thread", async () => {
686
+ const threadId = "thread-abc";
687
+ const msg1 = ctx.mailStore.insert({
688
+ id: "msg-1",
689
+ from: "a",
690
+ to: "b",
691
+ subject: "Start",
692
+ body: "first",
693
+ type: "status" as const,
694
+ priority: "normal" as const,
695
+ threadId,
696
+ });
697
+ ctx.mailStore.insert({
698
+ id: "msg-2",
699
+ from: "b",
700
+ to: "a",
701
+ subject: "Re: Start",
702
+ body: "reply",
703
+ type: "status" as const,
704
+ priority: "normal" as const,
705
+ threadId,
706
+ });
707
+
708
+ const server = await startServer();
709
+ const { status, body } = await getJson<{
710
+ data: { message: { id: string }; thread: Array<{ id: string }> };
711
+ }>(server, `/api/mail/${msg1.id}`);
712
+
713
+ expect(status).toBe(200);
714
+ expect(body.data.message.id).toBe("msg-1");
715
+ expect(body.data.thread.length).toBe(2);
716
+ });
717
+
718
+ test("no threadId: thread contains only the message itself", async () => {
719
+ const msg = ctx.mailStore.insert({
720
+ id: "msg-solo",
721
+ from: "x",
722
+ to: "y",
723
+ subject: "Solo",
724
+ body: "alone",
725
+ type: "status" as const,
726
+ priority: "normal" as const,
727
+ threadId: null,
728
+ });
729
+
730
+ const server = await startServer();
731
+ const { body } = await getJson<{
732
+ data: { message: { id: string }; thread: Array<{ id: string }> };
733
+ }>(server, `/api/mail/${msg.id}`);
734
+ expect(body.data.thread.length).toBe(1);
735
+ expect(body.data.thread[0]?.id).toBe("msg-solo");
736
+ });
737
+ });
738
+
739
+ // ─── POST /api/mail/:id/read ──────────────────────────────────────────────
740
+
741
+ describe("POST /api/mail/:id/read", () => {
742
+ test("marks message as read and returns confirmation", async () => {
743
+ const msg = ctx.mailStore.insert({
744
+ id: "msg-read-test",
745
+ from: "sender",
746
+ to: "recipient",
747
+ subject: "Mark me",
748
+ body: "body",
749
+ type: "status" as const,
750
+ priority: "normal" as const,
751
+ threadId: null,
752
+ });
753
+ expect(msg.read).toBe(false);
754
+
755
+ const server = await startServer();
756
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/mail/${msg.id}/read`, {
757
+ method: "POST",
758
+ });
759
+ expect(res.status).toBe(200);
760
+ const body = (await res.json()) as { data: { id: string; read: boolean } };
761
+ expect(body.data.read).toBe(true);
762
+
763
+ // Verify persistence
764
+ const updated = ctx.mailStore.getById(msg.id);
765
+ expect(updated?.read).toBe(true);
766
+ });
767
+
768
+ test("404 for unknown message", async () => {
769
+ const server = await startServer();
770
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/mail/no-such/read`, {
771
+ method: "POST",
772
+ });
773
+ expect(res.status).toBe(404);
774
+ });
775
+ });
776
+
777
+ // ─── POST /api/mail ───────────────────────────────────────────────────────
778
+
779
+ describe("POST /api/mail", () => {
780
+ async function postJson(
781
+ server: ReturnType<typeof Bun.serve>,
782
+ path: string,
783
+ body: unknown,
784
+ ): Promise<{ status: number; body: Record<string, unknown> }> {
785
+ const res = await fetch(`http://127.0.0.1:${server.port}${path}`, {
786
+ method: "POST",
787
+ headers: { "Content-Type": "application/json" },
788
+ body: JSON.stringify(body),
789
+ });
790
+ const json = (await res.json()) as Record<string, unknown>;
791
+ return { status: res.status, body: json };
792
+ }
793
+
794
+ test("happy path returns 200 + messageId", async () => {
795
+ ctx.sessionStore.upsert(makeSession({ agentName: "alice" }));
796
+
797
+ const server = await startServer();
798
+ const { status, body } = await postJson(server, "/api/mail", {
799
+ to: "alice",
800
+ subject: "Hello",
801
+ body: "world",
802
+ });
803
+
804
+ expect(status).toBe(200);
805
+ expect(body.success).toBe(true);
806
+ const data = body.data as { messageId?: string };
807
+ expect(typeof data.messageId).toBe("string");
808
+ });
809
+
810
+ test("invalid type returns 400", async () => {
811
+ ctx.sessionStore.upsert(makeSession({ agentName: "bob" }));
812
+
813
+ const server = await startServer();
814
+ const { status, body } = await postJson(server, "/api/mail", {
815
+ to: "bob",
816
+ subject: "s",
817
+ body: "b",
818
+ type: "not-a-type",
819
+ });
820
+
821
+ expect(status).toBe(400);
822
+ expect(body.success).toBe(false);
823
+ });
824
+
825
+ test("invalid priority returns 400", async () => {
826
+ ctx.sessionStore.upsert(makeSession({ agentName: "carol" }));
827
+
828
+ const server = await startServer();
829
+ const { status } = await postJson(server, "/api/mail", {
830
+ to: "carol",
831
+ subject: "s",
832
+ body: "b",
833
+ priority: "huge",
834
+ });
835
+
836
+ expect(status).toBe(400);
837
+ });
838
+
839
+ test("unknown recipient returns 400", async () => {
840
+ const server = await startServer();
841
+ const { status } = await postJson(server, "/api/mail", {
842
+ to: "ghost",
843
+ subject: "s",
844
+ body: "b",
845
+ });
846
+ expect(status).toBe(400);
847
+ });
848
+
849
+ test("invalid JSON body returns 400", async () => {
850
+ const server = await startServer();
851
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/mail`, {
852
+ method: "POST",
853
+ headers: { "Content-Type": "application/json" },
854
+ body: "{not json",
855
+ });
856
+ expect(res.status).toBe(400);
857
+ });
858
+
859
+ test("@builders fans out to two active builders", async () => {
860
+ ctx.sessionStore.upsert(
861
+ makeSession({ agentName: "build-1", startedAt: "2024-01-01T00:00:00.000Z" }),
862
+ );
863
+ ctx.sessionStore.upsert(
864
+ makeSession({ agentName: "build-2", startedAt: "2024-01-02T00:00:00.000Z" }),
865
+ );
866
+
867
+ const server = await startServer();
868
+ const { status, body } = await postJson(server, "/api/mail", {
869
+ to: "@builders",
870
+ subject: "s",
871
+ body: "b",
872
+ });
873
+
874
+ expect(status).toBe(200);
875
+ const data = body.data as { messageIds?: string[] };
876
+ expect(Array.isArray(data.messageIds)).toBe(true);
877
+ expect(data.messageIds?.length).toBe(2);
878
+ });
879
+ });
880
+
881
+ // ─── POST /api/mail/:id/reply ─────────────────────────────────────────────
882
+
883
+ describe("POST /api/mail/:id/reply", () => {
884
+ test("returns 200 + messageId", async () => {
885
+ const original = ctx.mailStore.insert({
886
+ id: "msg-reply-orig",
887
+ from: "alice",
888
+ to: "operator",
889
+ subject: "Hi",
890
+ body: "first",
891
+ type: "status" as const,
892
+ priority: "normal" as const,
893
+ threadId: null,
894
+ });
895
+
896
+ const server = await startServer();
897
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/mail/${original.id}/reply`, {
898
+ method: "POST",
899
+ headers: { "Content-Type": "application/json" },
900
+ body: JSON.stringify({ body: "thanks" }),
901
+ });
902
+ expect(res.status).toBe(200);
903
+ const body = (await res.json()) as { data: { messageId: string } };
904
+ expect(typeof body.data.messageId).toBe("string");
905
+
906
+ const reply = ctx.mailStore.getById(body.data.messageId);
907
+ expect(reply?.to).toBe("alice");
908
+ expect(reply?.from).toBe("operator");
909
+ expect(reply?.threadId).toBe(original.id);
910
+ });
911
+
912
+ test("404 for missing id", async () => {
913
+ const server = await startServer();
914
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/mail/no-such/reply`, {
915
+ method: "POST",
916
+ headers: { "Content-Type": "application/json" },
917
+ body: JSON.stringify({ body: "x" }),
918
+ });
919
+ expect(res.status).toBe(404);
920
+ });
921
+
922
+ test("400 when body is missing", async () => {
923
+ const original = ctx.mailStore.insert({
924
+ id: "msg-reply-empty",
925
+ from: "alice",
926
+ to: "operator",
927
+ subject: "Hi",
928
+ body: "first",
929
+ type: "status" as const,
930
+ priority: "normal" as const,
931
+ threadId: null,
932
+ });
933
+
934
+ const server = await startServer();
935
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/mail/${original.id}/reply`, {
936
+ method: "POST",
937
+ headers: { "Content-Type": "application/json" },
938
+ body: JSON.stringify({}),
939
+ });
940
+ expect(res.status).toBe(400);
941
+ });
942
+ });
943
+
944
+ // ─── DELETE /api/mail/:id ─────────────────────────────────────────────────
945
+
946
+ describe("DELETE /api/mail/:id", () => {
947
+ test("deletes the message and returns 200; second call returns 404", async () => {
948
+ const msg = ctx.mailStore.insert({
949
+ id: "msg-to-delete",
950
+ from: "a",
951
+ to: "b",
952
+ subject: "s",
953
+ body: "b",
954
+ type: "status" as const,
955
+ priority: "normal" as const,
956
+ threadId: null,
957
+ });
958
+
959
+ const server = await startServer();
960
+ const res1 = await fetch(`http://127.0.0.1:${server.port}/api/mail/${msg.id}`, {
961
+ method: "DELETE",
962
+ });
963
+ expect(res1.status).toBe(200);
964
+ const body1 = (await res1.json()) as { data: { id: string; deleted: boolean } };
965
+ expect(body1.data.deleted).toBe(true);
966
+ expect(body1.data.id).toBe(msg.id);
967
+ expect(ctx.mailStore.getById(msg.id)).toBeNull();
968
+
969
+ const res2 = await fetch(`http://127.0.0.1:${server.port}/api/mail/${msg.id}`, {
970
+ method: "DELETE",
971
+ });
972
+ expect(res2.status).toBe(404);
973
+ });
974
+
975
+ test("404 when id never existed", async () => {
976
+ const server = await startServer();
977
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/mail/never-existed`, {
978
+ method: "DELETE",
979
+ });
980
+ expect(res.status).toBe(404);
981
+ });
982
+ });
983
+
984
+ // ─── Unknown /api/* routes ────────────────────────────────────────────────
985
+
986
+ describe("unknown /api/* routes", () => {
987
+ test("returns 404 for unregistered paths", async () => {
988
+ const server = await startServer();
989
+ const { status, body } = await getJson<Record<string, unknown>>(
990
+ server,
991
+ "/api/unknown-endpoint",
992
+ );
993
+ expect(status).toBe(404);
994
+ expect(body.success).toBe(false);
995
+ });
996
+ });
997
+
998
+ // ─── Content-Type ─────────────────────────────────────────────────────────
999
+
1000
+ describe("Content-Type", () => {
1001
+ test("all /api/* responses have application/json content-type", async () => {
1002
+ const server = await startServer();
1003
+ const endpoints = ["/api/runs", "/api/agents", "/api/events", "/api/mail"];
1004
+ for (const ep of endpoints) {
1005
+ const res = await get(server, ep);
1006
+ expect(res.headers.get("content-type")).toContain("application/json");
1007
+ }
1008
+ });
1009
+ });
1010
+
1011
+ // ─── Coordinator API ──────────────────────────────────────────────────────
1012
+
1013
+ describe("coordinator API", () => {
1014
+ function makeCoordSession(
1015
+ overrides: Partial<{
1016
+ tmuxSession: string;
1017
+ state: "working" | "completed" | "booting";
1018
+ pid: number;
1019
+ }> = {},
1020
+ ) {
1021
+ return {
1022
+ id: `session-${Date.now()}-coordinator`,
1023
+ agentName: "coordinator",
1024
+ capability: "coordinator",
1025
+ worktreePath: ctx.tempDir,
1026
+ branchName: "main",
1027
+ taskId: "",
1028
+ tmuxSession: overrides.tmuxSession ?? "",
1029
+ state: overrides.state ?? ("working" as const),
1030
+ pid: overrides.pid ?? 99999,
1031
+ parentAgent: null,
1032
+ depth: 0,
1033
+ runId: "run-test",
1034
+ startedAt: new Date().toISOString(),
1035
+ lastActivity: new Date().toISOString(),
1036
+ escalationLevel: 0,
1037
+ stalledSince: null,
1038
+ transcriptPath: null,
1039
+ };
1040
+ }
1041
+
1042
+ // Replace registered handlers with custom action overrides for a test.
1043
+ function reregisterWith(overrides: {
1044
+ startCoordinatorHeadless?: (deps: unknown) => Promise<{
1045
+ started: boolean;
1046
+ alreadyRunning: boolean;
1047
+ pid: number | null;
1048
+ runId: string | null;
1049
+ }>;
1050
+ stopCoordinator?: (deps: unknown) => Promise<{ stopped: boolean }>;
1051
+ checkCoordinatorComplete?: (deps: unknown) => Promise<unknown>;
1052
+ }): void {
1053
+ _resetHandlers();
1054
+ registerRestApi({
1055
+ _runStore: ctx.runStore,
1056
+ _sessionStore: ctx.sessionStore,
1057
+ _eventStore: ctx.eventStore,
1058
+ _mailStore: ctx.mailStore,
1059
+ _coordinatorActionDeps: {
1060
+ projectRoot: ctx.tempDir,
1061
+ _sessionStore: ctx.sessionStore,
1062
+ _mailStore: ctx.mailStore,
1063
+ _askPollIntervalMs: 20,
1064
+ },
1065
+ _coordinatorActions: overrides as Parameters<typeof registerRestApi>[0] extends infer D
1066
+ ? D extends { _coordinatorActions?: infer A }
1067
+ ? A
1068
+ : never
1069
+ : never,
1070
+ });
1071
+ }
1072
+
1073
+ // ─── GET /api/coordinator/state ──────────────────────────────────────
1074
+
1075
+ describe("GET /api/coordinator/state", () => {
1076
+ test("returns running: false when no session", async () => {
1077
+ const server = await startServer();
1078
+ const { status, body } = await getJson<{
1079
+ data: { running: boolean };
1080
+ success: boolean;
1081
+ }>(server, "/api/coordinator/state");
1082
+ expect(status).toBe(200);
1083
+ expect(body.success).toBe(true);
1084
+ expect(body.data.running).toBe(false);
1085
+ });
1086
+
1087
+ test("returns running: true after seeding a session", async () => {
1088
+ ctx.sessionStore.upsert(makeCoordSession({ tmuxSession: "" }));
1089
+ const server = await startServer();
1090
+ const { status, body } = await getJson<{
1091
+ data: { running: boolean; pid: number | null; tmuxSession: string | null };
1092
+ }>(server, "/api/coordinator/state");
1093
+ expect(status).toBe(200);
1094
+ expect(body.data.running).toBe(true);
1095
+ expect(body.data.pid).toBe(99999);
1096
+ expect(body.data.tmuxSession).toBeNull();
1097
+ });
1098
+
1099
+ test("405 for POST", async () => {
1100
+ const server = await startServer();
1101
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/coordinator/state`, {
1102
+ method: "POST",
1103
+ });
1104
+ expect(res.status).toBe(405);
1105
+ });
1106
+ });
1107
+
1108
+ // ─── POST /api/coordinator/send ───────────────────────────────────────
1109
+
1110
+ describe("POST /api/coordinator/send", () => {
1111
+ test("creates mail row for a headless session", async () => {
1112
+ ctx.sessionStore.upsert(makeCoordSession({ tmuxSession: "" }));
1113
+ const server = await startServer();
1114
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/coordinator/send`, {
1115
+ method: "POST",
1116
+ headers: { "Content-Type": "application/json" },
1117
+ body: JSON.stringify({ subject: "hi", body: "the body" }),
1118
+ });
1119
+ expect(res.status).toBe(200);
1120
+ const json = (await res.json()) as { success: boolean; data: { messageId: string } };
1121
+ expect(json.success).toBe(true);
1122
+ expect(json.data.messageId).toMatch(/^msg-/);
1123
+
1124
+ const rows = ctx.mailStore.getAll({ to: "coordinator" });
1125
+ expect(rows.length).toBe(1);
1126
+ expect(rows[0]?.subject).toBe("hi");
1127
+ expect(rows[0]?.body).toBe("the body");
1128
+ });
1129
+
1130
+ test("returns 400 on missing fields", async () => {
1131
+ ctx.sessionStore.upsert(makeCoordSession({ tmuxSession: "" }));
1132
+ const server = await startServer();
1133
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/coordinator/send`, {
1134
+ method: "POST",
1135
+ headers: { "Content-Type": "application/json" },
1136
+ body: JSON.stringify({ body: "no subject" }),
1137
+ });
1138
+ expect(res.status).toBe(400);
1139
+ const json = (await res.json()) as { success: boolean };
1140
+ expect(json.success).toBe(false);
1141
+ });
1142
+
1143
+ test("returns 400 on invalid JSON", async () => {
1144
+ ctx.sessionStore.upsert(makeCoordSession({ tmuxSession: "" }));
1145
+ const server = await startServer();
1146
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/coordinator/send`, {
1147
+ method: "POST",
1148
+ headers: { "Content-Type": "application/json" },
1149
+ body: "{not json",
1150
+ });
1151
+ expect(res.status).toBe(400);
1152
+ });
1153
+
1154
+ test("returns 409 when session is tmux-only", async () => {
1155
+ ctx.sessionStore.upsert(makeCoordSession({ tmuxSession: "tmux-pane" }));
1156
+ const server = await startServer();
1157
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/coordinator/send`, {
1158
+ method: "POST",
1159
+ headers: { "Content-Type": "application/json" },
1160
+ body: JSON.stringify({ subject: "x", body: "y" }),
1161
+ });
1162
+ expect(res.status).toBe(409);
1163
+ });
1164
+
1165
+ test("returns 409 when no coordinator session is running", async () => {
1166
+ const server = await startServer();
1167
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/coordinator/send`, {
1168
+ method: "POST",
1169
+ headers: { "Content-Type": "application/json" },
1170
+ body: JSON.stringify({ subject: "x", body: "y" }),
1171
+ });
1172
+ expect(res.status).toBe(409);
1173
+ });
1174
+
1175
+ test("405 for GET", async () => {
1176
+ const server = await startServer();
1177
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/coordinator/send`);
1178
+ expect(res.status).toBe(405);
1179
+ });
1180
+ });
1181
+
1182
+ // ─── POST /api/coordinator/ask ────────────────────────────────────────
1183
+
1184
+ describe("POST /api/coordinator/ask", () => {
1185
+ test("creates mail and returns timedOut: true after deadline", async () => {
1186
+ ctx.sessionStore.upsert(makeCoordSession({ tmuxSession: "" }));
1187
+ const server = await startServer();
1188
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/coordinator/ask`, {
1189
+ method: "POST",
1190
+ headers: { "Content-Type": "application/json" },
1191
+ body: JSON.stringify({ subject: "Q", body: "?", timeoutSec: 1 }),
1192
+ });
1193
+ expect(res.status).toBe(200);
1194
+ const json = (await res.json()) as {
1195
+ success: boolean;
1196
+ data: { messageId: string; reply: unknown; timedOut: boolean };
1197
+ };
1198
+ expect(json.success).toBe(true);
1199
+ expect(json.data.timedOut).toBe(true);
1200
+ expect(json.data.reply).toBeNull();
1201
+
1202
+ const rows = ctx.mailStore.getAll({ to: "coordinator" });
1203
+ expect(rows.length).toBe(1);
1204
+ });
1205
+
1206
+ test("returns 400 on timeoutSec out of range", async () => {
1207
+ ctx.sessionStore.upsert(makeCoordSession({ tmuxSession: "" }));
1208
+ const server = await startServer();
1209
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/coordinator/ask`, {
1210
+ method: "POST",
1211
+ headers: { "Content-Type": "application/json" },
1212
+ body: JSON.stringify({ subject: "Q", body: "?", timeoutSec: 9999 }),
1213
+ });
1214
+ expect(res.status).toBe(400);
1215
+ });
1216
+ });
1217
+
1218
+ // ─── POST /api/coordinator/check-complete ─────────────────────────────
1219
+
1220
+ describe("POST /api/coordinator/check-complete", () => {
1221
+ test("returns the structured CheckCompleteResult shape", async () => {
1222
+ reregisterWith({
1223
+ checkCoordinatorComplete: async () => ({
1224
+ complete: false,
1225
+ triggers: {
1226
+ allAgentsDone: { enabled: false, met: false, detail: "" },
1227
+ taskTrackerEmpty: { enabled: false, met: false, detail: "" },
1228
+ onShutdownSignal: { enabled: false, met: false, detail: "" },
1229
+ },
1230
+ }),
1231
+ });
1232
+ const server = await startServer();
1233
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/coordinator/check-complete`, {
1234
+ method: "POST",
1235
+ });
1236
+ expect(res.status).toBe(200);
1237
+ const json = (await res.json()) as {
1238
+ success: boolean;
1239
+ data: { complete: boolean; triggers: Record<string, unknown> };
1240
+ };
1241
+ expect(json.success).toBe(true);
1242
+ expect(json.data.complete).toBe(false);
1243
+ expect(json.data.triggers).toBeDefined();
1244
+ });
1245
+ });
1246
+
1247
+ // ─── POST /api/coordinator/start ──────────────────────────────────────
1248
+
1249
+ describe("POST /api/coordinator/start", () => {
1250
+ test("returns started: true when start succeeds (DI stub)", async () => {
1251
+ reregisterWith({
1252
+ startCoordinatorHeadless: async () => ({
1253
+ started: true,
1254
+ alreadyRunning: false,
1255
+ pid: 12345,
1256
+ runId: "run-new",
1257
+ }),
1258
+ });
1259
+ const server = await startServer();
1260
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/coordinator/start`, {
1261
+ method: "POST",
1262
+ });
1263
+ expect(res.status).toBe(200);
1264
+ const json = (await res.json()) as {
1265
+ success: boolean;
1266
+ data: { started: boolean; pid: number };
1267
+ };
1268
+ expect(json.success).toBe(true);
1269
+ expect(json.data.started).toBe(true);
1270
+ expect(json.data.pid).toBe(12345);
1271
+ });
1272
+
1273
+ test("returns alreadyRunning: true when session already exists", async () => {
1274
+ ctx.sessionStore.upsert(makeCoordSession({ tmuxSession: "" }));
1275
+ const server = await startServer();
1276
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/coordinator/start`, {
1277
+ method: "POST",
1278
+ });
1279
+ expect(res.status).toBe(200);
1280
+ const json = (await res.json()) as {
1281
+ data: { alreadyRunning: boolean; started: boolean };
1282
+ };
1283
+ expect(json.data.alreadyRunning).toBe(true);
1284
+ expect(json.data.started).toBe(false);
1285
+ });
1286
+
1287
+ test("405 for GET", async () => {
1288
+ const server = await startServer();
1289
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/coordinator/start`);
1290
+ expect(res.status).toBe(405);
1291
+ });
1292
+ });
1293
+
1294
+ // ─── POST /api/coordinator/stop ───────────────────────────────────────
1295
+
1296
+ describe("POST /api/coordinator/stop", () => {
1297
+ test("returns stopped: true when an active session exists (DI stub)", async () => {
1298
+ ctx.sessionStore.upsert(makeCoordSession({ tmuxSession: "" }));
1299
+ reregisterWith({
1300
+ stopCoordinator: async () => ({ stopped: true }),
1301
+ });
1302
+ const server = await startServer();
1303
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/coordinator/stop`, {
1304
+ method: "POST",
1305
+ });
1306
+ expect(res.status).toBe(200);
1307
+ const json = (await res.json()) as { success: boolean; data: { stopped: boolean } };
1308
+ expect(json.success).toBe(true);
1309
+ expect(json.data.stopped).toBe(true);
1310
+ });
1311
+
1312
+ test("returns stopped: false when no active session", async () => {
1313
+ const server = await startServer();
1314
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/coordinator/stop`, {
1315
+ method: "POST",
1316
+ });
1317
+ expect(res.status).toBe(200);
1318
+ const json = (await res.json()) as { data: { stopped: boolean } };
1319
+ expect(json.data.stopped).toBe(false);
1320
+ });
1321
+ });
1322
+ });
1323
+ });