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.
@@ -20,7 +20,7 @@ export function createHealthPayload(now = new Date()) {
20
20
  };
21
21
  }
22
22
  export function createPublicConfigPayload(config) {
23
- const chatSseEnabled = config.chatSseEnabled ?? false;
23
+ const chatSseEnabled = config.chatSseEnabled ?? true;
24
24
  if (!config.entraAuthEnabled) {
25
25
  return {
26
26
  appName: "Chapterhouse",
@@ -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 — gated by CHAPTERHOUSE_CHAT_SSE=1 (#130)
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"),
@@ -1,10 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
- import { createServer } from "node:http";
5
4
  import { join } from "node:path";
6
5
  import test from "node:test";
7
6
  import Database from "better-sqlite3";
7
+ import { DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, STANDALONE_API_SERVER_STARTUP_TIMEOUT_MS, getFreePort, stopChild, waitForApiServerReady, } from "../test/api-server.js";
8
8
  test("supports API_TOKEN env var and personal health route helpers", async () => {
9
9
  const runtime = await import("./server-runtime.js");
10
10
  assert.equal(typeof runtime.resolveApiToken, "function", "resolveApiToken should be exported");
@@ -29,25 +29,25 @@ test("supports API_TOKEN env var and personal health route helpers", async () =>
29
29
  standaloneMode: false,
30
30
  entraClientId: "client-id",
31
31
  entraTenantId: "tenant-id",
32
- chatSseEnabled: false,
32
+ chatSseEnabled: true,
33
33
  }), {
34
34
  appName: "Chapterhouse",
35
35
  entraAuthEnabled: true,
36
36
  entraClientId: "client-id",
37
37
  entraTenantId: "tenant-id",
38
- chatSseEnabled: false,
38
+ chatSseEnabled: true,
39
39
  });
40
40
  assert.deepEqual(runtime.createPublicConfigPayload({
41
41
  entraAuthEnabled: false,
42
42
  standaloneMode: false,
43
43
  entraClientId: "client-id",
44
44
  entraTenantId: "tenant-id",
45
- chatSseEnabled: false,
45
+ chatSseEnabled: true,
46
46
  }), {
47
47
  appName: "Chapterhouse",
48
48
  entraAuthEnabled: false,
49
49
  standalone: false,
50
- chatSseEnabled: false,
50
+ chatSseEnabled: true,
51
51
  });
52
52
  assert.doesNotThrow(() => runtime.assertAuthenticationConfigured({
53
53
  entraAuthEnabled: false,
@@ -62,6 +62,21 @@ test("supports API_TOKEN env var and personal health route helpers", async () =>
62
62
  apiToken: null,
63
63
  }));
64
64
  });
65
+ test("createPublicConfigPayload defaults chat SSE on when not explicitly provided", async () => {
66
+ const runtime = await import("./server-runtime.js");
67
+ assert.equal(typeof runtime.createPublicConfigPayload, "function", "createPublicConfigPayload should be exported");
68
+ assert.deepEqual(runtime.createPublicConfigPayload({
69
+ entraAuthEnabled: false,
70
+ standaloneMode: true,
71
+ entraClientId: "client-id",
72
+ entraTenantId: "tenant-id",
73
+ }), {
74
+ appName: "Chapterhouse",
75
+ entraAuthEnabled: false,
76
+ standalone: true,
77
+ chatSseEnabled: true,
78
+ });
79
+ });
65
80
  test("formats named SSE status events", async () => {
66
81
  const sse = await import("./sse.js");
67
82
  assert.equal(typeof sse.formatSseEvent, "function", "formatSseEvent should be exported");
@@ -109,42 +124,18 @@ function readProjectRegistryRows(testRoot) {
109
124
  db.close();
110
125
  }
111
126
  }
112
- async function getFreePort() {
113
- const server = createServer();
114
- await new Promise((resolve) => {
115
- server.listen(0, "127.0.0.1", () => resolve());
116
- });
117
- const address = server.address();
118
- assert.ok(address && typeof address === "object", "server should expose a bound address");
119
- await new Promise((resolve, reject) => {
120
- server.close((err) => (err ? reject(err) : resolve()));
121
- });
122
- return address.port;
123
- }
124
- async function stopChild(child) {
125
- if (child.exitCode !== null) {
126
- return;
127
- }
128
- child.kill("SIGTERM");
129
- await new Promise((resolve) => {
130
- child.once("exit", () => resolve());
131
- setTimeout(() => {
132
- if (child.exitCode === null) {
133
- child.kill("SIGKILL");
134
- }
135
- }, 2_000);
136
- });
137
- }
138
127
  // Standalone mode triggers a full daemon init via the server→daemon circular import
139
128
  // (server.ts imports restartDaemon from daemon.ts, which has module-level main()).
140
129
  // The Copilot SDK's client.start() then blocks the event loop while authenticating,
141
- // so the HTTP server cannot respond until SDK init completes (~10-20 s depending on
142
- // session state). Other modes complete in ~330 ms because the SDK yields quickly.
130
+ // so the HTTP server cannot respond until SDK init completes (~10-30 s depending on
131
+ // session state and runner load). Other modes usually complete much faster, but cold
132
+ // CI starts can still exceed a 10 s budget.
143
133
  // Root cause tracked in decisions notes (backend fix: lazy-import or
144
- // guard env var in daemon.ts). Interim fix: 30 s timeout for standalone, 10 s elsewhere.
134
+ // guard env var in daemon.ts). Interim fix: use a more generous startup timeout for
135
+ // integration tests while keeping the same /status readiness probe.
145
136
  // Additionally, strip COPILOT_* env vars from the child so it doesn't accidentally
146
137
  // piggy-back on the running agent session, which worsens the blocking behaviour.
147
- async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
138
+ async function withStartedServer(run, extraEnv = {}, timeoutMs = DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS) {
148
139
  const testRoot = join(repoRoot, ".test-work", `server-routes-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
149
140
  mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
150
141
  rmSync(testRoot, { recursive: true, force: true });
@@ -174,24 +165,8 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
174
165
  child.stderr?.on("data", (chunk) => logs.push(String(chunk)));
175
166
  const baseUrl = `http://127.0.0.1:${port}`;
176
167
  try {
177
- const deadline = Date.now() + timeoutMs;
178
- while (Date.now() < deadline) {
179
- if (child.exitCode !== null) {
180
- throw new Error(`API server exited early:\n${logs.join("")}`);
181
- }
182
- try {
183
- const response = await fetch(`${baseUrl}/status`);
184
- if (response.ok) {
185
- await run({ baseUrl, authHeader: "Bearer route-token", testRoot });
186
- return;
187
- }
188
- }
189
- catch {
190
- // Server still starting.
191
- }
192
- await new Promise((resolve) => setTimeout(resolve, 100));
193
- }
194
- throw new Error(`Timed out waiting for API server to start:\n${logs.join("")}`);
168
+ await waitForApiServerReady({ child, baseUrl, logs, timeoutMs });
169
+ await run({ baseUrl, authHeader: "Bearer route-token", testRoot });
195
170
  }
196
171
  finally {
197
172
  await stopChild(child);
@@ -209,7 +184,7 @@ test("server routes expose bootstrap and public config without auth", async () =
209
184
  appName: "Chapterhouse",
210
185
  entraAuthEnabled: false,
211
186
  standalone: false,
212
- chatSseEnabled: false,
187
+ chatSseEnabled: true,
213
188
  });
214
189
  });
215
190
  });
@@ -224,14 +199,14 @@ test("server runs in standalone mode without auth", async () => {
224
199
  appName: "Chapterhouse",
225
200
  entraAuthEnabled: false,
226
201
  standalone: true,
227
- chatSseEnabled: false,
202
+ chatSseEnabled: true,
228
203
  });
229
204
  const model = await fetch(`${baseUrl}/api/model`);
230
205
  assert.equal(model.status, 200);
231
206
  assert.deepEqual(await model.json(), { model: "claude-sonnet-4.6" });
232
207
  }, {
233
208
  API_TOKEN: "",
234
- }, 30_000);
209
+ }, STANDALONE_API_SERVER_STARTUP_TIMEOUT_MS);
235
210
  });
236
211
  test("server bootstrap rejects non-loopback origins", async () => {
237
212
  await withStartedServer(async ({ baseUrl }) => {
@@ -1,42 +1,16 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { spawn } from "node:child_process";
3
3
  import { mkdirSync, rmSync } from "node:fs";
4
- import { createServer } from "node:http";
5
4
  import { join } from "node:path";
6
5
  import test from "node:test";
6
+ import { DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, getFreePort, stopChild, waitForApiServerReady, } from "../test/api-server.js";
7
7
  // ---------------------------------------------------------------------------
8
8
  // Server harness (copied from server.test.ts; distinct root path suffix to
9
9
  // avoid collision when both test files run in parallel in the same process).
10
10
  // ---------------------------------------------------------------------------
11
11
  const repoRoot = process.cwd();
12
12
  const sseTestRoot = join(repoRoot, ".test-work", `sse-integration-${process.pid}`);
13
- async function getFreePort() {
14
- const server = createServer();
15
- await new Promise((resolve) => {
16
- server.listen(0, "127.0.0.1", () => resolve());
17
- });
18
- const address = server.address();
19
- assert.ok(address && typeof address === "object", "server should expose a bound address");
20
- await new Promise((resolve, reject) => {
21
- server.close((err) => (err ? reject(err) : resolve()));
22
- });
23
- return address.port;
24
- }
25
- async function stopChild(child) {
26
- if (child.exitCode !== null) {
27
- return;
28
- }
29
- child.kill("SIGTERM");
30
- await new Promise((resolve) => {
31
- child.once("exit", () => resolve());
32
- setTimeout(() => {
33
- if (child.exitCode === null) {
34
- child.kill("SIGKILL");
35
- }
36
- }, 2_000);
37
- });
38
- }
39
- async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
13
+ async function withStartedServer(run, extraEnv = {}, timeoutMs = DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS) {
40
14
  mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
41
15
  rmSync(sseTestRoot, { recursive: true, force: true });
42
16
  mkdirSync(sseTestRoot, { recursive: true });
@@ -65,24 +39,8 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
65
39
  child.stderr?.on("data", (chunk) => logs.push(String(chunk)));
66
40
  const baseUrl = `http://127.0.0.1:${port}`;
67
41
  try {
68
- const deadline = Date.now() + timeoutMs;
69
- while (Date.now() < deadline) {
70
- if (child.exitCode !== null) {
71
- throw new Error(`API server exited early:\n${logs.join("")}`);
72
- }
73
- try {
74
- const response = await fetch(`${baseUrl}/status`);
75
- if (response.ok) {
76
- await run({ baseUrl, authHeader: "Bearer route-token" });
77
- return;
78
- }
79
- }
80
- catch {
81
- // Server still starting.
82
- }
83
- await new Promise((resolve) => setTimeout(resolve, 100));
84
- }
85
- throw new Error(`Timed out waiting for API server to start:\n${logs.join("")}`);
42
+ await waitForApiServerReady({ child, baseUrl, logs, timeoutMs });
43
+ await run({ baseUrl, authHeader: "Bearer route-token" });
86
44
  }
87
45
  finally {
88
46
  await stopChild(child);
@@ -2,8 +2,8 @@
2
2
  * Integration tests for the POST→SSE chat endpoints (#130).
3
3
  *
4
4
  * Tests:
5
- * 1. POST /api/sessions/:key/turn returns 404 when flag is off
6
- * 2. GET /api/sessions/:key/stream returns 404 when flag is off
5
+ * 1. POST /api/sessions/:key/turn returns 404 when flag is explicitly off
6
+ * 2. GET /api/sessions/:key/stream returns 404 when flag is explicitly off
7
7
  * 3. POST /api/sessions/:key/turn returns { turnId } when flag is on
8
8
  * 4. GET /api/sessions/:key/stream delivers turn events (SSE)
9
9
  * 5. SSE reconnect with Last-Event-ID replays missed events
@@ -11,8 +11,8 @@
11
11
  * 7. Legacy POST /api/message path unaffected when flag is off
12
12
  *
13
13
  * The tests that require the feature flag spawn a separate server process with
14
- * CHAPTERHOUSE_CHAT_SSE=1. Tests that verify flag-off behavior use a server
15
- * without the flag.
14
+ * CHAPTERHOUSE_CHAT_SSE defaults on. Tests that verify flag-off behavior use a
15
+ * server with CHAPTERHOUSE_CHAT_SSE=0.
16
16
  *
17
17
  * Because the Copilot SDK is not available in test env (no valid token), tests
18
18
  * that would trigger a real turn instead verify the API shape and event
@@ -23,34 +23,16 @@
23
23
  import assert from "node:assert/strict";
24
24
  import { spawn } from "node:child_process";
25
25
  import { mkdirSync, rmSync } from "node:fs";
26
- import { createServer } from "node:http";
27
26
  import { join } from "node:path";
28
27
  import test from "node:test";
28
+ import { DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, getFreePort, stopChild, waitForApiServerReady, } from "../test/api-server.js";
29
29
  // ---------------------------------------------------------------------------
30
30
  // Helpers — copied/adapted from sse.integration.test.ts
31
31
  // ---------------------------------------------------------------------------
32
32
  const repoRoot = process.cwd();
33
33
  const testWorkRoot = join(repoRoot, ".test-work", `turn-sse-${process.pid}`);
34
34
  let _instanceCounter = 0;
35
- async function getFreePort() {
36
- const server = createServer();
37
- await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
38
- const addr = server.address();
39
- assert.ok(addr && typeof addr === "object");
40
- await new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())));
41
- return addr.port;
42
- }
43
- async function stopChild(child) {
44
- if (child.exitCode !== null)
45
- return;
46
- child.kill("SIGTERM");
47
- await new Promise((resolve) => {
48
- child.once("exit", () => resolve());
49
- setTimeout(() => { if (child.exitCode === null)
50
- child.kill("SIGKILL"); }, 2_000);
51
- });
52
- }
53
- async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
35
+ async function withStartedServer(run, extraEnv = {}, timeoutMs = DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS) {
54
36
  mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
55
37
  const instanceRoot = `${testWorkRoot}-${Date.now()}-${++_instanceCounter}`;
56
38
  rmSync(instanceRoot, { recursive: true, force: true });
@@ -78,24 +60,15 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = 10_000) {
78
60
  child.stderr?.on("data", (c) => logs.push(String(c)));
79
61
  const baseUrl = `http://127.0.0.1:${port}`;
80
62
  try {
81
- const deadline = Date.now() + timeoutMs;
82
- while (Date.now() < deadline) {
83
- if (child.exitCode !== null)
84
- throw new Error(`Server exited early:\n${logs.join("")}`);
85
- let serverReady = false;
86
- try {
87
- const r = await fetch(`${baseUrl}/status`);
88
- serverReady = r.ok;
89
- }
90
- catch { /* not ready yet */ }
91
- if (serverReady) {
92
- // Run outside the fetch try/catch so assertion errors propagate correctly.
93
- await run({ baseUrl, authHeader: "Bearer test-token" });
94
- return;
95
- }
96
- await new Promise((r) => setTimeout(r, 100));
97
- }
98
- throw new Error(`Server did not start in ${timeoutMs}ms:\n${logs.join("")}`);
63
+ await waitForApiServerReady({
64
+ child,
65
+ baseUrl,
66
+ logs,
67
+ timeoutMs,
68
+ timeoutMessage: `Server did not start in ${timeoutMs}ms`,
69
+ exitMessage: "Server exited early",
70
+ });
71
+ await run({ baseUrl, authHeader: "Bearer test-token" });
99
72
  }
100
73
  finally {
101
74
  await stopChild(child);
@@ -162,18 +135,18 @@ test("turn-sse: POST /api/sessions/:key/turn returns 404 when flag is off", asyn
162
135
  headers: { Authorization: authHeader, "Content-Type": "application/json" },
163
136
  body: JSON.stringify({ prompt: "hello" }),
164
137
  });
165
- assert.equal(res.status, 404, "Should return 404 when CHAPTERHOUSE_CHAT_SSE is not set");
138
+ assert.equal(res.status, 404, "Should return 404 when CHAPTERHOUSE_CHAT_SSE=0");
166
139
  const body = await res.json();
167
140
  assert.ok(typeof body.error === "string" && body.error.includes("Chat SSE"), `Expected error about Chat SSE, got: ${body.error}`);
168
- });
141
+ }, { CHAPTERHOUSE_CHAT_SSE: "0" });
169
142
  });
170
143
  test("turn-sse: GET /api/sessions/:key/stream returns 404 when flag is off", async () => {
171
144
  await withStartedServer(async ({ baseUrl, authHeader }) => {
172
145
  const res = await fetch(`${baseUrl}/api/sessions/default/stream`, {
173
146
  headers: { Authorization: authHeader },
174
147
  });
175
- assert.equal(res.status, 404, "Should return 404 when CHAPTERHOUSE_CHAT_SSE is not set");
176
- });
148
+ assert.equal(res.status, 404, "Should return 404 when CHAPTERHOUSE_CHAT_SSE=0");
149
+ }, { CHAPTERHOUSE_CHAT_SSE: "0" });
177
150
  });
178
151
  test("turn-sse: legacy POST /api/message is unaffected when flag is off", async () => {
179
152
  await withStartedServer(async ({ baseUrl, authHeader }) => {
@@ -186,7 +159,7 @@ test("turn-sse: legacy POST /api/message is unaffected when flag is off", async
186
159
  });
187
160
  // 400 = route exists and validated the request (connectionId not found in sseClients)
188
161
  assert.ok(res.status === 400 || res.status === 401 || res.status === 422, `Expected 4xx from /api/message, got ${res.status}`);
189
- });
162
+ }, { CHAPTERHOUSE_CHAT_SSE: "0" });
190
163
  });
191
164
  // ---------------------------------------------------------------------------
192
165
  // Tests: feature flag ON
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 === "1",
243
+ chatSseEnabled: parseZeroOneEnv("CHAPTERHOUSE_CHAT_SSE", raw.CHAPTERHOUSE_CHAT_SSE, true),
234
244
  };
235
245
  }
236
246
  const runtimeConfig = parseRuntimeConfig(process.env);
@@ -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" ? "system" : "user";
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: "system",
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 ('system', '__migration_test__', 'test')`).run();
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 'system' — recreate table preserving data
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
- : "Chapterhouse";
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 (role = 'system') because the
348
- * UI only renders user/assistant turns.
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
  }));
@@ -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, 3, "3 user/assistant rows, system excluded");
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, "user");
184
- assert.equal(all[2].content, "second message");
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 (assistant + second user)
189
- assert.equal(limited[0].content, "hi there");
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
4
4
  "description": "Chapterhouse — a team-level AI assistant for engineering teams, built on the GitHub Copilot SDK. Web UI only.",
5
5
  "bin": {
6
6
  "chapterhouse": "dist/cli.js"