chapterhouse 0.4.0 → 0.4.2
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.js +14 -8
- package/dist/api/server.test.js +30 -0
- package/dist/copilot/agents.js +5 -2
- package/dist/copilot/agents.test.js +34 -0
- package/dist/copilot/orchestrator.js +8 -11
- package/dist/copilot/orchestrator.test.js +12 -4
- package/dist/copilot/prompt-date.js +8 -0
- package/dist/copilot/system-message.js +4 -0
- package/dist/copilot/system-message.test.js +34 -0
- package/dist/copilot/tools.agent.test.js +1 -0
- package/dist/copilot/tools.js +2 -1
- package/dist/copilot/tools.memory.test.js +49 -0
- package/dist/copilot/turn-event-log.js +35 -15
- package/dist/copilot/turn-event-log.test.js +31 -0
- package/dist/memory/eot.test.js +3 -3
- package/dist/memory/housekeeping.test.js +26 -26
- package/dist/memory/recall.js +15 -2
- package/dist/memory/recall.test.js +42 -0
- package/dist/store/db.js +336 -9
- package/dist/store/db.test.js +393 -7
- package/package.json +1 -1
- package/web/dist/assets/{index-DmYLALt0.js → index-B_cCSHan.js} +52 -52
- package/web/dist/assets/index-B_cCSHan.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-DmYLALt0.js.map +0 -1
package/dist/store/db.test.js
CHANGED
|
@@ -34,6 +34,7 @@ test("getDb initializes schema, state helpers, and conversation formatting", asy
|
|
|
34
34
|
"agent_tasks",
|
|
35
35
|
"projects",
|
|
36
36
|
"max_state",
|
|
37
|
+
"daemon_runs",
|
|
37
38
|
"conversation_log",
|
|
38
39
|
"memories",
|
|
39
40
|
]) {
|
|
@@ -181,15 +182,23 @@ test("getDb migrates legacy memory tiers from glacier to cold and preserves expl
|
|
|
181
182
|
const dbModule = await loadDbModule();
|
|
182
183
|
try {
|
|
183
184
|
const db = dbModule.getDb();
|
|
184
|
-
assert.deepEqual(db.prepare(`
|
|
185
|
+
assert.deepEqual(db.prepare(`
|
|
186
|
+
SELECT id, tier FROM mem_entities
|
|
187
|
+
WHERE name IN ('warm kept', 'cold mapped')
|
|
188
|
+
ORDER BY id
|
|
189
|
+
`).all(), [
|
|
185
190
|
{ id: 1, tier: "warm" },
|
|
186
191
|
{ id: 2, tier: "cold" },
|
|
187
192
|
]);
|
|
188
|
-
assert.deepEqual(db.prepare(`SELECT id, tier FROM mem_observations ORDER BY id`).all(), [
|
|
193
|
+
assert.deepEqual(db.prepare(`SELECT id, tier FROM mem_observations WHERE source = 'test' ORDER BY id`).all(), [
|
|
189
194
|
{ id: 1, tier: "hot" },
|
|
190
195
|
{ id: 2, tier: "cold" },
|
|
191
196
|
]);
|
|
192
|
-
assert.deepEqual(db.prepare(`
|
|
197
|
+
assert.deepEqual(db.prepare(`
|
|
198
|
+
SELECT id, tier FROM mem_decisions
|
|
199
|
+
WHERE title IN ('warm kept', 'cold mapped')
|
|
200
|
+
ORDER BY id
|
|
201
|
+
`).all(), [
|
|
193
202
|
{ id: 1, tier: "warm" },
|
|
194
203
|
{ id: 2, tier: "cold" },
|
|
195
204
|
]);
|
|
@@ -200,6 +209,305 @@ test("getDb migrates legacy memory tiers from glacier to cold and preserves expl
|
|
|
200
209
|
dbModule.closeDb();
|
|
201
210
|
}
|
|
202
211
|
});
|
|
212
|
+
test("getDb seeds the Chapterhouse entity and PR1 wiki observations idempotently", async () => {
|
|
213
|
+
const dbModule = await loadDbModule();
|
|
214
|
+
try {
|
|
215
|
+
const expectedObservations = [
|
|
216
|
+
{
|
|
217
|
+
content: "Chapterhouse architecture: Node.js daemon, web UI, per-conversation sessions, specialist agents, modular skills, MCP servers, local markdown wiki, project-rules prompt injection, and background workers with SSE event streaming.",
|
|
218
|
+
tier: "warm",
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
content: "Chapterhouse source lives at bketelsen/chapterhouse with local checkout ~/projects/chapterhouse; engineering plans are in docs/plans/ and product specs are in docs/prd/.",
|
|
222
|
+
tier: "warm",
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
content: "Background agent completions arrive as automatic system notifications; after spawning a background agent, end the turn and wait instead of polling read_agent, sleeping, or re-checking list_agents.",
|
|
226
|
+
tier: "hot",
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
content: "Use the default Chapterhouse chat for research and general questions; move code changes against a specific project into that project's chat session.",
|
|
230
|
+
tier: "warm",
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
content: "Squad manages Chapterhouse's own development workflow.",
|
|
234
|
+
tier: "warm",
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
content: "Chapterhouse uses frequent semver patch releases published to npm via the Publish to npm GitHub Actions workflow on tag push; never publish manually.",
|
|
238
|
+
tier: "hot",
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
content: "Agent-memory P1 shipped on 2026-05-13 as Chapterhouse v0.4.0 across 7 PRs: #215 through #225.",
|
|
242
|
+
tier: "warm",
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
content: "The v0.3.2 per-session orchestrator was confirmed under real concurrent load on 2026-05-08, enabling concurrent sessions without cross-blocking.",
|
|
246
|
+
tier: "hot",
|
|
247
|
+
},
|
|
248
|
+
];
|
|
249
|
+
let db = dbModule.getDb();
|
|
250
|
+
dbModule.closeDb();
|
|
251
|
+
db = dbModule.getDb();
|
|
252
|
+
const chapterhouseScope = db.prepare(`
|
|
253
|
+
SELECT id, slug
|
|
254
|
+
FROM mem_scopes
|
|
255
|
+
WHERE slug = 'chapterhouse'
|
|
256
|
+
`).get();
|
|
257
|
+
assert.ok(chapterhouseScope, "chapterhouse scope should be seeded");
|
|
258
|
+
const entity = db.prepare(`
|
|
259
|
+
SELECT id, scope_id, kind, name, summary, tier
|
|
260
|
+
FROM mem_entities
|
|
261
|
+
WHERE scope_id = ? AND kind = 'project' AND name = 'Chapterhouse'
|
|
262
|
+
`).get(chapterhouseScope.id);
|
|
263
|
+
assert.ok(entity, "Chapterhouse project entity should be seeded");
|
|
264
|
+
assert.equal(entity.scope_id, chapterhouseScope.id);
|
|
265
|
+
assert.equal(entity.summary, "Always-on team-level AI assistant daemon that orchestrates specialist subagents and maintains a persistent knowledge wiki.");
|
|
266
|
+
const rows = db.prepare(`
|
|
267
|
+
SELECT scope_id, entity_id, content, source, tier, tier_pinned_at, tier_reason
|
|
268
|
+
FROM mem_observations
|
|
269
|
+
WHERE source = 'wiki:pages/projects/chapterhouse/index.md'
|
|
270
|
+
ORDER BY content
|
|
271
|
+
`).all();
|
|
272
|
+
assert.equal(rows.length, expectedObservations.length, "PR1 should seed exactly 8 index.md observations");
|
|
273
|
+
for (const expected of expectedObservations) {
|
|
274
|
+
const row = rows.find((entry) => entry.content === expected.content);
|
|
275
|
+
assert.ok(row, `missing seeded observation: ${expected.content}`);
|
|
276
|
+
assert.equal(row.scope_id, chapterhouseScope.id, "all PR1 observations must be scoped to chapterhouse");
|
|
277
|
+
assert.equal(row.entity_id, entity.id, "all PR1 observations should attach to the Chapterhouse entity");
|
|
278
|
+
assert.equal(row.tier, expected.tier);
|
|
279
|
+
if (expected.tier === "hot") {
|
|
280
|
+
assert.ok(row.tier_pinned_at, "hot PR1 observations should be manually pinned");
|
|
281
|
+
assert.equal(row.tier_reason, "P6 PR1 wiki migration hot-tier candidate");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const duplicates = db.prepare(`
|
|
285
|
+
SELECT content, COUNT(*) AS count
|
|
286
|
+
FROM mem_observations
|
|
287
|
+
WHERE source = 'wiki:pages/projects/chapterhouse/index.md'
|
|
288
|
+
GROUP BY content
|
|
289
|
+
HAVING COUNT(*) > 1
|
|
290
|
+
`).all();
|
|
291
|
+
assert.deepEqual(duplicates, [], "re-running getDb must not duplicate seeded observations");
|
|
292
|
+
const entityCount = db.prepare(`
|
|
293
|
+
SELECT COUNT(*) AS count
|
|
294
|
+
FROM mem_entities
|
|
295
|
+
WHERE scope_id = ? AND kind = 'project' AND name = 'Chapterhouse'
|
|
296
|
+
`).get(chapterhouseScope.id).count;
|
|
297
|
+
assert.equal(entityCount, 1, "re-running getDb must not duplicate the Chapterhouse entity");
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
dbModule.closeDb();
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
test("getDb seeds the Chapterhouse PR2 wiki decisions idempotently", async () => {
|
|
304
|
+
const dbModule = await loadDbModule();
|
|
305
|
+
try {
|
|
306
|
+
const expectedDecisions = [
|
|
307
|
+
{
|
|
308
|
+
title: "Standardize API validation and JSON errors around `src/api/errors.ts`",
|
|
309
|
+
rationale: "Keep one 400/403/404/500 contract across routes instead of ad hoc parsing and responses; `src/api/errors.ts`, `src/api/server.ts`.",
|
|
310
|
+
decided_at: "2026-05-06",
|
|
311
|
+
tier: "warm",
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
title: "Run in standalone mode when neither Entra nor an API token is configured",
|
|
315
|
+
rationale: "Disable auth checks and team sync together so local single-user startup still works; `src/config.ts`, `src/api/auth.ts`.",
|
|
316
|
+
decided_at: "2026-05-06",
|
|
317
|
+
tier: "warm",
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
title: "Use bearer auth for SSE and serialize wiki writes",
|
|
321
|
+
rationale: "`/stream` no longer accepts query tokens, and wiki mutations go through `withWikiWrite()`; `web/src/stream.ts`, `src/wiki/lock.ts`.",
|
|
322
|
+
decided_at: "2026-05-06",
|
|
323
|
+
tier: "warm",
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
title: "Default production to least privilege",
|
|
327
|
+
rationale: "Run as a non-root container, validate env early, and keep CORS and security headers tight in production; `Dockerfile`, `deploy/deploy.sh`, `src/config.ts`.",
|
|
328
|
+
decided_at: "2026-05-06",
|
|
329
|
+
tier: "warm",
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
title: "Keep chat state session-scoped by `sessionKey`",
|
|
333
|
+
rationale: "Conversation history, SSE routing, and frontend buffers stay isolated per session instead of bleeding across chats; `src/store/db.ts`, `web/src/store.ts`.",
|
|
334
|
+
decided_at: "2026-05-07",
|
|
335
|
+
tier: "warm",
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
title: "Preserve the 3-layer daemon timing contract",
|
|
339
|
+
rationale: "Orchestrator timeout must stay below daemon shutdown grace, which must stay below systemd `TimeoutStopSec`; `README.md`, `src/daemon.ts`, `src/daemon-install.ts`.",
|
|
340
|
+
decided_at: "2026-05-08",
|
|
341
|
+
tier: "hot",
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
title: "Start WorkIQ MCP auto-install at daemon startup",
|
|
345
|
+
rationale: "Write the MCP entry before the SDK client starts so new sessions see it immediately; issue #33, PR #78.",
|
|
346
|
+
decided_at: "2026-05-08",
|
|
347
|
+
tier: "warm",
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
title: "Use per-session orchestrators instead of a global queue",
|
|
351
|
+
rationale: "`SessionManager` and `SessionRegistry` keep independent queues so one chat cannot block another; issue #74, `src/copilot/session-manager.ts`.",
|
|
352
|
+
decided_at: "2026-05-08",
|
|
353
|
+
tier: "hot",
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
title: "Make wiki path hierarchy the primary navigation model",
|
|
357
|
+
rationale: "`path` drives the tree and breadcrumbs, while `section` remains secondary metadata; `web/src/wiki/index.ts`, `web/src/routes/Wiki.tsx`.",
|
|
358
|
+
decided_at: "2026-05-06",
|
|
359
|
+
tier: "warm",
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
title: "Route wiki breadcrumbs through `selected` state",
|
|
363
|
+
rationale: "Breadcrumb clicks use the same route-state flow as the sidebar so navigation stays predictable; `web/src/components/wiki/WikiBreadcrumbs.tsx`.",
|
|
364
|
+
decided_at: "2026-05-06",
|
|
365
|
+
tier: "warm",
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
title: "Derive wiki page scope from configured team paths",
|
|
369
|
+
rationale: "The API returns `scope` and the UI shows personal/team indicators without adding new persisted metadata; `src/api/server.ts`, `web/src/components/wiki/WikiScopeIndicator.tsx`.",
|
|
370
|
+
decided_at: "2026-05-06",
|
|
371
|
+
tier: "warm",
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
title: "Sort sidebar recents by actual project activity",
|
|
375
|
+
rationale: "`last_used_at` replaces registration time so recent projects reflect chat usage rather than signup order; issue #26, PR #30.",
|
|
376
|
+
decided_at: "2026-05-08",
|
|
377
|
+
tier: "warm",
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
title: "Preload `CHAPTERHOUSE_DISABLE_DOTENV=1` for Node tests",
|
|
381
|
+
rationale: "Tests must not inherit developer-local env files before config singletons initialize; `src/test/setup-env.ts`, `src/config.ts`.",
|
|
382
|
+
decided_at: "2026-05-06",
|
|
383
|
+
tier: "warm",
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
title: "Test API routes through a spawned server process",
|
|
387
|
+
rationale: "Real HTTP coverage catches auth, config preload, error middleware, and wiki I/O behavior that an unexported in-process app would miss; `src/api/server.test.ts`.",
|
|
388
|
+
decided_at: "2026-05-06",
|
|
389
|
+
tier: "warm",
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
title: "Centralize frontend API Zod schemas in `web/src/api-schemas.ts`",
|
|
393
|
+
rationale: "Keep one audit surface for the browser-to-daemon contract and require validated JSON reads; issue #40, PR #63.",
|
|
394
|
+
decided_at: "2026-05-08",
|
|
395
|
+
tier: "warm",
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
title: "Make frontend test isolation an infrastructure concern",
|
|
399
|
+
rationale: "Vitest globals, `test-setup.ts`, and MSW own cleanup so tests do not hand-roll it; issue #43, PR #62.",
|
|
400
|
+
decided_at: "2026-05-08",
|
|
401
|
+
tier: "warm",
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
title: "Clean `dist/` before `npm test`",
|
|
405
|
+
rationale: "Remove stale compiled artifacts so deleted tests cannot linger in the Node test run; `package.json`.",
|
|
406
|
+
decided_at: "2026-05-08",
|
|
407
|
+
tier: "warm",
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
title: "Adopt `pino` as the backend logger",
|
|
411
|
+
rationale: "Use `childLogger()` everywhere and keep chat content at `debug`, not `info`; issue #13, PR #28.",
|
|
412
|
+
decided_at: "2026-05-08",
|
|
413
|
+
tier: "warm",
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
title: "Keep daemon control as a thin CLI over `src/daemon-install.ts`",
|
|
417
|
+
rationale: "Generate service artifacts in one module and support launchd and systemd user services without a separate control layer; issue #14, PR #24.",
|
|
418
|
+
decided_at: "2026-05-08",
|
|
419
|
+
tier: "warm",
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
title: "Default publish workflows to Node 24+",
|
|
423
|
+
rationale: "npm Trusted Publishing depends on npm 11.5.1+, which Node 22 does not provide; `.github/workflows/npm-publish.yml`.",
|
|
424
|
+
decided_at: "2026-05-08",
|
|
425
|
+
tier: "warm",
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
title: "Make `chapterhouse update` registry-aware",
|
|
429
|
+
rationale: "Registry installs update via npm, while legacy git installs keep the older pull-and-rebuild path; issue #31, PR #32.",
|
|
430
|
+
decided_at: "2026-05-08",
|
|
431
|
+
tier: "warm",
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
title: "Keep markdownlint pragmatic rather than maximalist",
|
|
435
|
+
rationale: "Disable MD060, ignore `.github/agents/**`, and suppress MD041 only where template UX requires it; `.markdownlint-cli2.jsonc`.",
|
|
436
|
+
decided_at: "2026-05-08",
|
|
437
|
+
tier: "warm",
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
title: "Enforce issue-closing references in feature and fix PRs",
|
|
441
|
+
rationale: "PR bodies must include `Closes #N`, `Fixes #N`, or `Resolves #N`, with a documented `no-issue` bypass; PR #60, `.github/workflows/lint-pr-closes.yml`.",
|
|
442
|
+
decided_at: "2026-05-08",
|
|
443
|
+
tier: "warm",
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
title: "Use Conventional Commits for commits and PR titles",
|
|
447
|
+
rationale: "Keep repo history machine-readable and enforce it with commitlint, husky, and PR-title linting; `commitlint.config.js`, `.github/workflows/lint-pr-title.yml`.",
|
|
448
|
+
decided_at: "2026-05-08",
|
|
449
|
+
tier: "warm",
|
|
450
|
+
},
|
|
451
|
+
];
|
|
452
|
+
let db = dbModule.getDb();
|
|
453
|
+
dbModule.closeDb();
|
|
454
|
+
db = dbModule.getDb();
|
|
455
|
+
const chapterhouseScope = db.prepare(`
|
|
456
|
+
SELECT id
|
|
457
|
+
FROM mem_scopes
|
|
458
|
+
WHERE slug = 'chapterhouse'
|
|
459
|
+
`).get();
|
|
460
|
+
assert.ok(chapterhouseScope, "chapterhouse scope should be seeded");
|
|
461
|
+
const chapterhouseEntity = db.prepare(`
|
|
462
|
+
SELECT id
|
|
463
|
+
FROM mem_entities
|
|
464
|
+
WHERE scope_id = ? AND kind = 'project' AND name = 'Chapterhouse'
|
|
465
|
+
`).get(chapterhouseScope.id);
|
|
466
|
+
assert.ok(chapterhouseEntity, "Chapterhouse project entity should be seeded");
|
|
467
|
+
const rows = db.prepare(`
|
|
468
|
+
SELECT title, rationale, decided_at, tier, entity_id, tier_pinned_at, tier_reason, superseded_by
|
|
469
|
+
FROM mem_decisions
|
|
470
|
+
WHERE scope_id = ?
|
|
471
|
+
ORDER BY decided_at, title
|
|
472
|
+
`).all(chapterhouseScope.id);
|
|
473
|
+
assert.equal(rows.length, 24, "PR2 should seed exactly 24 decisions from decisions.md");
|
|
474
|
+
assert.equal(expectedDecisions.length, 24, "test fixture should reflect the wiki source decision count");
|
|
475
|
+
for (const expected of expectedDecisions) {
|
|
476
|
+
const row = rows.find((entry) => entry.title === expected.title);
|
|
477
|
+
assert.ok(row, `missing seeded decision: ${expected.title}`);
|
|
478
|
+
assert.equal(row.rationale, expected.rationale);
|
|
479
|
+
assert.equal(row.decided_at, expected.decided_at);
|
|
480
|
+
assert.equal(row.tier, expected.tier);
|
|
481
|
+
assert.equal(row.entity_id, chapterhouseEntity.id, "all PR2 decisions should attach to the Chapterhouse entity");
|
|
482
|
+
assert.equal(row.superseded_by, null, "source has no explicit superseded-by markers");
|
|
483
|
+
if (expected.tier === "hot") {
|
|
484
|
+
assert.ok(row.tier_pinned_at, "hot PR2 decisions should be manually pinned");
|
|
485
|
+
assert.equal(row.tier_reason, "P6 PR2 wiki migration hot-tier candidate");
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
assert.equal(row.tier_pinned_at, null);
|
|
489
|
+
assert.equal(row.tier_reason, null);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const duplicates = db.prepare(`
|
|
493
|
+
SELECT title, COUNT(*) AS count
|
|
494
|
+
FROM mem_decisions
|
|
495
|
+
WHERE scope_id = ?
|
|
496
|
+
GROUP BY title
|
|
497
|
+
HAVING COUNT(*) > 1
|
|
498
|
+
`).all(chapterhouseScope.id);
|
|
499
|
+
assert.deepEqual(duplicates, [], "re-running getDb must not duplicate seeded decisions");
|
|
500
|
+
const supersededCount = db.prepare(`
|
|
501
|
+
SELECT COUNT(*) AS count
|
|
502
|
+
FROM mem_decisions
|
|
503
|
+
WHERE scope_id = ? AND superseded_by IS NOT NULL
|
|
504
|
+
`).get(chapterhouseScope.id).count;
|
|
505
|
+
assert.equal(supersededCount, 0, "source has no explicit superseded-by markers to port");
|
|
506
|
+
}
|
|
507
|
+
finally {
|
|
508
|
+
dbModule.closeDb();
|
|
509
|
+
}
|
|
510
|
+
});
|
|
203
511
|
test("getDb prunes oversized conversation logs on startup and during inserts", async () => {
|
|
204
512
|
const seedDb = new Database(dbPath);
|
|
205
513
|
seedDb.exec(`
|
|
@@ -255,8 +563,8 @@ test("getSessionMessages returns structured messages in chronological order, inc
|
|
|
255
563
|
dbModule.logConversation("user", "hello", "web", "test-session");
|
|
256
564
|
dbModule.logConversation("assistant", "hi there", "web", "test-session");
|
|
257
565
|
dbModule.logConversation("system", "system noise", "worker", "test-session");
|
|
258
|
-
db.prepare(`INSERT INTO conversation_log (role, content, source, session_key)
|
|
259
|
-
VALUES ('agent_completion', ?, 'background', 'test-session')`).run("[Agent task completed] @coder finished task task-1:\n\nDone");
|
|
566
|
+
db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, run_id)
|
|
567
|
+
VALUES ('agent_completion', ?, 'background', 'test-session', ?)`).run("[Agent task completed] @coder finished task task-1:\n\nDone", dbModule.getCurrentRunId());
|
|
260
568
|
dbModule.logConversation("user", "second message", "web", "test-session");
|
|
261
569
|
dbModule.logConversation("user", "from other session", "web", "other-session");
|
|
262
570
|
const all = dbModule.getSessionMessages("test-session");
|
|
@@ -284,6 +592,84 @@ test("getSessionMessages returns structured messages in chronological order, inc
|
|
|
284
592
|
dbModule.closeDb();
|
|
285
593
|
}
|
|
286
594
|
});
|
|
595
|
+
test("getSessionMessages returns stable row id and turn_id for hydration reconciliation", async () => {
|
|
596
|
+
const dbModule = await loadDbModule();
|
|
597
|
+
try {
|
|
598
|
+
const db = dbModule.getDb();
|
|
599
|
+
const columns = db.prepare(`PRAGMA table_info(conversation_log)`).all();
|
|
600
|
+
assert.equal(columns.some((column) => column.name === "turn_id"), true, "conversation_log should persist turn_id");
|
|
601
|
+
const result = db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, turn_id, run_id)
|
|
602
|
+
VALUES ('assistant', 'hydrated answer', 'web', 'stable-session', 'turn-stable-1', ?)`).run(dbModule.getCurrentRunId());
|
|
603
|
+
const messages = dbModule.getSessionMessages("stable-session");
|
|
604
|
+
assert.equal(messages.length, 1);
|
|
605
|
+
const message = messages[0];
|
|
606
|
+
assert.equal(message.id, Number(result.lastInsertRowid));
|
|
607
|
+
assert.equal(message.turn_id, "turn-stable-1");
|
|
608
|
+
}
|
|
609
|
+
finally {
|
|
610
|
+
dbModule.closeDb();
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
test("getSessionMessages defaults to the current daemon run and can include historical rows", async () => {
|
|
614
|
+
const dbModule = await loadDbModule();
|
|
615
|
+
try {
|
|
616
|
+
const db = dbModule.getDb();
|
|
617
|
+
const columns = db.prepare(`PRAGMA table_info(conversation_log)`).all();
|
|
618
|
+
if (!columns.some((column) => column.name === "run_id")) {
|
|
619
|
+
db.exec(`ALTER TABLE conversation_log ADD COLUMN run_id TEXT`);
|
|
620
|
+
}
|
|
621
|
+
db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, run_id)
|
|
622
|
+
VALUES ('user', ?, 'web', 'run-session', ?)`).run("previous run", "previous-run");
|
|
623
|
+
db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, run_id)
|
|
624
|
+
VALUES ('assistant', ?, 'web', 'run-session', ?)`).run("current run", "current-run");
|
|
625
|
+
const getSessionMessages = dbModule.getSessionMessages;
|
|
626
|
+
assert.deepEqual(getSessionMessages("run-session", undefined, { runId: "current-run" }).map((message) => message.content), ["current run"]);
|
|
627
|
+
assert.deepEqual(getSessionMessages("run-session", undefined, { runId: "current-run", includeHistorical: true }).map((message) => message.content), ["previous run", "current run"]);
|
|
628
|
+
}
|
|
629
|
+
finally {
|
|
630
|
+
dbModule.closeDb();
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
test("getDb migrates legacy hydration rows with NULL run_id and excludes them by default", async () => {
|
|
634
|
+
const seedDb = new Database(dbPath);
|
|
635
|
+
seedDb.exec(`
|
|
636
|
+
CREATE TABLE conversation_log (
|
|
637
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
638
|
+
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'agent_completion')),
|
|
639
|
+
content TEXT NOT NULL,
|
|
640
|
+
source TEXT NOT NULL DEFAULT 'unknown',
|
|
641
|
+
session_key TEXT NOT NULL DEFAULT 'default',
|
|
642
|
+
ts DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
643
|
+
);
|
|
644
|
+
INSERT INTO conversation_log (role, content, source, session_key)
|
|
645
|
+
VALUES ('user', 'legacy row', 'web', 'legacy-session');
|
|
646
|
+
CREATE TABLE turn_events (
|
|
647
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
648
|
+
turn_id TEXT NOT NULL,
|
|
649
|
+
session_key TEXT NOT NULL DEFAULT 'default',
|
|
650
|
+
seq INTEGER NOT NULL,
|
|
651
|
+
ts INTEGER NOT NULL,
|
|
652
|
+
event_type TEXT NOT NULL,
|
|
653
|
+
payload TEXT NOT NULL
|
|
654
|
+
);
|
|
655
|
+
INSERT INTO turn_events (turn_id, session_key, seq, ts, event_type, payload)
|
|
656
|
+
VALUES ('legacy-turn', 'legacy-session', 1, 1, 'turn:complete', '{"type":"turn:complete","turnId":"legacy-turn","sessionKey":"legacy-session","finalMessage":"legacy","_seq":1,"_ts":1}');
|
|
657
|
+
`);
|
|
658
|
+
seedDb.close();
|
|
659
|
+
const dbModule = await loadDbModule();
|
|
660
|
+
try {
|
|
661
|
+
const db = dbModule.getDb();
|
|
662
|
+
const columns = db.prepare(`PRAGMA table_info(conversation_log)`).all();
|
|
663
|
+
assert.ok(columns.some((column) => column.name === "run_id"), "conversation_log.run_id column must be added");
|
|
664
|
+
const turnColumns = db.prepare(`PRAGMA table_info(turn_events)`).all();
|
|
665
|
+
assert.ok(turnColumns.some((column) => column.name === "run_id"), "turn_events.run_id column must be added");
|
|
666
|
+
dbModule.logConversation("assistant", "current row", "web", "legacy-session");
|
|
667
|
+
assert.deepEqual(dbModule.getSessionMessages("legacy-session").map((message) => message.content), ["current row"]);
|
|
668
|
+
}
|
|
669
|
+
finally {
|
|
670
|
+
dbModule.closeDb();
|
|
671
|
+
}
|
|
672
|
+
});
|
|
287
673
|
// ---------------------------------------------------------------------------
|
|
288
674
|
// #86: agent_task_events — appendTaskEvent and getTaskEvents
|
|
289
675
|
// ---------------------------------------------------------------------------
|
|
@@ -441,8 +827,8 @@ test("getSessionMessages returns ts values normalized to ISO-8601 UTC (ends with
|
|
|
441
827
|
try {
|
|
442
828
|
const db = dbModule.getDb();
|
|
443
829
|
// Insert a row with a bare SQLite-format timestamp (no T, no Z)
|
|
444
|
-
db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, ts)
|
|
445
|
-
VALUES ('user', 'timezone test', 'web', 'tz-session', '2026-05-09 01:23:45')`).run();
|
|
830
|
+
db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, run_id, ts)
|
|
831
|
+
VALUES ('user', 'timezone test', 'web', 'tz-session', ?, '2026-05-09 01:23:45')`).run(dbModule.getCurrentRunId());
|
|
446
832
|
const messages = dbModule.getSessionMessages("tz-session");
|
|
447
833
|
assert.equal(messages.length, 1, "should return the inserted row");
|
|
448
834
|
assert.equal(messages[0].ts, "2026-05-09T01:23:45Z", "ts must be normalized to ISO-8601 UTC");
|
package/package.json
CHANGED