chapterhouse 0.1.1

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 (119) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +363 -0
  3. package/agents/chapterhouse.agent.md +40 -0
  4. package/agents/coder.agent.md +38 -0
  5. package/agents/designer.agent.md +43 -0
  6. package/agents/general-purpose.agent.md +30 -0
  7. package/dist/api/auth.js +159 -0
  8. package/dist/api/auth.test.js +463 -0
  9. package/dist/api/errors.js +95 -0
  10. package/dist/api/errors.test.js +89 -0
  11. package/dist/api/rate-limit.js +85 -0
  12. package/dist/api/server-runtime.js +62 -0
  13. package/dist/api/server.js +651 -0
  14. package/dist/api/server.test.js +385 -0
  15. package/dist/api/sse.integration.test.js +270 -0
  16. package/dist/api/sse.js +7 -0
  17. package/dist/api/team.js +196 -0
  18. package/dist/api/team.test.js +466 -0
  19. package/dist/cli.js +102 -0
  20. package/dist/config.js +299 -0
  21. package/dist/config.phase3.test.js +20 -0
  22. package/dist/config.test.js +148 -0
  23. package/dist/copilot/agents.js +447 -0
  24. package/dist/copilot/agents.squad.test.js +72 -0
  25. package/dist/copilot/classifier.js +72 -0
  26. package/dist/copilot/client.js +32 -0
  27. package/dist/copilot/client.test.js +100 -0
  28. package/dist/copilot/episode-writer.js +219 -0
  29. package/dist/copilot/episode-writer.test.js +41 -0
  30. package/dist/copilot/mcp-config.js +22 -0
  31. package/dist/copilot/okr-mapper.js +196 -0
  32. package/dist/copilot/okr-mapper.test.js +114 -0
  33. package/dist/copilot/orchestrator.js +685 -0
  34. package/dist/copilot/orchestrator.test.js +523 -0
  35. package/dist/copilot/router.js +142 -0
  36. package/dist/copilot/router.test.js +119 -0
  37. package/dist/copilot/skills.js +125 -0
  38. package/dist/copilot/standup.js +138 -0
  39. package/dist/copilot/standup.test.js +132 -0
  40. package/dist/copilot/system-message.js +143 -0
  41. package/dist/copilot/system-message.test.js +17 -0
  42. package/dist/copilot/tools.js +1212 -0
  43. package/dist/copilot/tools.okr.test.js +260 -0
  44. package/dist/copilot/tools.squad.test.js +168 -0
  45. package/dist/daemon.js +235 -0
  46. package/dist/home-path.js +12 -0
  47. package/dist/home-path.test.js +11 -0
  48. package/dist/integrations/ado-analytics.js +178 -0
  49. package/dist/integrations/ado-analytics.test.js +284 -0
  50. package/dist/integrations/ado-client.js +227 -0
  51. package/dist/integrations/ado-client.test.js +176 -0
  52. package/dist/integrations/ado-schema.js +25 -0
  53. package/dist/integrations/ado-schema.test.js +39 -0
  54. package/dist/integrations/ado-skill.js +55 -0
  55. package/dist/integrations/report-generator.js +114 -0
  56. package/dist/integrations/report-generator.test.js +62 -0
  57. package/dist/integrations/team-push.js +144 -0
  58. package/dist/integrations/team-push.test.js +178 -0
  59. package/dist/integrations/teams-notify.js +108 -0
  60. package/dist/integrations/teams-notify.test.js +135 -0
  61. package/dist/paths.js +41 -0
  62. package/dist/setup.js +149 -0
  63. package/dist/shutdown-signals.js +13 -0
  64. package/dist/shutdown-signals.test.js +33 -0
  65. package/dist/squad/charter.js +108 -0
  66. package/dist/squad/charter.test.js +89 -0
  67. package/dist/squad/context.js +48 -0
  68. package/dist/squad/context.test.js +59 -0
  69. package/dist/squad/discovery.js +280 -0
  70. package/dist/squad/discovery.test.js +93 -0
  71. package/dist/squad/index.js +7 -0
  72. package/dist/squad/mirror.js +81 -0
  73. package/dist/squad/mirror.scheduler.js +78 -0
  74. package/dist/squad/mirror.scheduler.test.js +197 -0
  75. package/dist/squad/mirror.test.js +172 -0
  76. package/dist/squad/registry.js +162 -0
  77. package/dist/squad/registry.test.js +31 -0
  78. package/dist/squad/squad-coordinator-system-message.test.js +190 -0
  79. package/dist/squad/squad-session-routing.test.js +260 -0
  80. package/dist/squad/types.js +4 -0
  81. package/dist/status.js +25 -0
  82. package/dist/status.test.js +22 -0
  83. package/dist/store/db.js +290 -0
  84. package/dist/store/db.test.js +126 -0
  85. package/dist/store/squad-sessions.test.js +341 -0
  86. package/dist/test/setup-env.js +3 -0
  87. package/dist/update.js +112 -0
  88. package/dist/update.test.js +25 -0
  89. package/dist/wiki/context.js +138 -0
  90. package/dist/wiki/fs.js +195 -0
  91. package/dist/wiki/fs.test.js +39 -0
  92. package/dist/wiki/index-manager.js +359 -0
  93. package/dist/wiki/index-manager.test.js +129 -0
  94. package/dist/wiki/lock.js +26 -0
  95. package/dist/wiki/lock.test.js +30 -0
  96. package/dist/wiki/log-manager.js +20 -0
  97. package/dist/wiki/migrate.js +306 -0
  98. package/dist/wiki/okr.test.js +101 -0
  99. package/dist/wiki/path-utils.js +4 -0
  100. package/dist/wiki/path-utils.test.js +8 -0
  101. package/dist/wiki/seed-team-wiki.js +296 -0
  102. package/dist/wiki/seed-team-wiki.test.js +69 -0
  103. package/dist/wiki/team-sync.js +212 -0
  104. package/dist/wiki/team-sync.test.js +185 -0
  105. package/dist/wiki/templates/okr.js +98 -0
  106. package/package.json +72 -0
  107. package/skills/.gitkeep +0 -0
  108. package/skills/find-skills/SKILL.md +161 -0
  109. package/skills/find-skills/_meta.json +4 -0
  110. package/skills/frontend-design/LICENSE.txt +177 -0
  111. package/skills/frontend-design/SKILL.md +42 -0
  112. package/skills/squad/SKILL.md +76 -0
  113. package/web/dist/assets/index-D-e7K-fT.css +10 -0
  114. package/web/dist/assets/index-DAg9IrpO.js +142 -0
  115. package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
  116. package/web/dist/chapterhouse-icon.png +0 -0
  117. package/web/dist/chapterhouse-icon.svg +42 -0
  118. package/web/dist/chapterhouse-logo.svg +46 -0
  119. package/web/dist/index.html +15 -0
@@ -0,0 +1,385 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { mkdirSync, rmSync } from "node:fs";
4
+ import { createServer } from "node:http";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ test("supports API_TOKEN env var and personal health route helpers", async () => {
8
+ const runtime = await import("./server-runtime.js");
9
+ assert.equal(typeof runtime.resolveApiToken, "function", "resolveApiToken should be exported");
10
+ assert.equal(typeof runtime.shouldServeSpaPath, "function", "shouldServeSpaPath should be exported");
11
+ assert.equal(typeof runtime.createHealthPayload, "function", "createHealthPayload should be exported");
12
+ assert.equal(typeof runtime.createPublicConfigPayload, "function", "createPublicConfigPayload should be exported");
13
+ assert.equal(typeof runtime.getDisplayHost, "function", "getDisplayHost should be exported");
14
+ assert.equal(typeof runtime.assertAuthenticationConfigured, "function", "assertAuthenticationConfigured should be exported");
15
+ const token = runtime.resolveApiToken({
16
+ envToken: "personal-token",
17
+ tokenPath: "/tmp/chapterhouse-api-token",
18
+ });
19
+ assert.equal(token, "personal-token");
20
+ assert.equal(runtime.shouldServeSpaPath("/health"), false);
21
+ assert.equal(runtime.getDisplayHost("0.0.0.0"), "localhost");
22
+ assert.deepEqual(runtime.createHealthPayload(new Date("2026-05-06T00:00:00.000Z")), {
23
+ status: "ok",
24
+ timestamp: "2026-05-06T00:00:00.000Z",
25
+ });
26
+ assert.deepEqual(runtime.createPublicConfigPayload({
27
+ entraAuthEnabled: true,
28
+ standaloneMode: false,
29
+ entraClientId: "client-id",
30
+ entraTenantId: "tenant-id",
31
+ }), {
32
+ appName: "Chapterhouse",
33
+ entraAuthEnabled: true,
34
+ entraClientId: "client-id",
35
+ entraTenantId: "tenant-id",
36
+ });
37
+ assert.deepEqual(runtime.createPublicConfigPayload({
38
+ entraAuthEnabled: false,
39
+ standaloneMode: false,
40
+ entraClientId: "client-id",
41
+ entraTenantId: "tenant-id",
42
+ }), {
43
+ appName: "Chapterhouse",
44
+ entraAuthEnabled: false,
45
+ standalone: false,
46
+ });
47
+ assert.doesNotThrow(() => runtime.assertAuthenticationConfigured({
48
+ entraAuthEnabled: false,
49
+ apiToken: null,
50
+ }));
51
+ assert.doesNotThrow(() => runtime.assertAuthenticationConfigured({
52
+ entraAuthEnabled: false,
53
+ apiToken: "personal-token",
54
+ }));
55
+ assert.doesNotThrow(() => runtime.assertAuthenticationConfigured({
56
+ entraAuthEnabled: true,
57
+ apiToken: null,
58
+ }));
59
+ });
60
+ test("formats named SSE status events", async () => {
61
+ const sse = await import("./sse.js");
62
+ assert.equal(typeof sse.formatSseEvent, "function", "formatSseEvent should be exported");
63
+ assert.equal(sse.formatSseEvent("status", { status: "dreaming", message: "Consolidating memories..." }), 'event: status\ndata: {"status":"dreaming","message":"Consolidating memories..."}\n\n');
64
+ });
65
+ test("buildHistoryEntries resolves wiki files through the shared path resolver", async () => {
66
+ const runtime = await import("./server-runtime.js");
67
+ assert.equal(typeof runtime.buildHistoryEntries, "function", "buildHistoryEntries should be exported");
68
+ const seen = [];
69
+ const entries = runtime.buildHistoryEntries([
70
+ "pages/conversations/older.md",
71
+ "pages/conversations/newer.md",
72
+ ], {
73
+ resolveWikiPath: (pageId) => {
74
+ seen.push(pageId);
75
+ return `/wiki/${pageId}`;
76
+ },
77
+ stat: (fullPath) => ({
78
+ mtimeMs: fullPath.endsWith("newer.md") ? 200 : 100,
79
+ }),
80
+ });
81
+ assert.deepEqual(seen, [
82
+ "pages/conversations/older.md",
83
+ "pages/conversations/newer.md",
84
+ ]);
85
+ assert.deepEqual(entries, [
86
+ { path: "pages/conversations/newer.md", mtime: 200 },
87
+ { path: "pages/conversations/older.md", mtime: 100 },
88
+ ]);
89
+ });
90
+ const repoRoot = process.cwd();
91
+ const serverTestRoot = join(repoRoot, ".test-work", `server-routes-${process.pid}`);
92
+ async function getFreePort() {
93
+ const server = createServer();
94
+ await new Promise((resolve) => {
95
+ server.listen(0, "127.0.0.1", () => resolve());
96
+ });
97
+ const address = server.address();
98
+ assert.ok(address && typeof address === "object", "server should expose a bound address");
99
+ await new Promise((resolve, reject) => {
100
+ server.close((err) => (err ? reject(err) : resolve()));
101
+ });
102
+ return address.port;
103
+ }
104
+ async function stopChild(child) {
105
+ if (child.exitCode !== null) {
106
+ return;
107
+ }
108
+ child.kill("SIGTERM");
109
+ await new Promise((resolve) => {
110
+ child.once("exit", () => resolve());
111
+ setTimeout(() => {
112
+ if (child.exitCode === null) {
113
+ child.kill("SIGKILL");
114
+ }
115
+ }, 2_000);
116
+ });
117
+ }
118
+ // Standalone mode triggers a full daemon init via the server→daemon circular import
119
+ // (server.ts imports restartDaemon from daemon.ts, which has module-level main()).
120
+ // The Copilot SDK's client.start() then blocks the event loop while authenticating,
121
+ // so the HTTP server cannot respond until SDK init completes (~10-20 s depending on
122
+ // session state). Other modes complete in ~330 ms because the SDK yields quickly.
123
+ // Root cause tracked in .squad/decisions/ for Kaylee (backend fix: lazy-import or
124
+ // guard env var in daemon.ts). Interim fix: 30 s timeout for standalone, 10 s elsewhere.
125
+ // Additionally, strip COPILOT_* env vars from the child so it doesn't accidentally
126
+ // piggy-back on the running agent session, which worsens the blocking behaviour.
127
+ async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
128
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
129
+ rmSync(serverTestRoot, { recursive: true, force: true });
130
+ mkdirSync(serverTestRoot, { recursive: true });
131
+ const port = await getFreePort();
132
+ const logs = [];
133
+ const child = spawn(process.execPath, [
134
+ "--input-type=module",
135
+ "-e",
136
+ "import { startApiServer } from './dist/api/server.js'; await startApiServer();",
137
+ ], {
138
+ cwd: repoRoot,
139
+ env: {
140
+ // Strip COPILOT_* vars so the child process doesn't inherit the running
141
+ // agent's session credentials, which causes the SDK to hang in standalone mode.
142
+ ...Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("COPILOT_"))),
143
+ CHAPTERHOUSE_DISABLE_DOTENV: "1",
144
+ CHAPTERHOUSE_HOME: serverTestRoot,
145
+ API_HOST: "127.0.0.1",
146
+ API_PORT: String(port),
147
+ API_TOKEN: "route-token",
148
+ ...extraEnv,
149
+ },
150
+ stdio: ["ignore", "pipe", "pipe"],
151
+ });
152
+ child.stdout?.on("data", (chunk) => logs.push(String(chunk)));
153
+ child.stderr?.on("data", (chunk) => logs.push(String(chunk)));
154
+ const baseUrl = `http://127.0.0.1:${port}`;
155
+ try {
156
+ const deadline = Date.now() + timeoutMs;
157
+ while (Date.now() < deadline) {
158
+ if (child.exitCode !== null) {
159
+ throw new Error(`API server exited early:\n${logs.join("")}`);
160
+ }
161
+ try {
162
+ const response = await fetch(`${baseUrl}/status`);
163
+ if (response.ok) {
164
+ await run({ baseUrl, authHeader: "Bearer route-token" });
165
+ return;
166
+ }
167
+ }
168
+ catch {
169
+ // Server still starting.
170
+ }
171
+ await new Promise((resolve) => setTimeout(resolve, 100));
172
+ }
173
+ throw new Error(`Timed out waiting for API server to start:\n${logs.join("")}`);
174
+ }
175
+ finally {
176
+ await stopChild(child);
177
+ rmSync(serverTestRoot, { recursive: true, force: true });
178
+ }
179
+ }
180
+ test("server routes expose bootstrap and public config without auth", async () => {
181
+ await withStartedServer(async ({ baseUrl }) => {
182
+ const bootstrap = await fetch(`${baseUrl}/api/bootstrap`);
183
+ assert.equal(bootstrap.status, 200);
184
+ assert.deepEqual(await bootstrap.json(), { authMode: "legacy", token: "route-token" });
185
+ const publicConfig = await fetch(`${baseUrl}/api/config/public`);
186
+ assert.equal(publicConfig.status, 200);
187
+ assert.deepEqual(await publicConfig.json(), {
188
+ appName: "Chapterhouse",
189
+ entraAuthEnabled: false,
190
+ standalone: false,
191
+ });
192
+ });
193
+ });
194
+ test("server runs in standalone mode without auth", async () => {
195
+ await withStartedServer(async ({ baseUrl }) => {
196
+ const bootstrap = await fetch(`${baseUrl}/api/bootstrap`);
197
+ assert.equal(bootstrap.status, 200);
198
+ assert.deepEqual(await bootstrap.json(), { authMode: "standalone" });
199
+ const publicConfig = await fetch(`${baseUrl}/api/config/public`);
200
+ assert.equal(publicConfig.status, 200);
201
+ assert.deepEqual(await publicConfig.json(), {
202
+ appName: "Chapterhouse",
203
+ entraAuthEnabled: false,
204
+ standalone: true,
205
+ });
206
+ const model = await fetch(`${baseUrl}/api/model`);
207
+ assert.equal(model.status, 200);
208
+ assert.deepEqual(await model.json(), { model: "claude-sonnet-4.6" });
209
+ }, {
210
+ API_TOKEN: "",
211
+ }, 30_000);
212
+ });
213
+ test("server bootstrap rejects non-loopback origins", async () => {
214
+ await withStartedServer(async ({ baseUrl }) => {
215
+ const response = await fetch(`${baseUrl}/api/bootstrap`, {
216
+ headers: { origin: "https://evil.example" },
217
+ });
218
+ assert.equal(response.status, 403);
219
+ assert.deepEqual(await response.json(), { error: "Bootstrap is loopback-only" });
220
+ });
221
+ });
222
+ test("server applies security headers and allows loopback CORS during development", async () => {
223
+ await withStartedServer(async ({ baseUrl }) => {
224
+ const response = await fetch(`${baseUrl}/api/config/public`, {
225
+ headers: { origin: "http://localhost:5173" },
226
+ });
227
+ assert.equal(response.status, 200);
228
+ assert.equal(response.headers.get("access-control-allow-origin"), "http://localhost:5173");
229
+ assert.equal(response.headers.get("x-content-type-options"), "nosniff");
230
+ assert.equal(response.headers.get("x-dns-prefetch-control"), "off");
231
+ });
232
+ });
233
+ test("server only enables production CORS for explicit origins", async () => {
234
+ await withStartedServer(async ({ baseUrl }) => {
235
+ const allowed = await fetch(`${baseUrl}/api/config/public`, {
236
+ headers: { origin: "https://app.example.com" },
237
+ });
238
+ assert.equal(allowed.status, 200);
239
+ assert.equal(allowed.headers.get("access-control-allow-origin"), "https://app.example.com");
240
+ const blocked = await fetch(`${baseUrl}/api/config/public`, {
241
+ headers: { origin: "https://evil.example" },
242
+ });
243
+ assert.equal(blocked.status, 200);
244
+ assert.equal(blocked.headers.get("access-control-allow-origin"), null);
245
+ }, {
246
+ NODE_ENV: "production",
247
+ CORS_ALLOWED_ORIGINS: "https://app.example.com",
248
+ });
249
+ });
250
+ test("server wiki routes support authenticated CRUD", async () => {
251
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
252
+ const createResponse = await fetch(`${baseUrl}/api/wiki/page?path=pages/shared/runbooks/deploy.md`, {
253
+ method: "PUT",
254
+ headers: {
255
+ authorization: authHeader,
256
+ "content-type": "application/json",
257
+ },
258
+ body: JSON.stringify({ content: "# Deploy\n" }),
259
+ });
260
+ assert.equal(createResponse.status, 200);
261
+ assert.deepEqual(await createResponse.json(), {
262
+ ok: true,
263
+ created: true,
264
+ path: "pages/shared/runbooks/deploy.md",
265
+ });
266
+ const getResponse = await fetch(`${baseUrl}/api/wiki/page?path=pages/shared/runbooks/deploy.md`, {
267
+ headers: { authorization: authHeader },
268
+ });
269
+ assert.equal(getResponse.status, 200);
270
+ assert.deepEqual(await getResponse.json(), {
271
+ path: "pages/shared/runbooks/deploy.md",
272
+ content: "# Deploy\n",
273
+ });
274
+ const deleteResponse = await fetch(`${baseUrl}/api/wiki/page?path=pages/shared/runbooks/deploy.md`, {
275
+ method: "DELETE",
276
+ headers: { authorization: authHeader },
277
+ });
278
+ assert.equal(deleteResponse.status, 200);
279
+ assert.deepEqual(await deleteResponse.json(), {
280
+ ok: true,
281
+ path: "pages/shared/runbooks/deploy.md",
282
+ });
283
+ const missingResponse = await fetch(`${baseUrl}/api/wiki/page?path=pages/shared/runbooks/deploy.md`, {
284
+ headers: { authorization: authHeader },
285
+ });
286
+ assert.equal(missingResponse.status, 404);
287
+ assert.deepEqual(await missingResponse.json(), { error: "Page not found" });
288
+ });
289
+ });
290
+ test("server message route validates the SSE connection id", async () => {
291
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
292
+ const response = await fetch(`${baseUrl}/api/message`, {
293
+ method: "POST",
294
+ headers: {
295
+ authorization: authHeader,
296
+ "content-type": "application/json",
297
+ },
298
+ body: JSON.stringify({
299
+ prompt: "Hello there",
300
+ connectionId: "missing-connection",
301
+ }),
302
+ });
303
+ assert.equal(response.status, 400);
304
+ assert.deepEqual(await response.json(), {
305
+ error: "Missing or invalid 'connectionId'. Connect to /stream first.",
306
+ });
307
+ });
308
+ });
309
+ test("server rate limits authenticated API routes", async () => {
310
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
311
+ const first = await fetch(`${baseUrl}/api/model`, {
312
+ headers: { authorization: authHeader },
313
+ });
314
+ const second = await fetch(`${baseUrl}/api/model`, {
315
+ headers: { authorization: authHeader },
316
+ });
317
+ const third = await fetch(`${baseUrl}/api/model`, {
318
+ headers: { authorization: authHeader },
319
+ });
320
+ assert.equal(first.status, 200);
321
+ assert.equal(second.status, 200);
322
+ assert.equal(third.status, 429);
323
+ assert.equal(third.headers.get("retry-after"), "60");
324
+ assert.deepEqual(await third.json(), {
325
+ error: "Too many API requests. Retry after 60 seconds.",
326
+ });
327
+ }, {
328
+ API_RATE_LIMIT_GENERAL_MAX: "2",
329
+ });
330
+ });
331
+ test("server rate limits bootstrap auth requests", async () => {
332
+ await withStartedServer(async ({ baseUrl }) => {
333
+ const first = await fetch(`${baseUrl}/api/bootstrap`);
334
+ const second = await fetch(`${baseUrl}/api/bootstrap`);
335
+ const third = await fetch(`${baseUrl}/api/bootstrap`);
336
+ assert.equal(first.status, 200);
337
+ assert.equal(second.status, 200);
338
+ assert.equal(third.status, 429);
339
+ assert.equal(third.headers.get("retry-after"), "60");
340
+ assert.deepEqual(await third.json(), {
341
+ error: "Too many authentication attempts. Retry after 60 seconds.",
342
+ });
343
+ }, {
344
+ API_RATE_LIMIT_AUTH_MAX: "2",
345
+ });
346
+ });
347
+ test("server caps concurrent SSE connections per IP", async () => {
348
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
349
+ const firstController = new AbortController();
350
+ const secondController = new AbortController();
351
+ let firstResponse;
352
+ let secondResponse;
353
+ try {
354
+ firstResponse = await fetch(`${baseUrl}/stream`, {
355
+ headers: { authorization: authHeader },
356
+ signal: firstController.signal,
357
+ });
358
+ secondResponse = await fetch(`${baseUrl}/stream`, {
359
+ headers: { authorization: authHeader },
360
+ signal: secondController.signal,
361
+ });
362
+ assert.equal(firstResponse.status, 200);
363
+ assert.equal(secondResponse.status, 200);
364
+ const rejected = await fetch(`${baseUrl}/stream`, {
365
+ headers: { authorization: authHeader },
366
+ });
367
+ assert.equal(rejected.status, 429);
368
+ assert.equal(rejected.headers.get("retry-after"), "60");
369
+ assert.deepEqual(await rejected.json(), {
370
+ error: "Too many concurrent stream connections. Retry after 60 seconds.",
371
+ });
372
+ }
373
+ finally {
374
+ firstController.abort();
375
+ secondController.abort();
376
+ await Promise.allSettled([
377
+ firstResponse?.body?.cancel() ?? Promise.resolve(),
378
+ secondResponse?.body?.cancel() ?? Promise.resolve(),
379
+ ]);
380
+ }
381
+ }, {
382
+ API_RATE_LIMIT_SSE_MAX_CONNECTIONS: "2",
383
+ });
384
+ });
385
+ //# sourceMappingURL=server.test.js.map
@@ -0,0 +1,270 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { mkdirSync, rmSync } from "node:fs";
4
+ import { createServer } from "node:http";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ // ---------------------------------------------------------------------------
8
+ // Server harness (copied from server.test.ts; distinct root path suffix to
9
+ // avoid collision when both test files run in parallel in the same process).
10
+ // ---------------------------------------------------------------------------
11
+ const repoRoot = process.cwd();
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) {
40
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
41
+ rmSync(sseTestRoot, { recursive: true, force: true });
42
+ mkdirSync(sseTestRoot, { recursive: true });
43
+ const port = await getFreePort();
44
+ const logs = [];
45
+ const child = spawn(process.execPath, [
46
+ "--input-type=module",
47
+ "-e",
48
+ "import { startApiServer } from './dist/api/server.js'; await startApiServer();",
49
+ ], {
50
+ cwd: repoRoot,
51
+ env: {
52
+ // Strip COPILOT_* vars so the child does not inherit the running
53
+ // agent session credentials, which causes the SDK to hang in standalone mode.
54
+ ...Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("COPILOT_"))),
55
+ CHAPTERHOUSE_DISABLE_DOTENV: "1",
56
+ CHAPTERHOUSE_HOME: sseTestRoot,
57
+ API_HOST: "127.0.0.1",
58
+ API_PORT: String(port),
59
+ API_TOKEN: "route-token",
60
+ ...extraEnv,
61
+ },
62
+ stdio: ["ignore", "pipe", "pipe"],
63
+ });
64
+ child.stdout?.on("data", (chunk) => logs.push(String(chunk)));
65
+ child.stderr?.on("data", (chunk) => logs.push(String(chunk)));
66
+ const baseUrl = `http://127.0.0.1:${port}`;
67
+ 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("")}`);
86
+ }
87
+ finally {
88
+ await stopChild(child);
89
+ rmSync(sseTestRoot, { recursive: true, force: true });
90
+ }
91
+ }
92
+ /**
93
+ * Wraps a `ReadableStreamDefaultReader<Uint8Array>` and provides a
94
+ * `next(count, timeoutMs)` method that accumulates bytes across calls,
95
+ * parses complete SSE event blocks, and returns the requested number of events.
96
+ *
97
+ * Comment lines (`:ping`) are silently skipped so heartbeats do not
98
+ * interfere with ordered event assertions.
99
+ */
100
+ function createSseStreamReader(body) {
101
+ const reader = body.getReader();
102
+ const decoder = new TextDecoder();
103
+ let buffer = "";
104
+ /**
105
+ * Parse all complete SSE event blocks currently in `buffer`.
106
+ * An event block is terminated by `\n\n`. Updates `buffer` in-place
107
+ * and returns the parsed events (skipping comment-only blocks).
108
+ */
109
+ function drainEvents() {
110
+ const events = [];
111
+ while (true) {
112
+ const blockEnd = buffer.indexOf("\n\n");
113
+ if (blockEnd === -1) {
114
+ break;
115
+ }
116
+ const block = buffer.slice(0, blockEnd);
117
+ buffer = buffer.slice(blockEnd + 2);
118
+ let eventName;
119
+ let dataLine;
120
+ for (const line of block.split("\n")) {
121
+ if (line.startsWith(":")) {
122
+ // SSE comment — skip (heartbeat pings arrive as `:ping`)
123
+ continue;
124
+ }
125
+ if (line.startsWith("event:")) {
126
+ eventName = line.slice("event:".length).trim();
127
+ }
128
+ else if (line.startsWith("data:")) {
129
+ dataLine = line.slice("data:".length).trim();
130
+ }
131
+ }
132
+ // Skip blocks that contained only comments (no data line).
133
+ if (dataLine === undefined) {
134
+ continue;
135
+ }
136
+ let parsed;
137
+ try {
138
+ parsed = JSON.parse(dataLine);
139
+ }
140
+ catch {
141
+ parsed = dataLine;
142
+ }
143
+ events.push({ eventName, data: parsed, raw: dataLine });
144
+ }
145
+ return events;
146
+ }
147
+ /**
148
+ * Read until `count` SSE events have been accumulated or `timeoutMs`
149
+ * elapses (default 5 000 ms). Returns the collected events in order.
150
+ */
151
+ async function next(count = 1, timeoutMs = 5_000) {
152
+ const collected = [];
153
+ const deadline = Date.now() + timeoutMs;
154
+ // Drain whatever is already buffered before issuing new reads.
155
+ collected.push(...drainEvents());
156
+ while (collected.length < count) {
157
+ if (Date.now() >= deadline) {
158
+ throw new Error(`SSE reader timed out after ${timeoutMs} ms waiting for ${count} event(s); ` +
159
+ `received ${collected.length} so far.`);
160
+ }
161
+ const remaining = deadline - Date.now();
162
+ const readResult = await Promise.race([
163
+ reader.read(),
164
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`SSE read timed out after ${timeoutMs} ms`)), remaining)),
165
+ ]);
166
+ if (readResult.done) {
167
+ break;
168
+ }
169
+ buffer += decoder.decode(readResult.value, { stream: true });
170
+ collected.push(...drainEvents());
171
+ }
172
+ return collected.slice(0, count);
173
+ }
174
+ async function cancel() {
175
+ await reader.cancel();
176
+ }
177
+ return { next, cancel };
178
+ }
179
+ // ---------------------------------------------------------------------------
180
+ // Tests
181
+ // ---------------------------------------------------------------------------
182
+ test("SSE wire: /stream returns text/event-stream and a connected event with connectionId", async () => {
183
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
184
+ const res = await fetch(`${baseUrl}/stream`, {
185
+ headers: { Authorization: authHeader },
186
+ });
187
+ assert.equal(res.status, 200, "HTTP status should be 200");
188
+ assert.ok(res.headers.get("content-type")?.includes("text/event-stream"), `Content-Type should include text/event-stream, got: ${res.headers.get("content-type")}`);
189
+ assert.ok(res.body, "Response body should be a readable stream");
190
+ const reader = createSseStreamReader(res.body);
191
+ try {
192
+ const events = await reader.next(1);
193
+ assert.equal(events.length, 1, "Should receive exactly 1 event");
194
+ const data = events[0]?.data;
195
+ assert.equal(data.type, "connected", `type should be "connected", got: ${data.type}`);
196
+ assert.ok(typeof data.connectionId === "string" && data.connectionId.startsWith("web-"), `connectionId should be a string starting with "web-", got: ${data.connectionId}`);
197
+ }
198
+ finally {
199
+ await reader.cancel();
200
+ }
201
+ });
202
+ });
203
+ test("SSE wire: /api/cancel broadcasts a cancelled event with sessionKey to connected clients", async () => {
204
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
205
+ const res = await fetch(`${baseUrl}/stream`, {
206
+ headers: { Authorization: authHeader },
207
+ });
208
+ assert.ok(res.body, "Response body should be a readable stream");
209
+ const reader = createSseStreamReader(res.body);
210
+ try {
211
+ // Drain the initial connected event.
212
+ const connected = await reader.next(1);
213
+ assert.equal(connected[0]?.data?.type, "connected", "First event should be connected");
214
+ // POST cancel — broadcasts a cancelled event to all SSE clients.
215
+ const cancelRes = await fetch(`${baseUrl}/api/cancel`, {
216
+ method: "POST",
217
+ headers: { Authorization: authHeader },
218
+ });
219
+ assert.equal(cancelRes.status, 200, "Cancel should return 200");
220
+ // Read the next event off the same open connection.
221
+ const events = await reader.next(1);
222
+ assert.equal(events.length, 1, "Should receive 1 event after cancel");
223
+ const data = events[0]?.data;
224
+ assert.equal(data.type, "cancelled", `type should be "cancelled", got: ${data.type}`);
225
+ assert.ok(typeof data.sessionKey === "string" && data.sessionKey.length > 0, `sessionKey should be a non-empty string, got: ${JSON.stringify(data.sessionKey)}`);
226
+ }
227
+ finally {
228
+ await reader.cancel();
229
+ }
230
+ });
231
+ });
232
+ test("SSE wire: cancel event is broadcast to all concurrent subscribers with matching sessionKey", async () => {
233
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
234
+ // Open two simultaneous SSE connections.
235
+ const [res1, res2] = await Promise.all([
236
+ fetch(`${baseUrl}/stream`, { headers: { Authorization: authHeader } }),
237
+ fetch(`${baseUrl}/stream`, { headers: { Authorization: authHeader } }),
238
+ ]);
239
+ assert.ok(res1.body, "Connection 1 should have a readable body");
240
+ assert.ok(res2.body, "Connection 2 should have a readable body");
241
+ const reader1 = createSseStreamReader(res1.body);
242
+ const reader2 = createSseStreamReader(res2.body);
243
+ try {
244
+ // Drain the connected events from both connections.
245
+ const [conn1, conn2] = await Promise.all([reader1.next(1), reader2.next(1)]);
246
+ assert.equal(conn1[0]?.data?.type, "connected", "Connection 1 first event should be connected");
247
+ assert.equal(conn2[0]?.data?.type, "connected", "Connection 2 first event should be connected");
248
+ // POST cancel — should broadcast to both subscribers.
249
+ const cancelRes = await fetch(`${baseUrl}/api/cancel`, {
250
+ method: "POST",
251
+ headers: { Authorization: authHeader },
252
+ });
253
+ assert.equal(cancelRes.status, 200, "Cancel should return 200");
254
+ // Both readers should receive the cancelled event.
255
+ const [events1, events2] = await Promise.all([reader1.next(1), reader2.next(1)]);
256
+ const data1 = events1[0]?.data;
257
+ const data2 = events2[0]?.data;
258
+ assert.equal(data1.type, "cancelled", `Connection 1 type should be "cancelled"`);
259
+ assert.equal(data2.type, "cancelled", `Connection 2 type should be "cancelled"`);
260
+ assert.ok(typeof data1.sessionKey === "string" && data1.sessionKey.length > 0, `Connection 1 sessionKey should be a non-empty string, got: ${JSON.stringify(data1.sessionKey)}`);
261
+ assert.ok(typeof data2.sessionKey === "string" && data2.sessionKey.length > 0, `Connection 2 sessionKey should be a non-empty string, got: ${JSON.stringify(data2.sessionKey)}`);
262
+ assert.equal(data1.sessionKey, data2.sessionKey, `Both connections must receive the same sessionKey; ` +
263
+ `got "${data1.sessionKey}" vs "${data2.sessionKey}"`);
264
+ }
265
+ finally {
266
+ await Promise.all([reader1.cancel(), reader2.cancel()]);
267
+ }
268
+ });
269
+ });
270
+ //# sourceMappingURL=sse.integration.test.js.map
@@ -0,0 +1,7 @@
1
+ export function formatSseData(payload) {
2
+ return `data: ${JSON.stringify(payload)}\n\n`;
3
+ }
4
+ export function formatSseEvent(eventName, payload) {
5
+ return `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
6
+ }
7
+ //# sourceMappingURL=sse.js.map