chapterhouse 0.3.11 → 0.3.13

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.
@@ -469,7 +469,7 @@ app.post("/api/message", (req, res) => {
469
469
  event.route = {
470
470
  model: routeResult.model,
471
471
  routerMode: routeResult.routerMode,
472
- tier: routeResult.tier,
472
+ ...(routeResult.tier !== null ? { tier: routeResult.tier } : {}),
473
473
  ...(routeResult.overrideName ? { overrideName: routeResult.overrideName } : {}),
474
474
  };
475
475
  }
@@ -547,7 +547,7 @@ app.post("/api/sessions/:sessionKey/interrupt", (req, res) => {
547
547
  event.route = {
548
548
  model: routeResult.model,
549
549
  routerMode: routeResult.routerMode,
550
- tier: routeResult.tier,
550
+ ...(routeResult.tier !== null ? { tier: routeResult.tier } : {}),
551
551
  ...(routeResult.overrideName ? { overrideName: routeResult.overrideName } : {}),
552
552
  };
553
553
  }
@@ -644,6 +644,7 @@ if (config.chatSseEnabled) {
644
644
  };
645
645
  // If Last-Event-ID is present and the session ring buffer doesn't cover it,
646
646
  // fall back to SQLite for replay of completed turns.
647
+ let replayHighSeq = lastSeq;
647
648
  if (lastSeq !== undefined) {
648
649
  const oldestBuf = oldestSessionSeq(sessionKey);
649
650
  const bufferMissesRange = oldestBuf === undefined || oldestBuf > lastSeq + 1;
@@ -652,13 +653,17 @@ if (config.chatSseEnabled) {
652
653
  const dbEvents = getSessionEventsFromDb(sessionKey, lastSeq);
653
654
  for (const e of dbEvents) {
654
655
  sendEvent(e, e._seq);
656
+ if (replayHighSeq === undefined || e._seq > replayHighSeq)
657
+ replayHighSeq = e._seq;
655
658
  }
656
659
  }
657
660
  }
658
- // Subscribe to session events (replays ring buffer for afterSeq, then live)
661
+ // Subscribe to session events (replays ring buffer for afterSeq, then live).
662
+ // Use replayHighSeq (not lastSeq) so ring-buffer replay starts after any DB
663
+ // events we already sent — avoids double-replay overlap (Fix 5).
659
664
  const unsub = subscribeSession(sessionKey, (e) => {
660
665
  sendEvent(e, e._seq);
661
- }, lastSeq);
666
+ }, replayHighSeq);
662
667
  // Send connected event
663
668
  res.write(`: connected session=${sessionKey}\n\n`);
664
669
  // Keep-alive every 15 s
@@ -349,4 +349,40 @@ test("turn-sse: reconnect with Last-Event-ID replays buffered events", async ()
349
349
  }
350
350
  }, { CHAPTERHOUSE_CHAT_SSE: "1" }, 15_000);
351
351
  });
352
+ // ---------------------------------------------------------------------------
353
+ // Regression test: turn ID contract (Fix 1 — root cause of blank SSE bubbles)
354
+ // ---------------------------------------------------------------------------
355
+ test("turn-sse: turnId returned by POST matches turnId in all SSE events for that turn", async () => {
356
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
357
+ const sessionKey = "test-session-turnid-contract";
358
+ // Open SSE stream first
359
+ const sseRes = await fetch(`${baseUrl}/api/sessions/${sessionKey}/stream`, {
360
+ headers: { Authorization: authHeader, Accept: "text/event-stream" },
361
+ });
362
+ assert.ok(sseRes.body, "SSE body must be readable");
363
+ const reader = createSseReader(sseRes.body);
364
+ // POST the turn — this is the canonical turnId the frontend tags user messages with
365
+ const turnRes = await fetch(`${baseUrl}/api/sessions/${sessionKey}/turn`, {
366
+ method: "POST",
367
+ headers: { Authorization: authHeader, "Content-Type": "application/json" },
368
+ body: JSON.stringify({ prompt: "turn-id-contract test" }),
369
+ });
370
+ assert.equal(turnRes.status, 200, `POST /turn returned ${turnRes.status}`);
371
+ const bodyText = await turnRes.text();
372
+ const { turnId: postedTurnId } = JSON.parse(bodyText);
373
+ assert.ok(typeof postedTurnId === "string" && postedTurnId.length > 0, "POST must return a non-empty turnId");
374
+ // Read all events for up to 3 s (no real Copilot token — only turn:started and
375
+ // possibly turn:error will arrive; that is sufficient to verify ID plumbing).
376
+ const frames = await reader.readFrames(3, 3_000);
377
+ await reader.cancel();
378
+ assert.ok(frames.length >= 1, `Expected at least 1 SSE frame, got ${frames.length}`);
379
+ // THE CONTRACT: every turn:* event must carry the same turnId the POST returned.
380
+ for (const frame of frames) {
381
+ const data = frame.data;
382
+ if (typeof data?.type === "string" && data.type.startsWith("turn:")) {
383
+ assert.equal(data.turnId, postedTurnId, `Event type=${data.type} has turnId=${String(data.turnId)} but POST returned turnId=${postedTurnId}`);
384
+ }
385
+ }
386
+ }, { CHAPTERHOUSE_CHAT_SSE: "1" }, 15_000);
387
+ });
352
388
  //# sourceMappingURL=turn-sse.integration.test.js.map
@@ -152,7 +152,7 @@ function sameUserContext(a, b) {
152
152
  return a?.name === b?.name && a?.role === b?.role;
153
153
  }
154
154
  function updateUserContext(source) {
155
- if (source.type !== "web")
155
+ if (source.type !== "web" && source.type !== "sse-web")
156
156
  return;
157
157
  const nextContext = source.user
158
158
  ? { name: source.user.name, role: source.user.role }
@@ -164,7 +164,7 @@ function updateUserContext(source) {
164
164
  registry?.get("default")?.invalidateSession();
165
165
  }
166
166
  function updateRequestContext(source) {
167
- if (source.type !== "web") {
167
+ if (source.type !== "web" && source.type !== "sse-web") {
168
168
  currentAuthenticatedUser = undefined;
169
169
  currentAuthorizationHeader = undefined;
170
170
  return;
@@ -749,14 +749,13 @@ function isRecoverableError(err) {
749
749
  return false;
750
750
  return /disconnect|connection|EPIPE|ECONNRESET|ECONNREFUSED|socket|closed|ENOENT|spawn|not found|expired|stale/i.test(msg);
751
751
  }
752
- export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance) {
752
+ export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance, externalTurnId) {
753
753
  updateUserContext(source);
754
754
  updateRequestContext(source);
755
- // Generate a unique ID for this orchestrator turn. Every SSE event emitted
756
- // during this turn carries this ID so the frontend can detect turn boundaries
757
- // and create a new assistant bubble when it changes (fixes #92).
758
- const turnId = randomUUID();
759
- const sourceLabel = source.type === "web" ? "web" : "background";
755
+ // Use the externally-supplied turnId if provided (POST→SSE path needs the ID
756
+ // returned to the client to match every emitted event Fix 1 root cause).
757
+ const turnId = externalTurnId ?? randomUUID();
758
+ const sourceLabel = source.type === "background" ? "background" : "web";
760
759
  logMessage("in", sourceLabel, prompt);
761
760
  let sessionKey;
762
761
  if (source.type === "web" && source.projectPath && config.squadEnabled) {
@@ -764,7 +763,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
764
763
  setChannelProject(source.connectionId, normalizeProjectPath(source.projectPath));
765
764
  bumpProjectLastUsed(normalizeProjectPath(source.projectPath));
766
765
  }
767
- else if (source.type === "background" && source.sessionKey) {
766
+ else if ((source.type === "background" || source.type === "sse-web") && source.sessionKey) {
768
767
  sessionKey = source.sessionKey;
769
768
  }
770
769
  else {
@@ -782,8 +781,9 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
782
781
  const sourceChannel = source.type === "web" ? "web" : undefined;
783
782
  // Capture auth context at enqueue time — prevents cross-session contamination
784
783
  // when concurrent sessions are processing simultaneously.
785
- const authUser = source.type === "web" ? source.user : undefined;
786
- const authHeader = source.type === "web" ? source.authorizationHeader?.trim() || undefined : undefined;
784
+ // sse-web carries user identity just like web (Fix 3).
785
+ const authUser = (source.type === "web" || source.type === "sse-web") ? source.user : undefined;
786
+ const authHeader = (source.type === "web" || source.type === "sse-web") ? source.authorizationHeader?.trim() || undefined : undefined;
787
787
  const manager = registry.getOrCreate(sessionKey);
788
788
  void (async () => {
789
789
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
@@ -797,9 +797,10 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
797
797
  // type dependencies. orchestrator.ts always passes valid ActivityEvent objects.
798
798
  onActivity: onActivity,
799
799
  turnId,
800
- // Background messages skip queue visibility — only web/user messages need it.
801
- onQueued: source.type === "web" ? onQueued : undefined,
802
- onAdvance: source.type === "web" ? onAdvance : undefined,
800
+ // Background messages skip queue visibility — only user-initiated messages need it.
801
+ // sse-web is user-initiated (Fix 2).
802
+ onQueued: source.type !== "background" ? onQueued : undefined,
803
+ onAdvance: source.type !== "background" ? onAdvance : undefined,
803
804
  sourceChannel,
804
805
  targetAgent,
805
806
  channelKey,
@@ -864,19 +865,19 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
864
865
  * replacement turn starts. Use to emit a `turn-interrupted` SSE event so the
865
866
  * frontend can drop the partial in-flight bubble.
866
867
  */
867
- export async function interruptCurrentTurn(sessionKey, newPrompt, source, callback, attachments, onActivity, onInterrupted) {
868
+ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callback, attachments, onActivity, onInterrupted, externalTurnId) {
868
869
  const manager = registry?.get(sessionKey);
869
870
  // If no session exists or it isn't processing, fall back to a normal send.
870
871
  if (!manager || !manager.isProcessing) {
871
- return sendToOrchestrator(newPrompt, source, callback, attachments, onActivity);
872
+ return sendToOrchestrator(newPrompt, source, callback, attachments, onActivity, undefined, undefined, externalTurnId);
872
873
  }
873
874
  updateUserContext(source);
874
875
  updateRequestContext(source);
875
- const turnId = randomUUID();
876
- const sourceLabel = source.type === "web" ? "web" : "background";
876
+ const turnId = externalTurnId ?? randomUUID();
877
+ const sourceLabel = source.type === "background" ? "background" : "web";
877
878
  const sourceChannel = source.type === "web" ? "web" : undefined;
878
- const authUser = source.type === "web" ? source.user : undefined;
879
- const authHeader = source.type === "web" ? source.authorizationHeader?.trim() || undefined : undefined;
879
+ const authUser = (source.type === "web" || source.type === "sse-web") ? source.user : undefined;
880
+ const authHeader = (source.type === "web" || source.type === "sse-web") ? source.authorizationHeader?.trim() || undefined : undefined;
880
881
  const taggedPrompt = source.type === "background"
881
882
  ? newPrompt
882
883
  : `[via ${sourceLabel}] ${newPrompt}`;
@@ -965,8 +966,8 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
965
966
  export function enqueueForSse(opts) {
966
967
  const { sessionKey, prompt, attachments, authUser, authHeader, interrupt } = opts;
967
968
  const turnId = randomUUID();
968
- const source = { type: "background", sessionKey };
969
- const taggedPrompt = `[via sse] ${prompt}`;
969
+ // sse-web carries auth and enables onQueued — unlike "background" (Fixes 2 & 3).
970
+ const source = { type: "sse-web", sessionKey, user: authUser, authorizationHeader: authHeader };
970
971
  // Emit turn:started immediately so the SSE client sees it before any delta
971
972
  emitTurnEvent(sessionKey, { type: "turn:started", turnId, sessionKey, prompt, attachments });
972
973
  const callback = (text, done, tid) => {
@@ -986,10 +987,12 @@ export function enqueueForSse(opts) {
986
987
  scheduleClearTurnLog(abortedTurnId);
987
988
  };
988
989
  if (interrupt) {
989
- void interruptCurrentTurn(sessionKey, taggedPrompt, source, callback, attachments, undefined, onInterrupted);
990
+ // Pass the outer turnId so the replacement turn uses the same ID (Fix 1).
991
+ void interruptCurrentTurn(sessionKey, prompt, source, callback, attachments, undefined, onInterrupted, turnId);
990
992
  }
991
993
  else {
992
- void sendToOrchestrator(taggedPrompt, source, callback, attachments, undefined, onQueued);
994
+ // Pass the outer turnId so sendToOrchestrator uses it instead of generating a new one (Fix 1).
995
+ void sendToOrchestrator(prompt, source, callback, attachments, undefined, onQueued, undefined, turnId);
993
996
  }
994
997
  return turnId;
995
998
  }
@@ -998,15 +1001,26 @@ export async function cancelCurrentMessage() {
998
1001
  if (!registry)
999
1002
  return false;
1000
1003
  let drained = 0;
1004
+ // Capture (sessionKey, turnId) before aborting so we can emit terminal events.
1001
1005
  const aborts = [];
1002
- for (const [, manager] of registry.getAll()) {
1006
+ for (const [sessionKey, manager] of registry.getAll()) {
1003
1007
  drained += manager.cancelQueued();
1004
1008
  if (manager.isProcessing) {
1005
- aborts.push(manager.abortCurrentTurn());
1009
+ const turnId = manager.currentTurnId;
1010
+ aborts.push({ promise: manager.abortCurrentTurn(), sessionKey, turnId });
1006
1011
  }
1007
1012
  }
1008
- const results = await Promise.all(aborts);
1013
+ const results = await Promise.all(aborts.map((a) => a.promise));
1009
1014
  const aborted = results.some(Boolean);
1015
+ // Emit turn:interrupted on per-session SSE streams for any turn that was aborted (Fix 4).
1016
+ for (let i = 0; i < aborts.length; i++) {
1017
+ if (results[i] && aborts[i].turnId) {
1018
+ const { sessionKey, turnId } = aborts[i];
1019
+ emitTurnEvent(sessionKey, { type: "turn:interrupted", turnId: turnId, sessionKey });
1020
+ persistTurnEvents(turnId, sessionKey);
1021
+ scheduleClearTurnLog(turnId);
1022
+ }
1023
+ }
1010
1024
  return aborted || drained > 0;
1011
1025
  }
1012
1026
  /** Switch the model on the live default orchestrator session without destroying it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
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"