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.
- package/dist/api/server-runtime.js +1 -1
- package/dist/api/server.js +53 -16
- package/dist/api/server.test.js +31 -56
- package/dist/api/sse.integration.test.js +4 -46
- package/dist/api/turn-sse.integration.test.js +20 -47
- package/dist/api/worker-events-sse.integration.test.js +241 -1
- package/dist/config.js +11 -1
- package/dist/config.test.js +14 -0
- package/dist/copilot/orchestrator.js +3 -1
- package/dist/copilot/orchestrator.test.js +1 -1
- package/dist/copilot/task-event-log.js +6 -1
- package/dist/copilot/task-event-log.test.js +45 -0
- package/dist/copilot/tools.js +39 -4
- package/dist/store/db.js +56 -16
- package/dist/store/db.test.js +67 -7
- package/dist/test/api-server.js +50 -0
- package/dist/test/api-server.test.js +57 -0
- package/dist/test/setup-env.js +9 -0
- package/dist/test/setup-env.test.js +34 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-BK-hInnO.js → index-BRPJa1DK.js} +83 -83
- package/web/dist/assets/{index-BK-hInnO.js.map → index-BRPJa1DK.js.map} +1 -1
- package/web/dist/assets/index-DhY5yWmC.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-D__tBB0X.css +0 -10
|
@@ -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 ??
|
|
23
|
+
const chatSseEnabled = config.chatSseEnabled ?? true;
|
|
24
24
|
if (!config.entraAuthEnabled) {
|
|
25
25
|
return {
|
|
26
26
|
appName: "Chapterhouse",
|
package/dist/api/server.js
CHANGED
|
@@ -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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
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"),
|
package/dist/api/server.test.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
45
|
+
chatSseEnabled: true,
|
|
46
46
|
}), {
|
|
47
47
|
appName: "Chapterhouse",
|
|
48
48
|
entraAuthEnabled: false,
|
|
49
49
|
standalone: false,
|
|
50
|
-
chatSseEnabled:
|
|
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-
|
|
142
|
-
// session state).
|
|
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).
|
|
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 =
|
|
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
|
-
|
|
178
|
-
|
|
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:
|
|
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:
|
|
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
|
-
},
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
15
|
-
*
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
|
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
|