chapterhouse 0.3.24 → 0.3.26

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,7 +20,7 @@ export function createHealthPayload(now = new Date()) {
20
20
  };
21
21
  }
22
22
  export function createPublicConfigPayload(config) {
23
- const chatSseEnabled = config.chatSseEnabled ?? false;
23
+ const chatSseEnabled = config.chatSseEnabled ?? true;
24
24
  if (!config.entraAuthEnabled) {
25
25
  return {
26
26
  appName: "Chapterhouse",
@@ -383,14 +383,34 @@ app.get("/api/workers/:taskId/events", (req, res) => {
383
383
  ? parseInt(rawLastId.trim(), 10)
384
384
  : undefined;
385
385
  const sendEvent = (event) => {
386
- const payload = {
387
- taskId: event.taskId,
388
- seq: event.seq,
389
- ts: event.ts,
390
- kind: event.kind,
391
- toolName: event.toolName,
392
- summary: event.summary,
393
- };
386
+ let payload;
387
+ if (event.kind === "output_delta") {
388
+ payload = {
389
+ type: "output_delta",
390
+ taskId: event.taskId,
391
+ seq: event.seq,
392
+ text: event.text ?? "",
393
+ };
394
+ }
395
+ else if (event.kind === "task_status") {
396
+ payload = {
397
+ type: "task_status",
398
+ taskId: event.taskId,
399
+ seq: event.seq,
400
+ status: event.status ?? "running",
401
+ summary: event.summary,
402
+ };
403
+ }
404
+ else {
405
+ payload = {
406
+ taskId: event.taskId,
407
+ seq: event.seq,
408
+ ts: event.ts,
409
+ kind: event.kind,
410
+ toolName: event.toolName,
411
+ summary: event.summary,
412
+ };
413
+ }
394
414
  res.write(`id: ${event.seq}\ndata: ${JSON.stringify(payload)}\n\n`);
395
415
  };
396
416
  let replayHighSeq = lastSeq;
@@ -427,24 +447,41 @@ app.get("/api/workers/:taskId/events", (req, res) => {
427
447
  const heartbeat = setInterval(() => {
428
448
  res.write(`: keep-alive\n\n`);
429
449
  }, 15_000);
430
- const unsubscribeTaskLog = subscribeTaskLog(taskId, (event) => {
450
+ let cleaned = false;
451
+ let unsubscribeTaskLog;
452
+ let unsubscribeDestroyed;
453
+ let unsubscribeError;
454
+ const cleanup = () => {
455
+ if (cleaned)
456
+ return;
457
+ cleaned = true;
458
+ clearInterval(heartbeat);
459
+ unsubscribeTaskLog?.();
460
+ unsubscribeDestroyed?.();
461
+ unsubscribeError?.();
462
+ };
463
+ unsubscribeTaskLog = subscribeTaskLog(taskId, (event) => {
431
464
  sendEvent(event);
465
+ // Close SSE when a terminal task_status event arrives
466
+ if (event.kind === "task_status" && event.status && TERMINAL_TASK_STATUSES.has(event.status)) {
467
+ cleanup();
468
+ res.end();
469
+ }
432
470
  });
433
- const unsubscribeDestroyed = agentEventBus.subscribe("session:destroyed", (event) => {
471
+ unsubscribeDestroyed = agentEventBus.subscribe("session:destroyed", (event) => {
434
472
  if (event.sessionId === taskId && isTerminal()) {
473
+ cleanup();
435
474
  res.end();
436
475
  }
437
476
  });
438
- const unsubscribeError = agentEventBus.subscribe("session:error", (event) => {
477
+ unsubscribeError = agentEventBus.subscribe("session:error", (event) => {
439
478
  if (event.sessionId === taskId && isTerminal()) {
479
+ cleanup();
440
480
  res.end();
441
481
  }
442
482
  });
443
483
  req.on("close", () => {
444
- clearInterval(heartbeat);
445
- unsubscribeTaskLog();
446
- unsubscribeDestroyed();
447
- unsubscribeError();
484
+ cleanup();
448
485
  });
449
486
  });
450
487
  // ---------------------------------------------------------------------------
@@ -631,7 +668,7 @@ app.post("/api/sessions/:sessionKey/interrupt", (req, res) => {
631
668
  res.json({ status: "interrupting" });
632
669
  });
633
670
  // ---------------------------------------------------------------------------
634
- // Chat POST→SSE endpoints — gated by CHAPTERHOUSE_CHAT_SSE=1 (#130)
671
+ // Chat POST→SSE endpoints — enabled by default, overridable via CHAPTERHOUSE_CHAT_SSE (#130)
635
672
  // ---------------------------------------------------------------------------
636
673
  const turnRequestSchema = z.object({
637
674
  prompt: z.string().trim().min(1, "Missing prompt"),
@@ -1,10 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
- import { createServer } from "node:http";
5
4
  import { join } from "node:path";
6
5
  import test from "node:test";
7
6
  import Database from "better-sqlite3";
7
+ import { DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, STANDALONE_API_SERVER_STARTUP_TIMEOUT_MS, getFreePort, stopChild, waitForApiServerReady, } from "../test/api-server.js";
8
8
  test("supports API_TOKEN env var and personal health route helpers", async () => {
9
9
  const runtime = await import("./server-runtime.js");
10
10
  assert.equal(typeof runtime.resolveApiToken, "function", "resolveApiToken should be exported");
@@ -29,25 +29,25 @@ test("supports API_TOKEN env var and personal health route helpers", async () =>
29
29
  standaloneMode: false,
30
30
  entraClientId: "client-id",
31
31
  entraTenantId: "tenant-id",
32
- chatSseEnabled: false,
32
+ chatSseEnabled: true,
33
33
  }), {
34
34
  appName: "Chapterhouse",
35
35
  entraAuthEnabled: true,
36
36
  entraClientId: "client-id",
37
37
  entraTenantId: "tenant-id",
38
- chatSseEnabled: false,
38
+ chatSseEnabled: true,
39
39
  });
40
40
  assert.deepEqual(runtime.createPublicConfigPayload({
41
41
  entraAuthEnabled: false,
42
42
  standaloneMode: false,
43
43
  entraClientId: "client-id",
44
44
  entraTenantId: "tenant-id",
45
- chatSseEnabled: false,
45
+ chatSseEnabled: true,
46
46
  }), {
47
47
  appName: "Chapterhouse",
48
48
  entraAuthEnabled: false,
49
49
  standalone: false,
50
- chatSseEnabled: false,
50
+ chatSseEnabled: true,
51
51
  });
52
52
  assert.doesNotThrow(() => runtime.assertAuthenticationConfigured({
53
53
  entraAuthEnabled: false,
@@ -62,6 +62,21 @@ test("supports API_TOKEN env var and personal health route helpers", async () =>
62
62
  apiToken: null,
63
63
  }));
64
64
  });
65
+ test("createPublicConfigPayload defaults chat SSE on when not explicitly provided", async () => {
66
+ const runtime = await import("./server-runtime.js");
67
+ assert.equal(typeof runtime.createPublicConfigPayload, "function", "createPublicConfigPayload should be exported");
68
+ assert.deepEqual(runtime.createPublicConfigPayload({
69
+ entraAuthEnabled: false,
70
+ standaloneMode: true,
71
+ entraClientId: "client-id",
72
+ entraTenantId: "tenant-id",
73
+ }), {
74
+ appName: "Chapterhouse",
75
+ entraAuthEnabled: false,
76
+ standalone: true,
77
+ chatSseEnabled: true,
78
+ });
79
+ });
65
80
  test("formats named SSE status events", async () => {
66
81
  const sse = await import("./sse.js");
67
82
  assert.equal(typeof sse.formatSseEvent, "function", "formatSseEvent should be exported");
@@ -109,42 +124,18 @@ function readProjectRegistryRows(testRoot) {
109
124
  db.close();
110
125
  }
111
126
  }
112
- async function getFreePort() {
113
- const server = createServer();
114
- await new Promise((resolve) => {
115
- server.listen(0, "127.0.0.1", () => resolve());
116
- });
117
- const address = server.address();
118
- assert.ok(address && typeof address === "object", "server should expose a bound address");
119
- await new Promise((resolve, reject) => {
120
- server.close((err) => (err ? reject(err) : resolve()));
121
- });
122
- return address.port;
123
- }
124
- async function stopChild(child) {
125
- if (child.exitCode !== null) {
126
- return;
127
- }
128
- child.kill("SIGTERM");
129
- await new Promise((resolve) => {
130
- child.once("exit", () => resolve());
131
- setTimeout(() => {
132
- if (child.exitCode === null) {
133
- child.kill("SIGKILL");
134
- }
135
- }, 2_000);
136
- });
137
- }
138
127
  // Standalone mode triggers a full daemon init via the server→daemon circular import
139
128
  // (server.ts imports restartDaemon from daemon.ts, which has module-level main()).
140
129
  // The Copilot SDK's client.start() then blocks the event loop while authenticating,
141
- // so the HTTP server cannot respond until SDK init completes (~10-20 s depending on
142
- // session state). Other modes complete in ~330 ms because the SDK yields quickly.
130
+ // so the HTTP server cannot respond until SDK init completes (~10-30 s depending on
131
+ // session state and runner load). Other modes usually complete much faster, but cold
132
+ // CI starts can still exceed a 10 s budget.
143
133
  // Root cause tracked in decisions notes (backend fix: lazy-import or
144
- // guard env var in daemon.ts). Interim fix: 30 s timeout for standalone, 10 s elsewhere.
134
+ // guard env var in daemon.ts). Interim fix: use a more generous startup timeout for
135
+ // integration tests while keeping the same /status readiness probe.
145
136
  // Additionally, strip COPILOT_* env vars from the child so it doesn't accidentally
146
137
  // piggy-back on the running agent session, which worsens the blocking behaviour.
147
- async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
138
+ async function withStartedServer(run, extraEnv = {}, timeoutMs = DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS) {
148
139
  const testRoot = join(repoRoot, ".test-work", `server-routes-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
149
140
  mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
150
141
  rmSync(testRoot, { recursive: true, force: true });
@@ -174,24 +165,8 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
174
165
  child.stderr?.on("data", (chunk) => logs.push(String(chunk)));
175
166
  const baseUrl = `http://127.0.0.1:${port}`;
176
167
  try {
177
- const deadline = Date.now() + timeoutMs;
178
- while (Date.now() < deadline) {
179
- if (child.exitCode !== null) {
180
- throw new Error(`API server exited early:\n${logs.join("")}`);
181
- }
182
- try {
183
- const response = await fetch(`${baseUrl}/status`);
184
- if (response.ok) {
185
- await run({ baseUrl, authHeader: "Bearer route-token", testRoot });
186
- return;
187
- }
188
- }
189
- catch {
190
- // Server still starting.
191
- }
192
- await new Promise((resolve) => setTimeout(resolve, 100));
193
- }
194
- throw new Error(`Timed out waiting for API server to start:\n${logs.join("")}`);
168
+ await waitForApiServerReady({ child, baseUrl, logs, timeoutMs });
169
+ await run({ baseUrl, authHeader: "Bearer route-token", testRoot });
195
170
  }
196
171
  finally {
197
172
  await stopChild(child);
@@ -209,7 +184,7 @@ test("server routes expose bootstrap and public config without auth", async () =
209
184
  appName: "Chapterhouse",
210
185
  entraAuthEnabled: false,
211
186
  standalone: false,
212
- chatSseEnabled: false,
187
+ chatSseEnabled: true,
213
188
  });
214
189
  });
215
190
  });
@@ -224,14 +199,14 @@ test("server runs in standalone mode without auth", async () => {
224
199
  appName: "Chapterhouse",
225
200
  entraAuthEnabled: false,
226
201
  standalone: true,
227
- chatSseEnabled: false,
202
+ chatSseEnabled: true,
228
203
  });
229
204
  const model = await fetch(`${baseUrl}/api/model`);
230
205
  assert.equal(model.status, 200);
231
206
  assert.deepEqual(await model.json(), { model: "claude-sonnet-4.6" });
232
207
  }, {
233
208
  API_TOKEN: "",
234
- }, 30_000);
209
+ }, STANDALONE_API_SERVER_STARTUP_TIMEOUT_MS);
235
210
  });
236
211
  test("server bootstrap rejects non-loopback origins", async () => {
237
212
  await withStartedServer(async ({ baseUrl }) => {
@@ -1,42 +1,16 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { spawn } from "node:child_process";
3
3
  import { mkdirSync, rmSync } from "node:fs";
4
- import { createServer } from "node:http";
5
4
  import { join } from "node:path";
6
5
  import test from "node:test";
6
+ import { DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, getFreePort, stopChild, waitForApiServerReady, } from "../test/api-server.js";
7
7
  // ---------------------------------------------------------------------------
8
8
  // Server harness (copied from server.test.ts; distinct root path suffix to
9
9
  // avoid collision when both test files run in parallel in the same process).
10
10
  // ---------------------------------------------------------------------------
11
11
  const repoRoot = process.cwd();
12
12
  const sseTestRoot = join(repoRoot, ".test-work", `sse-integration-${process.pid}`);
13
- async function getFreePort() {
14
- const server = createServer();
15
- await new Promise((resolve) => {
16
- server.listen(0, "127.0.0.1", () => resolve());
17
- });
18
- const address = server.address();
19
- assert.ok(address && typeof address === "object", "server should expose a bound address");
20
- await new Promise((resolve, reject) => {
21
- server.close((err) => (err ? reject(err) : resolve()));
22
- });
23
- return address.port;
24
- }
25
- async function stopChild(child) {
26
- if (child.exitCode !== null) {
27
- return;
28
- }
29
- child.kill("SIGTERM");
30
- await new Promise((resolve) => {
31
- child.once("exit", () => resolve());
32
- setTimeout(() => {
33
- if (child.exitCode === null) {
34
- child.kill("SIGKILL");
35
- }
36
- }, 2_000);
37
- });
38
- }
39
- async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
13
+ async function withStartedServer(run, extraEnv = {}, timeoutMs = DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS) {
40
14
  mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
41
15
  rmSync(sseTestRoot, { recursive: true, force: true });
42
16
  mkdirSync(sseTestRoot, { recursive: true });
@@ -65,24 +39,8 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
65
39
  child.stderr?.on("data", (chunk) => logs.push(String(chunk)));
66
40
  const baseUrl = `http://127.0.0.1:${port}`;
67
41
  try {
68
- const deadline = Date.now() + timeoutMs;
69
- while (Date.now() < deadline) {
70
- if (child.exitCode !== null) {
71
- throw new Error(`API server exited early:\n${logs.join("")}`);
72
- }
73
- try {
74
- const response = await fetch(`${baseUrl}/status`);
75
- if (response.ok) {
76
- await run({ baseUrl, authHeader: "Bearer route-token" });
77
- return;
78
- }
79
- }
80
- catch {
81
- // Server still starting.
82
- }
83
- await new Promise((resolve) => setTimeout(resolve, 100));
84
- }
85
- throw new Error(`Timed out waiting for API server to start:\n${logs.join("")}`);
42
+ await waitForApiServerReady({ child, baseUrl, logs, timeoutMs });
43
+ await run({ baseUrl, authHeader: "Bearer route-token" });
86
44
  }
87
45
  finally {
88
46
  await stopChild(child);
@@ -2,8 +2,8 @@
2
2
  * Integration tests for the POST→SSE chat endpoints (#130).
3
3
  *
4
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
5
+ * 1. POST /api/sessions/:key/turn returns 404 when flag is explicitly off
6
+ * 2. GET /api/sessions/:key/stream returns 404 when flag is explicitly off
7
7
  * 3. POST /api/sessions/:key/turn returns { turnId } when flag is on
8
8
  * 4. GET /api/sessions/:key/stream delivers turn events (SSE)
9
9
  * 5. SSE reconnect with Last-Event-ID replays missed events
@@ -11,8 +11,8 @@
11
11
  * 7. Legacy POST /api/message path unaffected when flag is off
12
12
  *
13
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.
14
+ * CHAPTERHOUSE_CHAT_SSE defaults on. Tests that verify flag-off behavior use a
15
+ * server with CHAPTERHOUSE_CHAT_SSE=0.
16
16
  *
17
17
  * Because the Copilot SDK is not available in test env (no valid token), tests
18
18
  * that would trigger a real turn instead verify the API shape and event
@@ -23,34 +23,16 @@
23
23
  import assert from "node:assert/strict";
24
24
  import { spawn } from "node:child_process";
25
25
  import { mkdirSync, rmSync } from "node:fs";
26
- import { createServer } from "node:http";
27
26
  import { join } from "node:path";
28
27
  import test from "node:test";
28
+ import { DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, getFreePort, stopChild, waitForApiServerReady, } from "../test/api-server.js";
29
29
  // ---------------------------------------------------------------------------
30
30
  // Helpers — copied/adapted from sse.integration.test.ts
31
31
  // ---------------------------------------------------------------------------
32
32
  const repoRoot = process.cwd();
33
33
  const testWorkRoot = join(repoRoot, ".test-work", `turn-sse-${process.pid}`);
34
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) {
35
+ async function withStartedServer(run, extraEnv = {}, timeoutMs = DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS) {
54
36
  mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
55
37
  const instanceRoot = `${testWorkRoot}-${Date.now()}-${++_instanceCounter}`;
56
38
  rmSync(instanceRoot, { recursive: true, force: true });
@@ -78,24 +60,15 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
78
60
  child.stderr?.on("data", (c) => logs.push(String(c)));
79
61
  const baseUrl = `http://127.0.0.1:${port}`;
80
62
  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("")}`);
63
+ await waitForApiServerReady({
64
+ child,
65
+ baseUrl,
66
+ logs,
67
+ timeoutMs,
68
+ timeoutMessage: `Server did not start in ${timeoutMs}ms`,
69
+ exitMessage: "Server exited early",
70
+ });
71
+ await run({ baseUrl, authHeader: "Bearer test-token" });
99
72
  }
100
73
  finally {
101
74
  await stopChild(child);
@@ -162,18 +135,18 @@ test("turn-sse: POST /api/sessions/:key/turn returns 404 when flag is off", asyn
162
135
  headers: { Authorization: authHeader, "Content-Type": "application/json" },
163
136
  body: JSON.stringify({ prompt: "hello" }),
164
137
  });
165
- assert.equal(res.status, 404, "Should return 404 when CHAPTERHOUSE_CHAT_SSE is not set");
138
+ assert.equal(res.status, 404, "Should return 404 when CHAPTERHOUSE_CHAT_SSE=0");
166
139
  const body = await res.json();
167
140
  assert.ok(typeof body.error === "string" && body.error.includes("Chat SSE"), `Expected error about Chat SSE, got: ${body.error}`);
168
- });
141
+ }, { CHAPTERHOUSE_CHAT_SSE: "0" });
169
142
  });
170
143
  test("turn-sse: GET /api/sessions/:key/stream returns 404 when flag is off", async () => {
171
144
  await withStartedServer(async ({ baseUrl, authHeader }) => {
172
145
  const res = await fetch(`${baseUrl}/api/sessions/default/stream`, {
173
146
  headers: { Authorization: authHeader },
174
147
  });
175
- assert.equal(res.status, 404, "Should return 404 when CHAPTERHOUSE_CHAT_SSE is not set");
176
- });
148
+ assert.equal(res.status, 404, "Should return 404 when CHAPTERHOUSE_CHAT_SSE=0");
149
+ }, { CHAPTERHOUSE_CHAT_SSE: "0" });
177
150
  });
178
151
  test("turn-sse: legacy POST /api/message is unaffected when flag is off", async () => {
179
152
  await withStartedServer(async ({ baseUrl, authHeader }) => {
@@ -186,7 +159,7 @@ test("turn-sse: legacy POST /api/message is unaffected when flag is off", async
186
159
  });
187
160
  // 400 = route exists and validated the request (connectionId not found in sseClients)
188
161
  assert.ok(res.status === 400 || res.status === 401 || res.status === 422, `Expected 4xx from /api/message, got ${res.status}`);
189
- });
162
+ }, { CHAPTERHOUSE_CHAT_SSE: "0" });
190
163
  });
191
164
  // ---------------------------------------------------------------------------
192
165
  // Tests: feature flag ON