chapterhouse 0.3.19 → 0.3.20

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.
@@ -33,6 +33,19 @@ let proactiveNotifyFn;
33
33
  export function setProactiveNotify(fn) {
34
34
  proactiveNotifyFn = fn;
35
35
  }
36
+ function usesSessionTurnLifecycle(source) {
37
+ return source.type === "background" || source.type === "sse-web";
38
+ }
39
+ function finalizeTurnEvent(sessionKey, event) {
40
+ if (event.type === "turn:complete") {
41
+ emitTurnEvent(sessionKey, { type: "turn:complete", turnId: event.turnId, sessionKey, finalMessage: event.finalMessage });
42
+ }
43
+ else {
44
+ emitTurnEvent(sessionKey, { type: "turn:error", turnId: event.turnId, sessionKey, error: event.error });
45
+ }
46
+ persistTurnEvents(event.turnId, sessionKey);
47
+ scheduleClearTurnLog(event.turnId);
48
+ }
36
49
  const turnContextStorage = new AsyncLocalStorage();
37
50
  // ---------------------------------------------------------------------------
38
51
  // Module-level state (not per-session)
@@ -765,6 +778,10 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
765
778
  // sse-web carries user identity just like web (Fix 3).
766
779
  const authUser = (source.type === "web" || source.type === "sse-web") ? source.user : undefined;
767
780
  const authHeader = (source.type === "web" || source.type === "sse-web") ? source.authorizationHeader?.trim() || undefined : undefined;
781
+ const emitSessionLifecycle = usesSessionTurnLifecycle(source);
782
+ if (emitSessionLifecycle) {
783
+ emitTurnEvent(sessionKey, { type: "turn:started", turnId, sessionKey, prompt, attachments });
784
+ }
768
785
  const manager = registry.getOrCreate(sessionKey);
769
786
  void (async () => {
770
787
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
@@ -793,6 +810,9 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
793
810
  });
794
811
  });
795
812
  callback(finalContent, true, turnId);
813
+ if (emitSessionLifecycle) {
814
+ finalizeTurnEvent(sessionKey, { type: "turn:complete", turnId, finalMessage: finalContent });
815
+ }
796
816
  try {
797
817
  logMessage("out", sourceLabel, finalContent);
798
818
  }
@@ -829,6 +849,9 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
829
849
  }
830
850
  log.error({ msg }, "Error processing message");
831
851
  callback(`Error: ${msg}`, true, turnId);
852
+ if (emitSessionLifecycle) {
853
+ finalizeTurnEvent(sessionKey, { type: "turn:error", turnId, error: msg });
854
+ }
832
855
  return;
833
856
  }
834
857
  }
@@ -938,7 +961,7 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
938
961
  *
939
962
  * Unlike `sendToOrchestrator`, this function:
940
963
  * - Returns the `turnId` immediately without waiting for the turn to complete.
941
- * - Emits turn:started, turn:complete, and turn:error events to the turn event log.
964
+ * - Routes through the shared lifecycle emitter in sendToOrchestrator.
942
965
  * - Does NOT write to sseClients — the SSE channel delivers events via subscribeSession().
943
966
  * - Supports interrupt: true which calls interruptCurrentTurn under the hood.
944
967
  *
@@ -949,15 +972,9 @@ export function enqueueForSse(opts) {
949
972
  const turnId = randomUUID();
950
973
  // sse-web carries auth and enables onQueued — unlike "background" (Fixes 2 & 3).
951
974
  const source = { type: "sse-web", sessionKey, user: authUser, authorizationHeader: authHeader };
952
- // Emit turn:started immediately so the SSE client sees it before any delta
953
- emitTurnEvent(sessionKey, { type: "turn:started", turnId, sessionKey, prompt, attachments });
954
- const callback = (text, done, tid) => {
955
- if (done) {
956
- emitTurnEvent(sessionKey, { type: "turn:complete", turnId: tid, sessionKey, finalMessage: text });
957
- persistTurnEvents(tid, sessionKey);
958
- scheduleClearTurnLog(tid);
959
- }
960
- // Note: mid-turn text deltas are emitted by executeOnSession's delta handler
975
+ const callback = (_text, _done, _tid) => {
976
+ // Note: sendToOrchestrator now owns turn:started/turn:complete/turn:error emission.
977
+ // Mid-turn text deltas are still emitted by executeOnSession's delta handler.
961
978
  };
962
979
  const onQueued = (position, tid) => {
963
980
  emitTurnEvent(sessionKey, { type: "turn:queued", turnId: tid, sessionKey, position });
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { clearTurnLog, subscribeSession } from "./turn-event-log.js";
3
4
  function createFakeClient(state) {
4
5
  class FakeSession {
5
6
  sessionId = "session-123";
@@ -25,6 +26,9 @@ function createFakeClient(state) {
25
26
  state.pendingReject = reject;
26
27
  });
27
28
  }
29
+ if (state.sendErrorMessage) {
30
+ throw new Error(state.sendErrorMessage);
31
+ }
28
32
  return { data: { content: state.sendResult } };
29
33
  }
30
34
  async setModel(model) {
@@ -245,6 +249,21 @@ async function loadOrchestratorModule(t, overrides = {}) {
245
249
  const orchestrator = await import(new URL(`./orchestrator.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
246
250
  return { orchestrator, state, client };
247
251
  }
252
+ function captureSessionEvents(t, sessionKey) {
253
+ const events = [];
254
+ const seenTurnIds = new Set();
255
+ const unsubscribe = subscribeSession(sessionKey, (event) => {
256
+ events.push(event);
257
+ seenTurnIds.add(event.turnId);
258
+ });
259
+ t.after(() => {
260
+ unsubscribe();
261
+ for (const turnId of seenTurnIds) {
262
+ clearTurnLog(turnId);
263
+ }
264
+ });
265
+ return events;
266
+ }
248
267
  test("initOrchestrator falls back to an available model and eagerly creates a session", async (t) => {
249
268
  const { orchestrator, state, client } = await loadOrchestratorModule(t);
250
269
  await orchestrator.initOrchestrator(client);
@@ -340,8 +359,10 @@ test("feedAgentResult injects a background completion turn and proactively notif
340
359
  selfEditEnabled: true,
341
360
  },
342
361
  sendResult: "Agent complete",
362
+ taskSessionKeys: new Map([["task-9", "chat:bg-lifecycle"]]),
343
363
  });
344
364
  await orchestrator.initOrchestrator(client);
365
+ const events = captureSessionEvents(t, "chat:bg-lifecycle");
345
366
  const notified = new Promise((resolve) => {
346
367
  orchestrator.setProactiveNotify(resolve);
347
368
  });
@@ -362,6 +383,57 @@ test("feedAgentResult injects a background completion turn and proactively notif
362
383
  source: "background",
363
384
  },
364
385
  ]);
386
+ const started = events.filter((event) => event.type === "turn:started");
387
+ const completed = events.filter((event) => event.type === "turn:complete");
388
+ assert.equal(started.length, 1, "background turn should emit one turn:started event");
389
+ assert.equal(completed.length, 1, "background turn should emit one turn:complete event");
390
+ assert.equal(started[0]?.turnId, completed[0]?.turnId, "background lifecycle events must share the same turnId");
391
+ });
392
+ test("enqueueForSse emits exactly one turn lifecycle pair for sse-web turns", async (t) => {
393
+ const { orchestrator, client } = await loadOrchestratorModule(t, {
394
+ config: {
395
+ copilotModel: "claude-sonnet-4.6",
396
+ selfEditEnabled: true,
397
+ },
398
+ sendResult: "SSE complete",
399
+ });
400
+ await orchestrator.initOrchestrator(client);
401
+ const sessionKey = `chat:sse-lifecycle-${Date.now()}`;
402
+ const events = captureSessionEvents(t, sessionKey);
403
+ const turnId = orchestrator.enqueueForSse({ sessionKey, prompt: "hello from sse" });
404
+ await new Promise((resolve) => setTimeout(resolve, 20));
405
+ const turnEvents = events.filter((event) => event.turnId === turnId);
406
+ const started = turnEvents.filter((event) => event.type === "turn:started");
407
+ const completed = turnEvents.filter((event) => event.type === "turn:complete");
408
+ assert.equal(started.length, 1, "sse-web turn should emit turn:started exactly once");
409
+ assert.equal(completed.length, 1, "sse-web turn should emit turn:complete exactly once");
410
+ });
411
+ test("sendToOrchestrator emits turn:error instead of turn:complete on failures", async (t) => {
412
+ const { orchestrator, client } = await loadOrchestratorModule(t, {
413
+ config: {
414
+ copilotModel: "claude-sonnet-4.6",
415
+ selfEditEnabled: true,
416
+ },
417
+ sendErrorMessage: "session exploded",
418
+ sendResult: "unreachable",
419
+ });
420
+ await orchestrator.initOrchestrator(client);
421
+ const sessionKey = `chat:error-lifecycle-${Date.now()}`;
422
+ const events = captureSessionEvents(t, sessionKey);
423
+ const received = new Promise((resolve) => {
424
+ orchestrator.sendToOrchestrator("trigger an error", { type: "background", sessionKey }, (text, done, turnId) => {
425
+ if (done) {
426
+ resolve({ text, done, turnId });
427
+ }
428
+ });
429
+ });
430
+ const result = await received;
431
+ assert.match(result.text, /^Error:/);
432
+ const turnEvents = events.filter((event) => event.turnId === result.turnId);
433
+ const errors = turnEvents.filter((event) => event.type === "turn:error");
434
+ const completed = turnEvents.filter((event) => event.type === "turn:complete");
435
+ assert.equal(errors.length, 1, "failed turn should emit one turn:error event");
436
+ assert.equal(completed.length, 0, "failed turn must not emit turn:complete");
365
437
  });
366
438
  test("cancelCurrentMessage aborts the active request and agent helpers expose running work", async (t) => {
367
439
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
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"