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.
- package/dist/api/server.js +9 -4
- package/dist/api/turn-sse.integration.test.js +36 -0
- package/dist/copilot/orchestrator.js +41 -27
- package/package.json +1 -1
- package/web/dist/assets/{index-D92WYeM5.js → index-IgSOXx_a.js} +51 -51
- package/web/dist/assets/{index-D92WYeM5.js.map → index-IgSOXx_a.js.map} +1 -1
- package/web/dist/index.html +1 -1
package/dist/api/server.js
CHANGED
|
@@ -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
|
-
},
|
|
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
|
-
//
|
|
756
|
-
//
|
|
757
|
-
|
|
758
|
-
const
|
|
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
|
-
|
|
786
|
-
const
|
|
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
|
|
801
|
-
|
|
802
|
-
|
|
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 === "
|
|
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
|
-
|
|
969
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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