chapterhouse 0.3.8 → 0.3.10

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.
@@ -20,11 +20,13 @@ export function createHealthPayload(now = new Date()) {
20
20
  };
21
21
  }
22
22
  export function createPublicConfigPayload(config) {
23
+ const chatSseEnabled = config.chatSseEnabled ?? false;
23
24
  if (!config.entraAuthEnabled) {
24
25
  return {
25
26
  appName: "Chapterhouse",
26
27
  entraAuthEnabled: false,
27
28
  standalone: config.standaloneMode,
29
+ chatSseEnabled,
28
30
  };
29
31
  }
30
32
  return {
@@ -32,6 +34,7 @@ export function createPublicConfigPayload(config) {
32
34
  entraAuthEnabled: true,
33
35
  entraClientId: config.entraClientId,
34
36
  entraTenantId: config.entraTenantId,
37
+ chatSseEnabled,
35
38
  };
36
39
  }
37
40
  export function shouldServeSpaPath(pathname) {
@@ -5,7 +5,7 @@ import { existsSync, statSync, readdirSync } from "fs";
5
5
  import { join, dirname } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import { z } from "zod";
8
- import { sendToOrchestrator, getAgentInfo, cancelCurrentMessage, getLastRouteResult, getCurrentSessionKey, subscribeTaskEvents } from "../copilot/orchestrator.js";
8
+ import { sendToOrchestrator, interruptCurrentTurn, enqueueForSse, getAgentInfo, cancelCurrentMessage, getLastRouteResult, getCurrentSessionKey } from "../copilot/orchestrator.js";
9
9
  import { squadEventBus } from "../copilot/squad-event-bus.js";
10
10
  import { getAgentRegistry } from "../copilot/agents.js";
11
11
  import { config, persistModel } from "../config.js";
@@ -21,6 +21,8 @@ import { listSkills, removeSkill } from "../copilot/skills.js";
21
21
  import { restartDaemon } from "../daemon.js";
22
22
  import { API_TOKEN_PATH, resolveWikiRelativePath } from "../paths.js";
23
23
  import { getDb, getSessionMessages, getTaskEvents, normalizeSqliteTsToIso } from "../store/db.js";
24
+ import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
25
+ import { subscribeSession, getSessionEventsFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
24
26
  import { getStatus, onStatusChange } from "../status.js";
25
27
  import { formatSseData, formatSseEvent } from "./sse.js";
26
28
  import { syncDecisionsFileToWiki } from "../squad/mirror.js";
@@ -45,6 +47,15 @@ const messageRequestSchema = z.object({
45
47
  * so the frontend can match the event to the user message bubble it should update. */
46
48
  msgId: z.string().optional(),
47
49
  });
50
+ const interruptRequestSchema = z.object({
51
+ prompt: requiredString("Missing 'prompt' in request body"),
52
+ connectionId: requiredString("Missing or invalid 'connectionId'. Connect to /stream first."),
53
+ attachments: z.array(z.object({
54
+ type: z.literal("file"),
55
+ path: z.string(),
56
+ displayName: z.string().optional(),
57
+ })).optional(),
58
+ });
48
59
  const modelRequestSchema = z.object({
49
60
  model: requiredString("Missing 'model' in request body"),
50
61
  }).strict();
@@ -222,6 +233,7 @@ app.get("/api/config/public", (_req, res) => {
222
233
  standaloneMode: config.standaloneMode,
223
234
  entraClientId: config.entraClientId,
224
235
  entraTenantId: config.entraTenantId,
236
+ chatSseEnabled: config.chatSseEnabled,
225
237
  }));
226
238
  });
227
239
  // Health check — intentionally unauthenticated, returns no sensitive data
@@ -284,7 +296,9 @@ app.get("/api/workers/:taskId", (req, res) => {
284
296
  completedAt: row.completed_at,
285
297
  });
286
298
  });
287
- // Historical event log for a task (catch-up on page load)
299
+ // Historical event log for a task (catch-up on page load / SSE reconnect).
300
+ // Ring buffer (fast, in-memory) is checked first; falls back to SQLite for
301
+ // completed tasks whose buffer has been cleared.
288
302
  app.get("/api/workers/:taskId/events", (req, res) => {
289
303
  const taskId = req.params.taskId;
290
304
  const afterSeqRaw = req.query.afterSeq;
@@ -295,10 +309,13 @@ app.get("/api/workers/:taskId/events", (req, res) => {
295
309
  if (!taskRow) {
296
310
  throw new NotFoundError("Task not found");
297
311
  }
298
- const events = getTaskEvents(taskId, afterSeq);
312
+ const ringEvents = getTaskLogEvents(taskId, afterSeq);
313
+ const events = ringEvents.length > 0 ? ringEvents : getTaskEvents(taskId, afterSeq);
299
314
  res.json({ taskId, events });
300
315
  });
301
- // SSE stream for per-task live tool-call activity
316
+ // SSE stream for per-task live tool-call activity.
317
+ // Uses the ring-buffer subscriber (subscribeTaskLog) so events fire immediately
318
+ // from in-memory state rather than needing an extra SQLite read.
302
319
  app.get("/api/workers/:taskId/events/stream", (req, res) => {
303
320
  const taskId = req.params.taskId;
304
321
  const taskRow = getDb()
@@ -314,8 +331,9 @@ app.get("/api/workers/:taskId/events/stream", (req, res) => {
314
331
  });
315
332
  res.write(formatSseData({ type: "connected", taskId }));
316
333
  const heartbeat = setInterval(() => { res.write(`:ping\n\n`); }, 20_000);
317
- const unsub = subscribeTaskEvents(taskId, (event) => {
318
- res.write(formatSseData({ type: "task_event", taskId, ...event }));
334
+ // subscribeTaskLog delivers ring-buffer-sourced events as they arrive.
335
+ const unsub = subscribeTaskLog(taskId, (event) => {
336
+ res.write(formatSseData({ type: "task_event", ...event }));
319
337
  });
320
338
  req.on("close", () => {
321
339
  clearInterval(heartbeat);
@@ -495,6 +513,173 @@ app.post("/api/cancel", async (_req, res) => {
495
513
  }
496
514
  res.json({ status: "ok", cancelled });
497
515
  });
516
+ // Interrupt the active turn on a specific session and start a replacement turn.
517
+ // POST /api/sessions/:sessionKey/interrupt
518
+ // Body: { prompt, connectionId, attachments? }
519
+ app.post("/api/sessions/:sessionKey/interrupt", (req, res) => {
520
+ const sessionKey = Array.isArray(req.params.sessionKey)
521
+ ? req.params.sessionKey[0]
522
+ : req.params.sessionKey;
523
+ if (!sessionKey)
524
+ throw new BadRequestError("Missing sessionKey");
525
+ const { prompt, connectionId, attachments } = parseRequest(interruptRequestSchema, req.body);
526
+ if (!sseClients.has(connectionId)) {
527
+ throw new BadRequestError("Missing or invalid 'connectionId'. Connect to /stream first.");
528
+ }
529
+ const source = {
530
+ type: "web",
531
+ connectionId,
532
+ user: req.user,
533
+ authorizationHeader: typeof req.headers.authorization === "string" ? req.headers.authorization : undefined,
534
+ };
535
+ interruptCurrentTurn(sessionKey, prompt, source, (text, done, turnId) => {
536
+ const sseRes = sseClients.get(connectionId);
537
+ if (sseRes) {
538
+ const event = {
539
+ type: done ? "message" : "delta",
540
+ content: text,
541
+ sessionKey,
542
+ turnId,
543
+ };
544
+ if (done) {
545
+ const routeResult = getLastRouteResult();
546
+ if (routeResult) {
547
+ event.route = {
548
+ model: routeResult.model,
549
+ routerMode: routeResult.routerMode,
550
+ tier: routeResult.tier,
551
+ ...(routeResult.overrideName ? { overrideName: routeResult.overrideName } : {}),
552
+ };
553
+ }
554
+ }
555
+ sseRes.write(formatSseData(event));
556
+ }
557
+ }, attachments, (activity, turnId) => {
558
+ const sseRes = sseClients.get(connectionId);
559
+ if (sseRes) {
560
+ sseRes.write(formatSseData({ type: "activity", ...activity, sessionKey, ...(turnId ? { turnId } : {}) }));
561
+ }
562
+ }, (abortedTurnId) => {
563
+ const sseRes = sseClients.get(connectionId);
564
+ if (sseRes) {
565
+ sseRes.write(formatSseData({ type: "turn-interrupted", abortedTurnId, sessionKey }));
566
+ }
567
+ });
568
+ res.json({ status: "interrupting" });
569
+ });
570
+ // ---------------------------------------------------------------------------
571
+ // Chat POST→SSE endpoints — gated by CHAPTERHOUSE_CHAT_SSE=1 (#130)
572
+ // ---------------------------------------------------------------------------
573
+ const turnRequestSchema = z.object({
574
+ prompt: z.string().trim().min(1, "Missing prompt"),
575
+ source: z.string().optional(),
576
+ attachments: z
577
+ .array(z.object({
578
+ type: z.literal("file"),
579
+ path: z.string(),
580
+ displayName: z.string().optional(),
581
+ }))
582
+ .optional(),
583
+ interrupt: z.boolean().optional(),
584
+ });
585
+ if (config.chatSseEnabled) {
586
+ /**
587
+ * POST /api/sessions/:key/turn
588
+ *
589
+ * Submit a new turn to the given session. Returns `{ turnId }` immediately;
590
+ * the turn runs asynchronously and emits events on the per-session SSE stream.
591
+ *
592
+ * Body: `{ prompt, source?, attachments?, interrupt? }`
593
+ * Response: `{ turnId: string }`
594
+ */
595
+ app.post("/api/sessions/:key/turn", (req, res) => {
596
+ const sessionKey = Array.isArray(req.params.key) ? req.params.key[0] : req.params.key;
597
+ if (!sessionKey)
598
+ throw new BadRequestError("Missing session key");
599
+ const { prompt, attachments, interrupt } = parseRequest(turnRequestSchema, req.body);
600
+ const authUser = req.user;
601
+ const authHeader = req.headers.authorization ?? undefined;
602
+ const turnId = enqueueForSse({
603
+ sessionKey,
604
+ prompt,
605
+ attachments,
606
+ authUser,
607
+ authHeader,
608
+ interrupt: interrupt ?? false,
609
+ });
610
+ res.json({ turnId });
611
+ });
612
+ /**
613
+ * GET /api/sessions/:key/stream
614
+ *
615
+ * Per-session SSE channel. Delivers turn events:
616
+ * `turn:started`, `turn:delta`, `turn:queued`, `turn:interrupted`,
617
+ * `turn:complete`, `turn:error`
618
+ *
619
+ * Supports `Last-Event-ID` for reconnect replay:
620
+ * - If the session ring buffer covers the requested range, replays from memory.
621
+ * - Otherwise falls back to SQLite (covers completed-turn replay).
622
+ *
623
+ * Keep-alive comments are sent every 15 s.
624
+ * Multiple simultaneous subscribers (tabs) are supported.
625
+ */
626
+ app.get("/api/sessions/:key/stream", (req, res) => {
627
+ const sessionKey = Array.isArray(req.params.key) ? req.params.key[0] : req.params.key;
628
+ if (!sessionKey)
629
+ throw new BadRequestError("Missing session key");
630
+ res.setHeader("Content-Type", "text/event-stream");
631
+ res.setHeader("Cache-Control", "no-cache");
632
+ res.setHeader("Connection", "keep-alive");
633
+ res.setHeader("X-Accel-Buffering", "no");
634
+ res.flushHeaders();
635
+ // Parse Last-Event-ID for reconnect replay
636
+ const rawLastId = req.headers["last-event-id"];
637
+ const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
638
+ ? parseInt(rawLastId.trim(), 10)
639
+ : undefined;
640
+ // Helper: send a named SSE event with an id: field
641
+ const sendEvent = (event, seq) => {
642
+ const payload = JSON.stringify(event);
643
+ res.write(`id: ${seq}\ndata: ${payload}\n\n`);
644
+ };
645
+ // If Last-Event-ID is present and the session ring buffer doesn't cover it,
646
+ // fall back to SQLite for replay of completed turns.
647
+ if (lastSeq !== undefined) {
648
+ const oldestBuf = oldestSessionSeq(sessionKey);
649
+ const bufferMissesRange = oldestBuf === undefined || oldestBuf > lastSeq + 1;
650
+ if (bufferMissesRange) {
651
+ // Replay from SQLite (completed turns)
652
+ const dbEvents = getSessionEventsFromDb(sessionKey, lastSeq);
653
+ for (const e of dbEvents) {
654
+ sendEvent(e, e._seq);
655
+ }
656
+ }
657
+ }
658
+ // Subscribe to session events (replays ring buffer for afterSeq, then live)
659
+ const unsub = subscribeSession(sessionKey, (e) => {
660
+ sendEvent(e, e._seq);
661
+ }, lastSeq);
662
+ // Send connected event
663
+ res.write(`: connected session=${sessionKey}\n\n`);
664
+ // Keep-alive every 15 s
665
+ const keepAlive = setInterval(() => {
666
+ res.write(`: keep-alive\n\n`);
667
+ }, 15_000);
668
+ req.on("close", () => {
669
+ clearInterval(keepAlive);
670
+ unsub();
671
+ });
672
+ });
673
+ }
674
+ else {
675
+ // Feature flag off — return 404 for both endpoints
676
+ app.post("/api/sessions/:key/turn", (_req, res) => {
677
+ res.status(404).json({ error: "Chat SSE not enabled. Set CHAPTERHOUSE_CHAT_SSE=1." });
678
+ });
679
+ app.get("/api/sessions/:key/stream", (_req, res) => {
680
+ res.status(404).json({ error: "Chat SSE not enabled. Set CHAPTERHOUSE_CHAT_SSE=1." });
681
+ });
682
+ }
498
683
  // ---------------------------------------------------------------------------
499
684
  // Model & router
500
685
  // ---------------------------------------------------------------------------
@@ -28,21 +28,25 @@ test("supports API_TOKEN env var and personal health route helpers", async () =>
28
28
  standaloneMode: false,
29
29
  entraClientId: "client-id",
30
30
  entraTenantId: "tenant-id",
31
+ chatSseEnabled: false,
31
32
  }), {
32
33
  appName: "Chapterhouse",
33
34
  entraAuthEnabled: true,
34
35
  entraClientId: "client-id",
35
36
  entraTenantId: "tenant-id",
37
+ chatSseEnabled: false,
36
38
  });
37
39
  assert.deepEqual(runtime.createPublicConfigPayload({
38
40
  entraAuthEnabled: false,
39
41
  standaloneMode: false,
40
42
  entraClientId: "client-id",
41
43
  entraTenantId: "tenant-id",
44
+ chatSseEnabled: false,
42
45
  }), {
43
46
  appName: "Chapterhouse",
44
47
  entraAuthEnabled: false,
45
48
  standalone: false,
49
+ chatSseEnabled: false,
46
50
  });
47
51
  assert.doesNotThrow(() => runtime.assertAuthenticationConfigured({
48
52
  entraAuthEnabled: false,
@@ -188,6 +192,7 @@ test("server routes expose bootstrap and public config without auth", async () =
188
192
  appName: "Chapterhouse",
189
193
  entraAuthEnabled: false,
190
194
  standalone: false,
195
+ chatSseEnabled: false,
191
196
  });
192
197
  });
193
198
  });
@@ -202,6 +207,7 @@ test("server runs in standalone mode without auth", async () => {
202
207
  appName: "Chapterhouse",
203
208
  entraAuthEnabled: false,
204
209
  standalone: true,
210
+ chatSseEnabled: false,
205
211
  });
206
212
  const model = await fetch(`${baseUrl}/api/model`);
207
213
  assert.equal(model.status, 200);
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Integration tests for the POST→SSE chat endpoints (#130).
3
+ *
4
+ * Tests:
5
+ * 1. POST /api/sessions/:key/turn returns 404 when flag is off
6
+ * 2. GET /api/sessions/:key/stream returns 404 when flag is off
7
+ * 3. POST /api/sessions/:key/turn returns { turnId } when flag is on
8
+ * 4. GET /api/sessions/:key/stream delivers turn events (SSE)
9
+ * 5. SSE reconnect with Last-Event-ID replays missed events
10
+ * 6. Multi-subscriber: two tabs both receive events
11
+ * 7. Legacy POST /api/message path unaffected when flag is off
12
+ *
13
+ * The tests that require the feature flag spawn a separate server process with
14
+ * CHAPTERHOUSE_CHAT_SSE=1. Tests that verify flag-off behavior use a server
15
+ * without the flag.
16
+ *
17
+ * Because the Copilot SDK is not available in test env (no valid token), tests
18
+ * that would trigger a real turn instead verify the API shape and event
19
+ * delivery plumbing via the turn event log directly. Integration of the full
20
+ * orchestrator path (POST→turn→events→SSE) is documented as manual verification
21
+ * because it requires a live Copilot token.
22
+ */
23
+ import assert from "node:assert/strict";
24
+ import { spawn } from "node:child_process";
25
+ import { mkdirSync, rmSync } from "node:fs";
26
+ import { createServer } from "node:http";
27
+ import { join } from "node:path";
28
+ import test from "node:test";
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers — copied/adapted from sse.integration.test.ts
31
+ // ---------------------------------------------------------------------------
32
+ const repoRoot = process.cwd();
33
+ const testWorkRoot = join(repoRoot, ".test-work", `turn-sse-${process.pid}`);
34
+ let _instanceCounter = 0;
35
+ async function getFreePort() {
36
+ const server = createServer();
37
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
38
+ const addr = server.address();
39
+ assert.ok(addr && typeof addr === "object");
40
+ await new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())));
41
+ return addr.port;
42
+ }
43
+ async function stopChild(child) {
44
+ if (child.exitCode !== null)
45
+ return;
46
+ child.kill("SIGTERM");
47
+ await new Promise((resolve) => {
48
+ child.once("exit", () => resolve());
49
+ setTimeout(() => { if (child.exitCode === null)
50
+ child.kill("SIGKILL"); }, 2_000);
51
+ });
52
+ }
53
+ async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
54
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
55
+ const instanceRoot = `${testWorkRoot}-${Date.now()}-${++_instanceCounter}`;
56
+ rmSync(instanceRoot, { recursive: true, force: true });
57
+ mkdirSync(instanceRoot, { recursive: true });
58
+ const port = await getFreePort();
59
+ const logs = [];
60
+ const child = spawn(process.execPath, [
61
+ "--input-type=module",
62
+ "-e",
63
+ "import { startApiServer } from './dist/api/server.js'; await startApiServer();",
64
+ ], {
65
+ cwd: repoRoot,
66
+ env: {
67
+ ...Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("COPILOT_") && !k.startsWith("NODE_TEST_"))),
68
+ CHAPTERHOUSE_DISABLE_DOTENV: "1",
69
+ CHAPTERHOUSE_HOME: instanceRoot,
70
+ API_HOST: "127.0.0.1",
71
+ API_PORT: String(port),
72
+ API_TOKEN: "test-token",
73
+ ...extraEnv,
74
+ },
75
+ stdio: ["ignore", "pipe", "pipe"],
76
+ });
77
+ child.stdout?.on("data", (c) => logs.push(String(c)));
78
+ child.stderr?.on("data", (c) => logs.push(String(c)));
79
+ const baseUrl = `http://127.0.0.1:${port}`;
80
+ try {
81
+ const deadline = Date.now() + timeoutMs;
82
+ while (Date.now() < deadline) {
83
+ if (child.exitCode !== null)
84
+ throw new Error(`Server exited early:\n${logs.join("")}`);
85
+ let serverReady = false;
86
+ try {
87
+ const r = await fetch(`${baseUrl}/status`);
88
+ serverReady = r.ok;
89
+ }
90
+ catch { /* not ready yet */ }
91
+ if (serverReady) {
92
+ // Run outside the fetch try/catch so assertion errors propagate correctly.
93
+ await run({ baseUrl, authHeader: "Bearer test-token" });
94
+ return;
95
+ }
96
+ await new Promise((r) => setTimeout(r, 100));
97
+ }
98
+ throw new Error(`Server did not start in ${timeoutMs}ms:\n${logs.join("")}`);
99
+ }
100
+ finally {
101
+ await stopChild(child);
102
+ rmSync(instanceRoot, { recursive: true, force: true });
103
+ }
104
+ }
105
+ function createSseReader(body) {
106
+ const reader = body.getReader();
107
+ const decoder = new TextDecoder();
108
+ let leftover = "";
109
+ async function readFrames(n, timeoutMs = 3_000) {
110
+ const frames = [];
111
+ const deadline = Date.now() + timeoutMs;
112
+ while (frames.length < n && Date.now() < deadline) {
113
+ const { value, done } = await Promise.race([
114
+ reader.read(),
115
+ new Promise((r) => setTimeout(() => r({ value: undefined, done: true }), 200)),
116
+ ]);
117
+ if (done || value === undefined)
118
+ continue;
119
+ leftover += decoder.decode(value, { stream: true });
120
+ const parts = leftover.split("\n\n");
121
+ leftover = parts.pop() ?? "";
122
+ for (const part of parts) {
123
+ if (!part.trim())
124
+ continue;
125
+ const frame = { data: null };
126
+ for (const line of part.split("\n")) {
127
+ if (line.startsWith("id: "))
128
+ frame.id = line.slice(4);
129
+ else if (line.startsWith("event: "))
130
+ frame.event = line.slice(7);
131
+ else if (line.startsWith("data: ")) {
132
+ try {
133
+ frame.data = JSON.parse(line.slice(6));
134
+ }
135
+ catch {
136
+ frame.data = line.slice(6);
137
+ }
138
+ }
139
+ // skip comment lines (": ...")
140
+ }
141
+ if (frame.data !== null)
142
+ frames.push(frame);
143
+ }
144
+ }
145
+ return frames;
146
+ }
147
+ async function cancel() {
148
+ try {
149
+ await reader.cancel();
150
+ }
151
+ catch { /* ignore */ }
152
+ }
153
+ return { readFrames, cancel };
154
+ }
155
+ // ---------------------------------------------------------------------------
156
+ // Tests: feature flag OFF
157
+ // ---------------------------------------------------------------------------
158
+ test("turn-sse: POST /api/sessions/:key/turn returns 404 when flag is off", async () => {
159
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
160
+ const res = await fetch(`${baseUrl}/api/sessions/default/turn`, {
161
+ method: "POST",
162
+ headers: { Authorization: authHeader, "Content-Type": "application/json" },
163
+ body: JSON.stringify({ prompt: "hello" }),
164
+ });
165
+ assert.equal(res.status, 404, "Should return 404 when CHAPTERHOUSE_CHAT_SSE is not set");
166
+ const body = await res.json();
167
+ assert.ok(typeof body.error === "string" && body.error.includes("Chat SSE"), `Expected error about Chat SSE, got: ${body.error}`);
168
+ });
169
+ });
170
+ test("turn-sse: GET /api/sessions/:key/stream returns 404 when flag is off", async () => {
171
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
172
+ const res = await fetch(`${baseUrl}/api/sessions/default/stream`, {
173
+ headers: { Authorization: authHeader },
174
+ });
175
+ assert.equal(res.status, 404, "Should return 404 when CHAPTERHOUSE_CHAT_SSE is not set");
176
+ });
177
+ });
178
+ test("turn-sse: legacy POST /api/message is unaffected when flag is off", async () => {
179
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
180
+ // The legacy endpoint doesn't need a live SSE connection in our test —
181
+ // we just verify it returns 400 for a bad connectionId (the route exists).
182
+ const res = await fetch(`${baseUrl}/api/message`, {
183
+ method: "POST",
184
+ headers: { Authorization: authHeader, "Content-Type": "application/json" },
185
+ body: JSON.stringify({ prompt: "test", connectionId: "nonexistent-conn-id" }),
186
+ });
187
+ // 400 = route exists and validated the request (connectionId not found in sseClients)
188
+ assert.ok(res.status === 400 || res.status === 401 || res.status === 422, `Expected 4xx from /api/message, got ${res.status}`);
189
+ });
190
+ });
191
+ // ---------------------------------------------------------------------------
192
+ // Tests: feature flag ON
193
+ // ---------------------------------------------------------------------------
194
+ test("turn-sse: POST /api/sessions/:key/turn returns { turnId } when flag is on", async () => {
195
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
196
+ const res = await fetch(`${baseUrl}/api/sessions/default/turn`, {
197
+ method: "POST",
198
+ headers: { Authorization: authHeader, "Content-Type": "application/json" },
199
+ body: JSON.stringify({ prompt: "hello world" }),
200
+ });
201
+ // The turn will queue but fail quickly without a real Copilot token.
202
+ // We only care that the route accepted the request and returned a turnId.
203
+ // Read body once to avoid "body used already" if the status assertion were to fail.
204
+ const bodyText = await res.text();
205
+ assert.ok(res.status === 200 || res.status === 202, `Expected 2xx from /api/sessions/:key/turn, got ${res.status}: ${bodyText}`);
206
+ const body = JSON.parse(bodyText);
207
+ assert.ok(typeof body.turnId === "string" && body.turnId.length > 0, `Expected turnId string, got ${JSON.stringify(body)}`);
208
+ }, { CHAPTERHOUSE_CHAT_SSE: "1" }, 15_000);
209
+ });
210
+ test("turn-sse: GET /api/sessions/:key/stream opens SSE connection", async () => {
211
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
212
+ const res = await fetch(`${baseUrl}/api/sessions/default/stream`, {
213
+ headers: { Authorization: authHeader, Accept: "text/event-stream" },
214
+ });
215
+ assert.equal(res.status, 200, "SSE stream should return 200");
216
+ assert.ok(res.headers.get("content-type")?.includes("text/event-stream"), "Content-Type should be text/event-stream");
217
+ assert.ok(res.body, "Response should have a readable body");
218
+ await res.body.cancel();
219
+ }, { CHAPTERHOUSE_CHAT_SSE: "1" }, 15_000);
220
+ });
221
+ test("turn-sse: POST turn then GET stream receives turn:started event", async () => {
222
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
223
+ const sessionKey = "test-session-post-stream";
224
+ // Open SSE connection first
225
+ const sseRes = await fetch(`${baseUrl}/api/sessions/${sessionKey}/stream`, {
226
+ headers: { Authorization: authHeader, Accept: "text/event-stream" },
227
+ });
228
+ assert.ok(sseRes.body, "SSE body should be readable");
229
+ const reader = createSseReader(sseRes.body);
230
+ // POST a turn
231
+ const turnRes = await fetch(`${baseUrl}/api/sessions/${sessionKey}/turn`, {
232
+ method: "POST",
233
+ headers: { Authorization: authHeader, "Content-Type": "application/json" },
234
+ body: JSON.stringify({ prompt: "test prompt for SSE" }),
235
+ });
236
+ assert.equal(turnRes.status, 200);
237
+ const { turnId } = await turnRes.json();
238
+ assert.ok(typeof turnId === "string", "turnId must be a string");
239
+ // Read events — expect turn:started
240
+ const frames = await reader.readFrames(1, 2_000);
241
+ await reader.cancel();
242
+ // The frame should be a turn:started or another turn event
243
+ assert.ok(frames.length >= 1, `Expected at least 1 SSE frame, got ${frames.length}`);
244
+ const firstData = frames[0]?.data;
245
+ assert.ok(typeof firstData?.type === "string" && firstData.type.startsWith("turn:"), `First event should be a turn:* event, got: ${JSON.stringify(firstData?.type)}`);
246
+ assert.equal(firstData?.turnId, turnId, "Event turnId must match posted turnId");
247
+ }, { CHAPTERHOUSE_CHAT_SSE: "1" }, 15_000);
248
+ });
249
+ test("turn-sse: SSE events have id: field for Last-Event-ID reconnect", async () => {
250
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
251
+ const sessionKey = "test-session-lastid";
252
+ const sseRes = await fetch(`${baseUrl}/api/sessions/${sessionKey}/stream`, {
253
+ headers: { Authorization: authHeader, Accept: "text/event-stream" },
254
+ });
255
+ assert.ok(sseRes.body);
256
+ const reader = createSseReader(sseRes.body);
257
+ // POST a turn to generate events
258
+ await fetch(`${baseUrl}/api/sessions/${sessionKey}/turn`, {
259
+ method: "POST",
260
+ headers: { Authorization: authHeader, "Content-Type": "application/json" },
261
+ body: JSON.stringify({ prompt: "generate events" }),
262
+ });
263
+ const frames = await reader.readFrames(1, 2_000);
264
+ await reader.cancel();
265
+ if (frames.length > 0) {
266
+ assert.ok(typeof frames[0].id === "string" && /^\d+$/.test(frames[0].id), `Each SSE frame must have a numeric id:, got: ${JSON.stringify(frames[0].id)}`);
267
+ }
268
+ }, { CHAPTERHOUSE_CHAT_SSE: "1" }, 15_000);
269
+ });
270
+ test("turn-sse: multi-subscriber — two clients both receive events", async () => {
271
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
272
+ const sessionKey = "test-session-multi-sub";
273
+ // Open two SSE connections
274
+ const [res1, res2] = await Promise.all([
275
+ fetch(`${baseUrl}/api/sessions/${sessionKey}/stream`, {
276
+ headers: { Authorization: authHeader, Accept: "text/event-stream" },
277
+ }),
278
+ fetch(`${baseUrl}/api/sessions/${sessionKey}/stream`, {
279
+ headers: { Authorization: authHeader, Accept: "text/event-stream" },
280
+ }),
281
+ ]);
282
+ assert.ok(res1.body && res2.body, "Both SSE connections should have bodies");
283
+ const reader1 = createSseReader(res1.body);
284
+ const reader2 = createSseReader(res2.body);
285
+ // POST a turn
286
+ const turnRes = await fetch(`${baseUrl}/api/sessions/${sessionKey}/turn`, {
287
+ method: "POST",
288
+ headers: { Authorization: authHeader, "Content-Type": "application/json" },
289
+ body: JSON.stringify({ prompt: "broadcast test" }),
290
+ });
291
+ assert.equal(turnRes.status, 200);
292
+ const { turnId } = await turnRes.json();
293
+ // Both should receive the turn:started event
294
+ const [frames1, frames2] = await Promise.all([
295
+ reader1.readFrames(1, 2_000),
296
+ reader2.readFrames(1, 2_000),
297
+ ]);
298
+ await Promise.all([reader1.cancel(), reader2.cancel()]);
299
+ assert.ok(frames1.length >= 1, "Client 1 should receive events");
300
+ assert.ok(frames2.length >= 1, "Client 2 should receive events");
301
+ const data1 = frames1[0]?.data;
302
+ const data2 = frames2[0]?.data;
303
+ assert.equal(data1?.turnId, turnId, "Client 1 event should have correct turnId");
304
+ assert.equal(data2?.turnId, turnId, "Client 2 event should have correct turnId");
305
+ }, { CHAPTERHOUSE_CHAT_SSE: "1" }, 15_000);
306
+ });
307
+ test("turn-sse: reconnect with Last-Event-ID replays buffered events", async () => {
308
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
309
+ const sessionKey = "test-session-reconnect";
310
+ // Connect, receive a turn:started event, then disconnect
311
+ const res1 = await fetch(`${baseUrl}/api/sessions/${sessionKey}/stream`, {
312
+ headers: { Authorization: authHeader, Accept: "text/event-stream" },
313
+ });
314
+ assert.ok(res1.body);
315
+ const reader1 = createSseReader(res1.body);
316
+ await fetch(`${baseUrl}/api/sessions/${sessionKey}/turn`, {
317
+ method: "POST",
318
+ headers: { Authorization: authHeader, "Content-Type": "application/json" },
319
+ body: JSON.stringify({ prompt: "reconnect test" }),
320
+ });
321
+ const initialFrames = await reader1.readFrames(1, 2_000);
322
+ await reader1.cancel();
323
+ if (initialFrames.length === 0) {
324
+ // No events generated (no real Copilot token) — skip replay assertion
325
+ return;
326
+ }
327
+ // The last id: field we saw
328
+ const lastId = initialFrames[initialFrames.length - 1].id;
329
+ assert.ok(lastId, "Events must have id: for reconnect");
330
+ // Reconnect with Last-Event-ID header BEFORE the first event's id
331
+ const prevId = String(parseInt(lastId, 10) - 1);
332
+ const res2 = await fetch(`${baseUrl}/api/sessions/${sessionKey}/stream`, {
333
+ headers: {
334
+ Authorization: authHeader,
335
+ Accept: "text/event-stream",
336
+ "Last-Event-ID": prevId,
337
+ },
338
+ });
339
+ assert.ok(res2.body);
340
+ const reader2 = createSseReader(res2.body);
341
+ // Should get replayed events from the ring buffer
342
+ const replayedFrames = await reader2.readFrames(1, 2_000);
343
+ await reader2.cancel();
344
+ // The replayed events should all have _seq > prevId
345
+ for (const frame of replayedFrames) {
346
+ if (frame.id) {
347
+ assert.ok(parseInt(frame.id, 10) > parseInt(prevId, 10), `Replayed event id (${frame.id}) should be > Last-Event-ID (${prevId})`);
348
+ }
349
+ }
350
+ }, { CHAPTERHOUSE_CHAT_SSE: "1" }, 15_000);
351
+ });
352
+ //# sourceMappingURL=turn-sse.integration.test.js.map