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
|
-
* -
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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