chapterhouse 0.3.9 → 0.3.11
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 +3 -0
- package/dist/api/server.js +179 -1
- package/dist/api/server.test.js +6 -0
- package/dist/api/turn-sse.integration.test.js +352 -0
- package/dist/config.js +3 -0
- package/dist/copilot/orchestrator.js +226 -9
- package/dist/copilot/orchestrator.test.js +42 -0
- package/dist/copilot/ring-buffer.js +34 -0
- package/dist/copilot/ring-buffer.test.js +82 -0
- package/dist/copilot/session-manager.js +14 -1
- package/dist/copilot/task-event-log.js +3 -26
- package/dist/copilot/turn-event-log.js +298 -0
- package/dist/copilot/turn-event-log.test.js +326 -0
- package/dist/copilot/turn-events.js +11 -0
- package/dist/store/db.js +15 -0
- package/package.json +1 -1
- package/web/dist/assets/index-D92WYeM5.js +219 -0
- package/web/dist/assets/index-D92WYeM5.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-vL9s_H8H.js +0 -213
- package/web/dist/assets/index-vL9s_H8H.js.map +0 -1
|
@@ -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) {
|
package/dist/api/server.js
CHANGED
|
@@ -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 } 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";
|
|
@@ -22,6 +22,7 @@ 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
24
|
import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
|
|
25
|
+
import { subscribeSession, getSessionEventsFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
|
|
25
26
|
import { getStatus, onStatusChange } from "../status.js";
|
|
26
27
|
import { formatSseData, formatSseEvent } from "./sse.js";
|
|
27
28
|
import { syncDecisionsFileToWiki } from "../squad/mirror.js";
|
|
@@ -46,6 +47,15 @@ const messageRequestSchema = z.object({
|
|
|
46
47
|
* so the frontend can match the event to the user message bubble it should update. */
|
|
47
48
|
msgId: z.string().optional(),
|
|
48
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
|
+
});
|
|
49
59
|
const modelRequestSchema = z.object({
|
|
50
60
|
model: requiredString("Missing 'model' in request body"),
|
|
51
61
|
}).strict();
|
|
@@ -223,6 +233,7 @@ app.get("/api/config/public", (_req, res) => {
|
|
|
223
233
|
standaloneMode: config.standaloneMode,
|
|
224
234
|
entraClientId: config.entraClientId,
|
|
225
235
|
entraTenantId: config.entraTenantId,
|
|
236
|
+
chatSseEnabled: config.chatSseEnabled,
|
|
226
237
|
}));
|
|
227
238
|
});
|
|
228
239
|
// Health check — intentionally unauthenticated, returns no sensitive data
|
|
@@ -502,6 +513,173 @@ app.post("/api/cancel", async (_req, res) => {
|
|
|
502
513
|
}
|
|
503
514
|
res.json({ status: "ok", cancelled });
|
|
504
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
|
+
}
|
|
505
683
|
// ---------------------------------------------------------------------------
|
|
506
684
|
// Model & router
|
|
507
685
|
// ---------------------------------------------------------------------------
|
package/dist/api/server.test.js
CHANGED
|
@@ -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
|
package/dist/config.js
CHANGED
|
@@ -47,6 +47,7 @@ const configSchema = z.object({
|
|
|
47
47
|
API_RATE_LIMIT_SSE_MAX_CONNECTIONS: z.string().optional(),
|
|
48
48
|
ENABLE_SQUAD: z.string().optional(),
|
|
49
49
|
CHAPTERHOUSE_WORKIQ_AUTO_INSTALL: z.string().optional(),
|
|
50
|
+
CHAPTERHOUSE_CHAT_SSE: z.string().optional(),
|
|
50
51
|
});
|
|
51
52
|
export const DEFAULT_MODEL = "claude-sonnet-4.6";
|
|
52
53
|
export const DEFAULT_TEAM_WIKI_CACHE_TTL_MINUTES = 60;
|
|
@@ -221,6 +222,7 @@ export function parseRuntimeConfig(env, options = {}) {
|
|
|
221
222
|
apiRateLimitSseMaxConnections,
|
|
222
223
|
squadEnabled: raw.ENABLE_SQUAD === "1",
|
|
223
224
|
workiqAutoInstall: parseBooleanEnv("CHAPTERHOUSE_WORKIQ_AUTO_INSTALL", raw.CHAPTERHOUSE_WORKIQ_AUTO_INSTALL, true),
|
|
225
|
+
chatSseEnabled: raw.CHAPTERHOUSE_CHAT_SSE === "1",
|
|
224
226
|
};
|
|
225
227
|
}
|
|
226
228
|
const runtimeConfig = parseRuntimeConfig(process.env);
|
|
@@ -261,6 +263,7 @@ export const config = {
|
|
|
261
263
|
apiRateLimitSseMaxConnections: runtimeConfig.apiRateLimitSseMaxConnections,
|
|
262
264
|
squadEnabled: runtimeConfig.squadEnabled,
|
|
263
265
|
workiqAutoInstall: runtimeConfig.workiqAutoInstall,
|
|
266
|
+
chatSseEnabled: runtimeConfig.chatSseEnabled,
|
|
264
267
|
copilotAuthToken: runtimeConfig.copilotAuthToken,
|
|
265
268
|
get copilotModel() {
|
|
266
269
|
return _copilotModel;
|