chapterhouse 0.3.25 → 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 +1 -1
- 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/config.js +11 -1
- package/dist/config.test.js +14 -0
- package/dist/copilot/orchestrator.js +1 -1
- package/dist/copilot/orchestrator.test.js +1 -1
- package/dist/store/db.js +11 -9
- package/dist/store/db.test.js +11 -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
|
@@ -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
|
@@ -668,7 +668,7 @@ app.post("/api/sessions/:sessionKey/interrupt", (req, res) => {
|
|
|
668
668
|
res.json({ status: "interrupting" });
|
|
669
669
|
});
|
|
670
670
|
// ---------------------------------------------------------------------------
|
|
671
|
-
// Chat POST→SSE endpoints —
|
|
671
|
+
// Chat POST→SSE endpoints — enabled by default, overridable via CHAPTERHOUSE_CHAT_SSE (#130)
|
|
672
672
|
// ---------------------------------------------------------------------------
|
|
673
673
|
const turnRequestSchema = z.object({
|
|
674
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
|
package/dist/config.js
CHANGED
|
@@ -60,6 +60,16 @@ export const DEFAULT_API_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
|
60
60
|
export const DEFAULT_API_RATE_LIMIT_GENERAL_MAX = 100;
|
|
61
61
|
export const DEFAULT_API_RATE_LIMIT_AUTH_MAX = 10;
|
|
62
62
|
export const DEFAULT_API_RATE_LIMIT_SSE_MAX_CONNECTIONS = 5;
|
|
63
|
+
function parseZeroOneEnv(name, rawValue, defaultValue) {
|
|
64
|
+
const normalized = rawValue?.trim();
|
|
65
|
+
if (!normalized) {
|
|
66
|
+
return defaultValue;
|
|
67
|
+
}
|
|
68
|
+
if (normalized !== "0" && normalized !== "1") {
|
|
69
|
+
throw new Error(`${name} must be '0' or '1', got: "${rawValue}"`);
|
|
70
|
+
}
|
|
71
|
+
return normalized === "1";
|
|
72
|
+
}
|
|
63
73
|
function parseBooleanEnv(name, rawValue, defaultValue) {
|
|
64
74
|
const normalized = rawValue?.trim();
|
|
65
75
|
if (!normalized) {
|
|
@@ -230,7 +240,7 @@ export function parseRuntimeConfig(env, options = {}) {
|
|
|
230
240
|
apiRateLimitAuthMax,
|
|
231
241
|
apiRateLimitSseMaxConnections,
|
|
232
242
|
workiqAutoInstall: parseBooleanEnv("CHAPTERHOUSE_WORKIQ_AUTO_INSTALL", raw.CHAPTERHOUSE_WORKIQ_AUTO_INSTALL, true),
|
|
233
|
-
chatSseEnabled: raw.CHAPTERHOUSE_CHAT_SSE
|
|
243
|
+
chatSseEnabled: parseZeroOneEnv("CHAPTERHOUSE_CHAT_SSE", raw.CHAPTERHOUSE_CHAT_SSE, true),
|
|
234
244
|
};
|
|
235
245
|
}
|
|
236
246
|
const runtimeConfig = parseRuntimeConfig(process.env);
|
package/dist/config.test.js
CHANGED
|
@@ -95,6 +95,20 @@ test("defaults TEAM_WIKI_PATHS to include the shared namespace", async () => {
|
|
|
95
95
|
const parsed = configModule.parseRuntimeConfig({});
|
|
96
96
|
assert.deepEqual(parsed.teamWikiPaths, ["pages/team", "pages/okrs", "pages/kpis", "pages/shared"]);
|
|
97
97
|
});
|
|
98
|
+
test("defaults chat SSE on and still honors explicit CHAPTERHOUSE_CHAT_SSE overrides", async () => {
|
|
99
|
+
const configModule = await import("./config.js");
|
|
100
|
+
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
101
|
+
const parsedDefault = configModule.parseRuntimeConfig({});
|
|
102
|
+
const parsedDisabled = configModule.parseRuntimeConfig({
|
|
103
|
+
CHAPTERHOUSE_CHAT_SSE: "0",
|
|
104
|
+
});
|
|
105
|
+
const parsedEnabled = configModule.parseRuntimeConfig({
|
|
106
|
+
CHAPTERHOUSE_CHAT_SSE: "1",
|
|
107
|
+
});
|
|
108
|
+
assert.equal(parsedDefault.chatSseEnabled, true);
|
|
109
|
+
assert.equal(parsedDisabled.chatSseEnabled, false);
|
|
110
|
+
assert.equal(parsedEnabled.chatSseEnabled, true);
|
|
111
|
+
});
|
|
98
112
|
test("prefers COPILOT_TOKEN over GITHUB_TOKEN for Copilot SDK auth", async () => {
|
|
99
113
|
const configModule = await import("./config.js");
|
|
100
114
|
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
@@ -843,7 +843,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
843
843
|
const taggedPrompt = source.type === "background"
|
|
844
844
|
? routedPrompt
|
|
845
845
|
: `[via ${sourceLabel}] ${routedPrompt}`;
|
|
846
|
-
const logRole = source.type === "background" ? "
|
|
846
|
+
const logRole = source.type === "background" ? "agent_completion" : "user";
|
|
847
847
|
const sourceChannel = source.type === "web" ? "web" : undefined;
|
|
848
848
|
// Capture auth context at enqueue time — prevents cross-session contamination
|
|
849
849
|
// when concurrent sessions are processing simultaneously.
|
|
@@ -675,7 +675,7 @@ test("feedAgentResult injects a background completion turn and proactively notif
|
|
|
675
675
|
}]);
|
|
676
676
|
assert.deepEqual(state.dbLogs, [
|
|
677
677
|
{
|
|
678
|
-
role: "
|
|
678
|
+
role: "agent_completion",
|
|
679
679
|
content: "[Agent task completed] @coder finished task task-9:\n\nFixed the flaky test",
|
|
680
680
|
source: "background",
|
|
681
681
|
},
|
package/dist/store/db.js
CHANGED
|
@@ -62,7 +62,7 @@ export function getDb() {
|
|
|
62
62
|
db.exec(`
|
|
63
63
|
CREATE TABLE IF NOT EXISTS conversation_log (
|
|
64
64
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
-
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
|
|
65
|
+
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'agent_completion')),
|
|
66
66
|
content TEXT NOT NULL,
|
|
67
67
|
source TEXT NOT NULL DEFAULT 'unknown',
|
|
68
68
|
ts DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
@@ -80,16 +80,16 @@ export function getDb() {
|
|
|
80
80
|
`);
|
|
81
81
|
// Migrate: if the table already existed with a stricter CHECK, recreate it
|
|
82
82
|
try {
|
|
83
|
-
db.prepare(`INSERT INTO conversation_log (role, content, source) VALUES ('
|
|
83
|
+
db.prepare(`INSERT INTO conversation_log (role, content, source) VALUES ('agent_completion', '__migration_test__', 'test')`).run();
|
|
84
84
|
db.prepare(`DELETE FROM conversation_log WHERE content = '__migration_test__'`).run();
|
|
85
85
|
}
|
|
86
86
|
catch {
|
|
87
|
-
// CHECK constraint doesn't allow
|
|
87
|
+
// CHECK constraint doesn't allow current synthetic roles — recreate table preserving data
|
|
88
88
|
db.exec(`ALTER TABLE conversation_log RENAME TO conversation_log_old`);
|
|
89
89
|
db.exec(`
|
|
90
90
|
CREATE TABLE conversation_log (
|
|
91
91
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
92
|
-
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
|
|
92
|
+
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'agent_completion')),
|
|
93
93
|
content TEXT NOT NULL,
|
|
94
94
|
source TEXT NOT NULL DEFAULT 'unknown',
|
|
95
95
|
ts DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
@@ -309,7 +309,8 @@ export function getRecentConversation(limit, sessionKey) {
|
|
|
309
309
|
return rows.map((r) => {
|
|
310
310
|
const tag = r.role === "user" ? `[${r.source}] User`
|
|
311
311
|
: r.role === "system" ? `[${r.source}] System`
|
|
312
|
-
: "
|
|
312
|
+
: r.role === "agent_completion" ? `[${r.source}] Agent completion`
|
|
313
|
+
: "Chapterhouse";
|
|
313
314
|
// Truncate long messages to keep context manageable
|
|
314
315
|
const content = r.content.length > 1500 ? r.content.slice(0, 1500) + "…" : r.content;
|
|
315
316
|
return `${tag}: ${content}`;
|
|
@@ -344,21 +345,22 @@ export function normalizeSqliteTsToIso(ts) {
|
|
|
344
345
|
* suitable for seeding the frontend Zustand store on mount.
|
|
345
346
|
*
|
|
346
347
|
* Unlike `getRecentConversation()`, this returns structured objects (not a
|
|
347
|
-
* formatted string) and omits system messages
|
|
348
|
-
*
|
|
348
|
+
* formatted string) and omits internal system messages. Synthetic background
|
|
349
|
+
* completion notices are included and mapped to assistant-style turns so reload
|
|
350
|
+
* matches the live chat rendering path.
|
|
349
351
|
*/
|
|
350
352
|
export function getSessionMessages(sessionKey, limit) {
|
|
351
353
|
const db = getDb();
|
|
352
354
|
const effectiveLimit = Math.min(limit ?? DEFAULT_SESSION_MESSAGES_LIMIT, MAX_SESSION_MESSAGES_LIMIT);
|
|
353
355
|
const rows = db
|
|
354
356
|
.prepare(`SELECT role, content, ts FROM conversation_log
|
|
355
|
-
WHERE session_key = ? AND role IN ('user', 'assistant')
|
|
357
|
+
WHERE session_key = ? AND role IN ('user', 'assistant', 'agent_completion')
|
|
356
358
|
ORDER BY id DESC LIMIT ?`)
|
|
357
359
|
.all(sessionKey, effectiveLimit);
|
|
358
360
|
// Reverse so oldest is first (chronological order for the UI)
|
|
359
361
|
rows.reverse();
|
|
360
362
|
return rows.map((r) => ({
|
|
361
|
-
role: r.role,
|
|
363
|
+
role: r.role === "agent_completion" ? "assistant" : r.role,
|
|
362
364
|
content: r.content,
|
|
363
365
|
ts: normalizeSqliteTsToIso(r.ts),
|
|
364
366
|
}));
|
package/dist/store/db.test.js
CHANGED
|
@@ -165,28 +165,32 @@ test("getSessionMessages returns empty array for unknown session", async () => {
|
|
|
165
165
|
dbModule.closeDb();
|
|
166
166
|
}
|
|
167
167
|
});
|
|
168
|
-
test("getSessionMessages returns structured messages in chronological order, excludes system rows, respects limit", async () => {
|
|
168
|
+
test("getSessionMessages returns structured messages in chronological order, includes agent completions, excludes system rows, respects limit", async () => {
|
|
169
169
|
const dbModule = await loadDbModule();
|
|
170
170
|
try {
|
|
171
|
-
dbModule.getDb();
|
|
171
|
+
const db = dbModule.getDb();
|
|
172
172
|
dbModule.logConversation("user", "hello", "web", "test-session");
|
|
173
173
|
dbModule.logConversation("assistant", "hi there", "web", "test-session");
|
|
174
174
|
dbModule.logConversation("system", "system noise", "worker", "test-session");
|
|
175
|
+
db.prepare(`INSERT INTO conversation_log (role, content, source, session_key)
|
|
176
|
+
VALUES ('agent_completion', ?, 'background', 'test-session')`).run("[Agent task completed] @coder finished task task-1:\n\nDone");
|
|
175
177
|
dbModule.logConversation("user", "second message", "web", "test-session");
|
|
176
178
|
dbModule.logConversation("user", "from other session", "web", "other-session");
|
|
177
179
|
const all = dbModule.getSessionMessages("test-session");
|
|
178
|
-
assert.equal(all.length,
|
|
180
|
+
assert.equal(all.length, 4, "user/assistant rows plus agent completion, system excluded");
|
|
179
181
|
assert.equal(all[0].role, "user");
|
|
180
182
|
assert.equal(all[0].content, "hello");
|
|
181
183
|
assert.equal(all[1].role, "assistant");
|
|
182
184
|
assert.equal(all[1].content, "hi there");
|
|
183
|
-
assert.equal(all[2].role, "
|
|
184
|
-
assert.equal(all[2].content, "
|
|
185
|
+
assert.equal(all[2].role, "assistant");
|
|
186
|
+
assert.equal(all[2].content, "[Agent task completed] @coder finished task task-1:\n\nDone");
|
|
187
|
+
assert.equal(all[3].role, "user");
|
|
188
|
+
assert.equal(all[3].content, "second message");
|
|
185
189
|
// Limit clamping
|
|
186
190
|
const limited = dbModule.getSessionMessages("test-session", 2);
|
|
187
191
|
assert.equal(limited.length, 2, "limit=2 returns 2 most recent rows");
|
|
188
|
-
// After reversal, these should be the 2 most-recent
|
|
189
|
-
assert.equal(limited[0].content, "
|
|
192
|
+
// After reversal, these should be the 2 most-recent renderable rows.
|
|
193
|
+
assert.equal(limited[0].content, "[Agent task completed] @coder finished task task-1:\n\nDone");
|
|
190
194
|
assert.equal(limited[1].content, "second message");
|
|
191
195
|
// Other session not leaked
|
|
192
196
|
const other = dbModule.getSessionMessages("other-session");
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
export const DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS = 60_000;
|
|
5
|
+
export const STANDALONE_API_SERVER_STARTUP_TIMEOUT_MS = 60_000;
|
|
6
|
+
export const API_SERVER_STARTUP_POLL_INTERVAL_MS = 100;
|
|
7
|
+
export async function getFreePort() {
|
|
8
|
+
const server = createServer();
|
|
9
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
10
|
+
const address = server.address();
|
|
11
|
+
assert.ok(address && typeof address === "object", "server should expose a bound address");
|
|
12
|
+
await new Promise((resolve, reject) => {
|
|
13
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
14
|
+
});
|
|
15
|
+
return address.port;
|
|
16
|
+
}
|
|
17
|
+
export async function stopChild(child) {
|
|
18
|
+
if (child.exitCode !== null) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
child.kill("SIGTERM");
|
|
22
|
+
await new Promise((resolve) => {
|
|
23
|
+
child.once("exit", () => resolve());
|
|
24
|
+
setTimeout(() => {
|
|
25
|
+
if (child.exitCode === null) {
|
|
26
|
+
child.kill("SIGKILL");
|
|
27
|
+
}
|
|
28
|
+
}, 2_000);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export async function waitForApiServerReady({ child, baseUrl, logs, timeoutMs = DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, pollIntervalMs = API_SERVER_STARTUP_POLL_INTERVAL_MS, timeoutMessage = "Timed out waiting for API server to start", exitMessage = "API server exited early", }) {
|
|
32
|
+
const deadline = Date.now() + timeoutMs;
|
|
33
|
+
while (Date.now() < deadline) {
|
|
34
|
+
if (child.exitCode !== null) {
|
|
35
|
+
throw new Error(`${exitMessage}:\n${logs.join("")}`);
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetch(`${baseUrl}/status`);
|
|
39
|
+
if (response.ok) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Server is still starting.
|
|
45
|
+
}
|
|
46
|
+
await delay(pollIntervalMs);
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`${timeoutMessage}:\n${logs.join("")}`);
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=api-server.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, STANDALONE_API_SERVER_STARTUP_TIMEOUT_MS, waitForApiServerReady, } from "./api-server.js";
|
|
5
|
+
async function listen(server) {
|
|
6
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
7
|
+
const address = server.address();
|
|
8
|
+
assert.ok(address && typeof address === "object");
|
|
9
|
+
return address.port;
|
|
10
|
+
}
|
|
11
|
+
async function close(server) {
|
|
12
|
+
if (!server.listening) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
await new Promise((resolve, reject) => {
|
|
16
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
test("waitForApiServerReady waits until /status responds with ok", async () => {
|
|
20
|
+
let ready = false;
|
|
21
|
+
const server = createServer((req, res) => {
|
|
22
|
+
if (req.url !== "/status") {
|
|
23
|
+
res.writeHead(404).end();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (!ready) {
|
|
27
|
+
res.writeHead(503).end("starting");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
31
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
32
|
+
});
|
|
33
|
+
const port = await listen(server);
|
|
34
|
+
const child = { exitCode: null };
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
ready = true;
|
|
37
|
+
}, 200);
|
|
38
|
+
const startedAt = Date.now();
|
|
39
|
+
try {
|
|
40
|
+
await waitForApiServerReady({
|
|
41
|
+
child,
|
|
42
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
43
|
+
logs: [],
|
|
44
|
+
timeoutMs: 1_000,
|
|
45
|
+
pollIntervalMs: 25,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await close(server);
|
|
50
|
+
}
|
|
51
|
+
assert.ok(Date.now() - startedAt >= 150);
|
|
52
|
+
});
|
|
53
|
+
test("API server startup timeouts leave headroom for slow CI runners", () => {
|
|
54
|
+
assert.equal(DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, 60_000);
|
|
55
|
+
assert.equal(STANDALONE_API_SERVER_STARTUP_TIMEOUT_MS, 60_000);
|
|
56
|
+
});
|
|
57
|
+
//# sourceMappingURL=api-server.test.js.map
|
package/dist/test/setup-env.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
const RUNTIME_OVERRIDE_ENV_VARS = [
|
|
2
|
+
"CHAPTERHOUSE_MODE",
|
|
3
|
+
"CHAPTERHOUSE_SELF_EDIT",
|
|
4
|
+
"CHAPTERHOUSE_WORKIQ_AUTO_INSTALL",
|
|
5
|
+
"CHAPTERHOUSE_CHAT_SSE",
|
|
6
|
+
];
|
|
7
|
+
for (const name of RUNTIME_OVERRIDE_ENV_VARS) {
|
|
8
|
+
delete process.env[name];
|
|
9
|
+
}
|
|
1
10
|
process.env.CHAPTERHOUSE_DISABLE_DOTENV = "1";
|
|
2
11
|
export {};
|
|
3
12
|
//# sourceMappingURL=setup-env.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
const RUNTIME_OVERRIDE_ENV_VARS = [
|
|
4
|
+
"CHAPTERHOUSE_MODE",
|
|
5
|
+
"CHAPTERHOUSE_SELF_EDIT",
|
|
6
|
+
"CHAPTERHOUSE_WORKIQ_AUTO_INSTALL",
|
|
7
|
+
"CHAPTERHOUSE_CHAT_SSE",
|
|
8
|
+
];
|
|
9
|
+
test("setup-env clears ambient Chapterhouse runtime overrides", async () => {
|
|
10
|
+
const originalValues = new Map(["CHAPTERHOUSE_DISABLE_DOTENV", ...RUNTIME_OVERRIDE_ENV_VARS].map((name) => [name, process.env[name]]));
|
|
11
|
+
try {
|
|
12
|
+
process.env.CHAPTERHOUSE_DISABLE_DOTENV = "0";
|
|
13
|
+
process.env.CHAPTERHOUSE_MODE = "team";
|
|
14
|
+
process.env.CHAPTERHOUSE_SELF_EDIT = "1";
|
|
15
|
+
process.env.CHAPTERHOUSE_WORKIQ_AUTO_INSTALL = "false";
|
|
16
|
+
process.env.CHAPTERHOUSE_CHAT_SSE = "0";
|
|
17
|
+
await import(`./setup-env.js?cache-bust=${Date.now()}`);
|
|
18
|
+
assert.equal(process.env.CHAPTERHOUSE_DISABLE_DOTENV, "1");
|
|
19
|
+
for (const name of RUNTIME_OVERRIDE_ENV_VARS) {
|
|
20
|
+
assert.equal(process.env[name], undefined, `${name} should be cleared by setup-env`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
for (const [name, value] of originalValues) {
|
|
25
|
+
if (value === undefined) {
|
|
26
|
+
delete process.env[name];
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
process.env[name] = value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
//# sourceMappingURL=setup-env.test.js.map
|
package/package.json
CHANGED