switchroom 0.13.0 → 0.13.1
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/cli/switchroom.js
CHANGED
|
@@ -47248,8 +47248,8 @@ var {
|
|
|
47248
47248
|
} = import__.default;
|
|
47249
47249
|
|
|
47250
47250
|
// src/build-info.ts
|
|
47251
|
-
var VERSION = "0.13.
|
|
47252
|
-
var COMMIT_SHA = "
|
|
47251
|
+
var VERSION = "0.13.1";
|
|
47252
|
+
var COMMIT_SHA = "b7489c5a";
|
|
47253
47253
|
|
|
47254
47254
|
// src/cli/agent.ts
|
|
47255
47255
|
init_source();
|
package/package.json
CHANGED
|
@@ -31221,6 +31221,7 @@ function createDraftStream(send, edit, config = {}) {
|
|
|
31221
31221
|
draftId = allocateDraftId();
|
|
31222
31222
|
currentChunkStartedAt = null;
|
|
31223
31223
|
persistChainFires++;
|
|
31224
|
+
sendFires++;
|
|
31224
31225
|
if (process.env.SWITCHROOM_STREAM_TRACES !== "0") {
|
|
31225
31226
|
process.stderr.write(`gw-trace stream-persist chunk_chars=${chunk.length} ` + `elapsed=${elapsed} reason=${timeElapsed ? "time" : "size"} ` + `newMsgId=${newMsgId} newDraftId=${draftId} ` + `chatId=${chatId || "-"}
|
|
31226
31227
|
`);
|
|
@@ -31367,6 +31368,7 @@ function createDraftStream(send, edit, config = {}) {
|
|
|
31367
31368
|
try {
|
|
31368
31369
|
messageId = await send(textToMaterialize);
|
|
31369
31370
|
persistedTextLen = fullText.length;
|
|
31371
|
+
sendFires++;
|
|
31370
31372
|
log?.(`stream \u2192 materialized tail (id: ${messageId}, ${textToMaterialize.length} chars)`);
|
|
31371
31373
|
} catch (err) {
|
|
31372
31374
|
warn?.(`draft-stream: materialize sendMessage failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -47677,10 +47679,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
47677
47679
|
}
|
|
47678
47680
|
|
|
47679
47681
|
// ../src/build-info.ts
|
|
47680
|
-
var VERSION = "0.13.
|
|
47681
|
-
var COMMIT_SHA = "
|
|
47682
|
-
var COMMIT_DATE = "2026-05-
|
|
47683
|
-
var LATEST_PR =
|
|
47682
|
+
var VERSION = "0.13.1";
|
|
47683
|
+
var COMMIT_SHA = "b7489c5a";
|
|
47684
|
+
var COMMIT_DATE = "2026-05-21T00:27:54Z";
|
|
47685
|
+
var LATEST_PR = 1609;
|
|
47684
47686
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
47685
47687
|
|
|
47686
47688
|
// gateway/boot-version.ts
|
|
@@ -48630,18 +48632,21 @@ function statusKey(chatId, threadId) {
|
|
|
48630
48632
|
function streamKey3(chatId, threadId) {
|
|
48631
48633
|
return chatKey(chatId, threadId);
|
|
48632
48634
|
}
|
|
48633
|
-
function
|
|
48634
|
-
const outboundEmitted = endingTurn != null ? endingTurn.replyCalled === true : currentTurn?.replyCalled === true;
|
|
48635
|
-
shadowEmit({ kind: "turnEnd", key, at: Date.now(), outboundEmitted });
|
|
48635
|
+
function clearReactionState(key) {
|
|
48636
48636
|
const msgInfo = activeReactionMsgIds.get(key);
|
|
48637
48637
|
activeStatusReactions.delete(key);
|
|
48638
48638
|
activeReactionMsgIds.delete(key);
|
|
48639
|
-
activeTurnStartedAt.delete(key);
|
|
48640
48639
|
if (msgInfo) {
|
|
48641
48640
|
const agentDir = resolveAgentDirFromEnv();
|
|
48642
48641
|
if (agentDir != null)
|
|
48643
48642
|
removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId);
|
|
48644
48643
|
}
|
|
48644
|
+
}
|
|
48645
|
+
function purgeReactionTracking(key, endingTurn, outboundEmittedOverride) {
|
|
48646
|
+
const outboundEmitted = outboundEmittedOverride !== undefined ? outboundEmittedOverride : endingTurn != null ? endingTurn.replyCalled === true : currentTurn?.replyCalled === true;
|
|
48647
|
+
shadowEmit({ kind: "turnEnd", key, at: Date.now(), outboundEmitted });
|
|
48648
|
+
clearReactionState(key);
|
|
48649
|
+
activeTurnStartedAt.delete(key);
|
|
48645
48650
|
if (activeTurnStartedAt.size === 0) {
|
|
48646
48651
|
const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
48647
48652
|
if (pendingInboundBuffer.depth(selfAgentForFlush) > 0) {
|
|
@@ -48791,7 +48796,7 @@ function endStatusReaction(chatId, threadId, outcome) {
|
|
|
48791
48796
|
ctrl.setDone();
|
|
48792
48797
|
else
|
|
48793
48798
|
ctrl.setError();
|
|
48794
|
-
|
|
48799
|
+
clearReactionState(key);
|
|
48795
48800
|
}
|
|
48796
48801
|
function resolveThreadId(chat_id, explicit) {
|
|
48797
48802
|
if (explicit != null)
|
|
@@ -49514,8 +49519,8 @@ startTimer({
|
|
|
49514
49519
|
lastPtyPreviewByChat.delete(fbKey);
|
|
49515
49520
|
preambleSuppressor.dropNow();
|
|
49516
49521
|
endTurn(fbKey);
|
|
49517
|
-
purgeReactionTracking(fbKey);
|
|
49518
|
-
const fbExtraPurge = purgeStaleTurnsForChat(fbChatId, activeTurnStartedAt.keys(), purgeReactionTracking);
|
|
49522
|
+
purgeReactionTracking(fbKey, undefined, false);
|
|
49523
|
+
const fbExtraPurge = purgeStaleTurnsForChat(fbChatId, activeTurnStartedAt.keys(), (k) => purgeReactionTracking(k, undefined, false));
|
|
49519
49524
|
if (turnMatchesFallback && currentTurn === wedgedTurn)
|
|
49520
49525
|
currentTurn = null;
|
|
49521
49526
|
try {
|
|
@@ -51337,7 +51342,6 @@ function handleSessionEvent(ev) {
|
|
|
51337
51342
|
const ctrl = activeStatusReactions.get(ceKey);
|
|
51338
51343
|
if (ctrl)
|
|
51339
51344
|
ctrl.setError();
|
|
51340
|
-
purgeReactionTracking(ceKey);
|
|
51341
51345
|
endTurn(ceKey);
|
|
51342
51346
|
if (turn.answerStream != null) {
|
|
51343
51347
|
turn.answerStream.stop();
|
|
@@ -51420,7 +51424,6 @@ function handleSessionEvent(ev) {
|
|
|
51420
51424
|
unpinProgressCardForChat?.(chatId, threadId);
|
|
51421
51425
|
if (ctrl)
|
|
51422
51426
|
ctrl.setDone();
|
|
51423
|
-
purgeReactionTracking(statusKey(chatId, threadId));
|
|
51424
51427
|
{
|
|
51425
51428
|
const sKey = streamKey3(chatId, threadId);
|
|
51426
51429
|
const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0;
|
|
@@ -51491,7 +51494,7 @@ function handleSessionEvent(ev) {
|
|
|
51491
51494
|
if (recentCount > 0) {
|
|
51492
51495
|
process.stderr.write(`telegram gateway: turn-flush suppressed \u2014 reply tool sent ${recentCount} message(s) within 2s
|
|
51493
51496
|
`);
|
|
51494
|
-
purgeReactionTracking(statusKey(backstopChatId, backstopThreadId));
|
|
51497
|
+
purgeReactionTracking(statusKey(backstopChatId, backstopThreadId), turn);
|
|
51495
51498
|
return;
|
|
51496
51499
|
}
|
|
51497
51500
|
} catch {}
|
|
@@ -51576,14 +51579,13 @@ function handleSessionEvent(ev) {
|
|
|
51576
51579
|
if (backstopCtrl)
|
|
51577
51580
|
backstopCtrl.setError();
|
|
51578
51581
|
} finally {
|
|
51579
|
-
purgeReactionTracking(statusKey(backstopChatId, backstopThreadId));
|
|
51582
|
+
purgeReactionTracking(statusKey(backstopChatId, backstopThreadId), turn);
|
|
51580
51583
|
}
|
|
51581
51584
|
})();
|
|
51582
51585
|
return;
|
|
51583
51586
|
}
|
|
51584
51587
|
if (ctrl)
|
|
51585
51588
|
ctrl.setDone();
|
|
51586
|
-
purgeReactionTracking(statusKey(chatId, threadId));
|
|
51587
51589
|
{
|
|
51588
51590
|
const sKey = streamKey3(chatId, threadId);
|
|
51589
51591
|
const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0;
|
|
@@ -461,6 +461,12 @@ export function createDraftStream(
|
|
|
461
461
|
draftId = allocateDraftId()
|
|
462
462
|
currentChunkStartedAt = null
|
|
463
463
|
persistChainFires++
|
|
464
|
+
// PR follow-up: persist-chain's bare send() bypasses
|
|
465
|
+
// sendViaMessage's increment, same shape as the finalize-
|
|
466
|
+
// materialize bug. Without this, streams that cross the
|
|
467
|
+
// 25s / 4000-char boundary would under-report `sends` by
|
|
468
|
+
// the chain count in stream-end.
|
|
469
|
+
sendFires++
|
|
464
470
|
if (process.env.SWITCHROOM_STREAM_TRACES !== '0') {
|
|
465
471
|
process.stderr.write(
|
|
466
472
|
`gw-trace stream-persist chunk_chars=${chunk.length} ` +
|
|
@@ -664,6 +670,13 @@ export function createDraftStream(
|
|
|
664
670
|
try {
|
|
665
671
|
messageId = await send(textToMaterialize)
|
|
666
672
|
persistedTextLen = fullText.length
|
|
673
|
+
// PR follow-up: bump sendFires so the stream-end trace
|
|
674
|
+
// reflects the finalize-materialize sendMessage call. Pre-
|
|
675
|
+
// this fix, the counter under-reported by 1 for every
|
|
676
|
+
// draft-transport stream that produced a non-empty reply:
|
|
677
|
+
// gw-trace stream-end showed `drafts=N sends=0` even
|
|
678
|
+
// though sendMessage HAD fired (visible in tg-post lines).
|
|
679
|
+
sendFires++
|
|
667
680
|
log?.(`stream → materialized tail (id: ${messageId}, ${textToMaterialize.length} chars)`)
|
|
668
681
|
} catch (err) {
|
|
669
682
|
warn?.(`draft-stream: materialize sendMessage failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
@@ -1278,32 +1278,62 @@ function streamKey(chatId: string, threadId?: number | null): string {
|
|
|
1278
1278
|
return chatKey(chatId, threadId)
|
|
1279
1279
|
}
|
|
1280
1280
|
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
: currentTurn?.replyCalled === true
|
|
1298
|
-
shadowEmit({ kind: 'turnEnd', key: key as _ChatKey, at: Date.now(), outboundEmitted })
|
|
1281
|
+
/**
|
|
1282
|
+
* Reaction-state cleanup — controller + msg-id maps + active-reaction
|
|
1283
|
+
* file removal. PURE reaction-cleanup, no turn-end semantics:
|
|
1284
|
+
* - does NOT emit shadow `turnEnd`
|
|
1285
|
+
* - does NOT clear `activeTurnStartedAt` (turn-active marker)
|
|
1286
|
+
* - does NOT fire the model-idle restart/flush gate
|
|
1287
|
+
*
|
|
1288
|
+
* Called from mid-turn signals like `endStatusReaction` (post-reply-tool,
|
|
1289
|
+
* post-stream-reply-finalize) where the 👍 transition fires but the
|
|
1290
|
+
* turn is still active. Per #1603 audit step 2: the reply tool was
|
|
1291
|
+
* previously calling `purgeReactionTracking` here, which fired premature
|
|
1292
|
+
* shadow `turnEnd` events and cleared `activeTurnStartedAt` mid-turn —
|
|
1293
|
+
* the latter would trigger the model-idle restart probe and
|
|
1294
|
+
* pendingInbound flush as if claude had gone idle.
|
|
1295
|
+
*/
|
|
1296
|
+
function clearReactionState(key: string): void {
|
|
1299
1297
|
const msgInfo = activeReactionMsgIds.get(key)
|
|
1300
1298
|
activeStatusReactions.delete(key)
|
|
1301
1299
|
activeReactionMsgIds.delete(key)
|
|
1302
|
-
activeTurnStartedAt.delete(key)
|
|
1303
1300
|
if (msgInfo) {
|
|
1304
1301
|
const agentDir = resolveAgentDirFromEnv()
|
|
1305
1302
|
if (agentDir != null) removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId)
|
|
1306
1303
|
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function purgeReactionTracking(
|
|
1307
|
+
key: string,
|
|
1308
|
+
endingTurn?: CurrentTurn,
|
|
1309
|
+
outboundEmittedOverride?: boolean,
|
|
1310
|
+
): void {
|
|
1311
|
+
// Phase 2b: turn end. The key was registered via setTurnStarted when
|
|
1312
|
+
// the inbound arrived; purge is the canonical turn-end signal.
|
|
1313
|
+
//
|
|
1314
|
+
// outboundEmitted derivation, in precedence order:
|
|
1315
|
+
// 1. Explicit `outboundEmittedOverride` (e.g. silence-poke
|
|
1316
|
+
// framework fallback FORCES false because the 5-min fallback
|
|
1317
|
+
// firing proves visible delivery never happened — regardless of
|
|
1318
|
+
// whatever `replyCalled` the wedged turn object carries).
|
|
1319
|
+
// 2. `endingTurn.replyCalled` when the canonical caller threads
|
|
1320
|
+
// the authoritative turn (endCurrentTurnAtomic path; module-scope
|
|
1321
|
+
// currentTurn is already null by the time we get here).
|
|
1322
|
+
// 3. `currentTurn?.replyCalled` fallback for the (now-vanishing)
|
|
1323
|
+
// legacy callsites. Without the explicit-turn handoff the shadow
|
|
1324
|
+
// trace would report outboundEmitted=false on every replied
|
|
1325
|
+
// turn (the dominant happy path), producing strictly worse data
|
|
1326
|
+
// than the blind `true` it replaced. Invariant #5's
|
|
1327
|
+
// `lastOutboundAt` correctness depends on this signal being
|
|
1328
|
+
// accurate.
|
|
1329
|
+
const outboundEmitted = outboundEmittedOverride !== undefined
|
|
1330
|
+
? outboundEmittedOverride
|
|
1331
|
+
: endingTurn != null
|
|
1332
|
+
? endingTurn.replyCalled === true
|
|
1333
|
+
: currentTurn?.replyCalled === true
|
|
1334
|
+
shadowEmit({ kind: 'turnEnd', key: key as _ChatKey, at: Date.now(), outboundEmitted })
|
|
1335
|
+
clearReactionState(key)
|
|
1336
|
+
activeTurnStartedAt.delete(key)
|
|
1307
1337
|
|
|
1308
1338
|
// If no more active turns and a restart is pending, perform it now.
|
|
1309
1339
|
//
|
|
@@ -1593,12 +1623,24 @@ async function resolveCompactCard(
|
|
|
1593
1623
|
}
|
|
1594
1624
|
|
|
1595
1625
|
function endStatusReaction(chatId: string, threadId: number | undefined, outcome: 'done' | 'error'): void {
|
|
1626
|
+
// Mid-turn signal: the reply tool fired, or stream_reply finalized,
|
|
1627
|
+
// and the status-reaction needs to transition to its terminal emoji
|
|
1628
|
+
// (👍 / ⚠️). The turn itself is still active — the canonical turn-end
|
|
1629
|
+
// signal is `endCurrentTurnAtomic(turn)`, which runs later via the
|
|
1630
|
+
// turn_end handler / context-exhaust path / silent-marker path.
|
|
1631
|
+
//
|
|
1632
|
+
// Pre-#1603 audit step 2 (this commit), this called
|
|
1633
|
+
// `purgeReactionTracking(key)` directly, which would fire shadow
|
|
1634
|
+
// `turnEnd` and clear the turn-active marker mid-turn — the latter
|
|
1635
|
+
// triggering the model-idle restart probe + pendingInbound flush as
|
|
1636
|
+
// if claude had gone idle. Use `clearReactionState` to only do the
|
|
1637
|
+
// reaction-cleanup work.
|
|
1596
1638
|
const key = statusKey(chatId, threadId)
|
|
1597
1639
|
const ctrl = activeStatusReactions.get(key)
|
|
1598
1640
|
if (!ctrl) return
|
|
1599
1641
|
if (outcome === 'done') ctrl.setDone()
|
|
1600
1642
|
else ctrl.setError()
|
|
1601
|
-
|
|
1643
|
+
clearReactionState(key)
|
|
1602
1644
|
}
|
|
1603
1645
|
|
|
1604
1646
|
function resolveThreadId(chat_id: string, explicit?: string | number | null): number | undefined {
|
|
@@ -3093,7 +3135,15 @@ silencePoke.startTimer({
|
|
|
3093
3135
|
// Drop silence-poke state and clear turn-active so the next inbound
|
|
3094
3136
|
// for this chat starts a fresh turn instead of queueing forever.
|
|
3095
3137
|
silencePoke.endTurn(fbKey)
|
|
3096
|
-
|
|
3138
|
+
// PR 3b step 5 (#1603 audit): force outboundEmitted=false. The
|
|
3139
|
+
// framework fallback fires precisely because visible delivery
|
|
3140
|
+
// didn't happen in 5 min — `wedgedTurn.replyCalled` may have been
|
|
3141
|
+
// set during the turn (e.g. reply tool invoked but Telegram side
|
|
3142
|
+
// never confirmed delivery), but from the user's perspective no
|
|
3143
|
+
// outbound landed. The state machine's `noteOutbound` effect
|
|
3144
|
+
// must NOT fire for this path. Pass `undefined` for endingTurn
|
|
3145
|
+
// and `false` as the explicit override.
|
|
3146
|
+
purgeReactionTracking(fbKey, undefined, false)
|
|
3097
3147
|
// Defense-in-depth: the fallback's purgeReactionTracking above
|
|
3098
3148
|
// clears the canonical statusKey(chatId, threadId) for fbKey
|
|
3099
3149
|
// only. activeTurnStartedAt can hold sibling entries for the
|
|
@@ -3106,10 +3156,14 @@ silencePoke.startTimer({
|
|
|
3106
3156
|
// purger. Multi-chat-safe — only touches keys for fbChatId, so
|
|
3107
3157
|
// #1546's intentional cross-chat safety guard is preserved.
|
|
3108
3158
|
// See turn-state-purge.ts.
|
|
3159
|
+
//
|
|
3160
|
+
// Same `outboundEmitted=false` rationale as the bare call above —
|
|
3161
|
+
// wrap the purger so every sibling-key purge emits a fallback
|
|
3162
|
+
// shadow turnEnd with the truthful "no visible delivery" signal.
|
|
3109
3163
|
const fbExtraPurge = purgeStaleTurnsForChat(
|
|
3110
3164
|
fbChatId,
|
|
3111
3165
|
activeTurnStartedAt.keys(),
|
|
3112
|
-
purgeReactionTracking,
|
|
3166
|
+
(k) => purgeReactionTracking(k, undefined, false),
|
|
3113
3167
|
)
|
|
3114
3168
|
// Null `currentTurn` if it's still pointing at the wedged turn —
|
|
3115
3169
|
// when claude eventually fires a late `turn_end` for this session
|
|
@@ -5828,7 +5882,10 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5828
5882
|
const ceKey = statusKey(chatId, threadId)
|
|
5829
5883
|
const ctrl = activeStatusReactions.get(ceKey)
|
|
5830
5884
|
if (ctrl) ctrl.setError()
|
|
5831
|
-
|
|
5885
|
+
// Duplicate-emit removed (#1603 audit, step 1): the canonical
|
|
5886
|
+
// endCurrentTurnAtomic(turn) call at line ~5851 below already
|
|
5887
|
+
// invokes purgeReactionTracking on the same ceKey. The bare
|
|
5888
|
+
// call here was firing a second shadow `turnEnd` per traversal.
|
|
5832
5889
|
// Surfaced during CC-5 investigation (`docs/status-ask-cause-classes.md`):
|
|
5833
5890
|
// the context-exhaust bail path teardown was missing
|
|
5834
5891
|
// `silencePoke.endTurn(key)`. Without it, the silence-poke state for
|
|
@@ -5986,7 +6043,10 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5986
6043
|
// Fall through to normal state cleanup (ctrl.setDone, purge, etc.)
|
|
5987
6044
|
// but skip the regular closeProgressLane so we don't re-finalize.
|
|
5988
6045
|
if (ctrl) ctrl.setDone()
|
|
5989
|
-
|
|
6046
|
+
// Duplicate-emit removed (#1603 audit, step 1): endCurrentTurnAtomic(turn)
|
|
6047
|
+
// at line ~6049 below invokes purgeReactionTracking on the same key
|
|
6048
|
+
// (statusKey(chatId, threadId)). The bare call here was firing a
|
|
6049
|
+
// second shadow `turnEnd` per silent-marker traversal.
|
|
5990
6050
|
// Match the normal turn_end path's telemetry so silent-marker turns
|
|
5991
6051
|
// still appear in turn-duration graphs.
|
|
5992
6052
|
{
|
|
@@ -6127,7 +6187,15 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6127
6187
|
// mirroring this contract — so reply-only turns transition
|
|
6128
6188
|
// to terminal 👍 in their own success path rather than
|
|
6129
6189
|
// relying on this dedup heuristic.
|
|
6130
|
-
|
|
6190
|
+
//
|
|
6191
|
+
// PR 3b step 3 (#1603 audit): thread the captured `turn`
|
|
6192
|
+
// explicitly. `endCurrentTurnAtomic(turn)` ran at line ~6120
|
|
6193
|
+
// before this IIFE started, so `currentTurn === null` by
|
|
6194
|
+
// now — without an explicit endingTurn argument, the shadow
|
|
6195
|
+
// trace would read `outboundEmitted=false` for this dedup
|
|
6196
|
+
// path even though `recentCount > 0` proves the reply tool
|
|
6197
|
+
// did fire (turn.replyCalled === true).
|
|
6198
|
+
purgeReactionTracking(statusKey(backstopChatId, backstopThreadId), turn)
|
|
6131
6199
|
return
|
|
6132
6200
|
}
|
|
6133
6201
|
} catch {}
|
|
@@ -6255,14 +6323,35 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6255
6323
|
process.stderr.write(`telegram gateway: turn-flush send failed: ${(err as Error).message}\n`)
|
|
6256
6324
|
if (backstopCtrl) backstopCtrl.setError()
|
|
6257
6325
|
} finally {
|
|
6258
|
-
|
|
6326
|
+
// PR 3b step 3 (#1603 audit): thread the captured `turn`
|
|
6327
|
+
// explicitly. The turn-flush backstop runs inside this IIFE
|
|
6328
|
+
// after `endCurrentTurnAtomic(turn)` already nulled
|
|
6329
|
+
// `currentTurn` at line ~6120. Without threading, the shadow
|
|
6330
|
+
// trace would read `outboundEmitted=currentTurn?.replyCalled
|
|
6331
|
+
// === undefined` → false. For the turn-flush path
|
|
6332
|
+
// `turn.replyCalled` is `false` regardless (the model didn't
|
|
6333
|
+
// call the reply tool — the gateway backstop did the work),
|
|
6334
|
+
// so the threaded value matches the existing fallback here.
|
|
6335
|
+
// But pinning the source via the captured turn matches the
|
|
6336
|
+
// canonical pattern and survives any future change to how
|
|
6337
|
+
// `currentTurn` is sequenced.
|
|
6338
|
+
purgeReactionTracking(statusKey(backstopChatId, backstopThreadId), turn)
|
|
6259
6339
|
}
|
|
6260
6340
|
})()
|
|
6261
6341
|
return
|
|
6262
6342
|
}
|
|
6263
6343
|
|
|
6264
6344
|
if (ctrl) ctrl.setDone()
|
|
6265
|
-
|
|
6345
|
+
// Duplicate-emit removed (#1603 audit, step 4 — the audit's
|
|
6346
|
+
// original "route through endCurrentTurnAtomic" recommendation
|
|
6347
|
+
// missed that this same code path already calls
|
|
6348
|
+
// `endCurrentTurnAtomic(turn)` ~90 lines below at line ~6412
|
|
6349
|
+
// on the same key — `chatId === turn.sessionChatId` and
|
|
6350
|
+
// `threadId === turn.sessionThreadId` per the bindings at
|
|
6351
|
+
// ~5946-5947. Removing this bare call closes the last duplicate
|
|
6352
|
+
// shadow-`turnEnd` emit on the dominant happy-path turn-end
|
|
6353
|
+
// tail; the canonical primitive below still fires the single
|
|
6354
|
+
// authoritative turnEnd with the threaded turn).
|
|
6266
6355
|
{
|
|
6267
6356
|
const sKey = streamKey(chatId, threadId)
|
|
6268
6357
|
const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
|
|
@@ -1202,4 +1202,83 @@ describe('createDraftStream — draft transport', () => {
|
|
|
1202
1202
|
})
|
|
1203
1203
|
})
|
|
1204
1204
|
|
|
1205
|
+
// ─── Follow-up: stream-end `sends` counter includes finalize-materialize ───
|
|
1206
|
+
describe('finalize-materialize bumps sends counter', () => {
|
|
1207
|
+
let captured: string[] = []
|
|
1208
|
+
let originalWrite: typeof process.stderr.write
|
|
1209
|
+
|
|
1210
|
+
beforeEach(() => {
|
|
1211
|
+
captured = []
|
|
1212
|
+
originalWrite = process.stderr.write
|
|
1213
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
1214
|
+
const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
|
|
1215
|
+
captured.push(text)
|
|
1216
|
+
return true
|
|
1217
|
+
}) as typeof process.stderr.write
|
|
1218
|
+
})
|
|
1219
|
+
afterEach(() => {
|
|
1220
|
+
process.stderr.write = originalWrite
|
|
1221
|
+
})
|
|
1222
|
+
|
|
1223
|
+
it('draft-transport stream that materializes on finalize shows sends>=1', async () => {
|
|
1224
|
+
// Pre-fix this showed sends=0 even though sendMessage fired
|
|
1225
|
+
// inside finalize. Bug was visible in production v0.13.0 traces.
|
|
1226
|
+
const m = makeMock()
|
|
1227
|
+
const sendMessageDraft = vi.fn(async () => {})
|
|
1228
|
+
const stream = createDraftStream(m.send, m.edit, {
|
|
1229
|
+
throttleMs: 50,
|
|
1230
|
+
previewTransport: 'draft',
|
|
1231
|
+
sendMessageDraft,
|
|
1232
|
+
chatId: 'chat-x',
|
|
1233
|
+
})
|
|
1234
|
+
void stream.update('Hello world')
|
|
1235
|
+
await microtaskFlush()
|
|
1236
|
+
vi.advanceTimersByTime(100)
|
|
1237
|
+
await microtaskFlush()
|
|
1238
|
+
await stream.finalize()
|
|
1239
|
+
// Real send() called inside finalize.
|
|
1240
|
+
expect(m.sendCalls.length).toBe(1)
|
|
1241
|
+
const endLine = captured.find((c) => c.includes('gw-trace stream-end'))
|
|
1242
|
+
expect(endLine).toBeDefined()
|
|
1243
|
+
// Counter now reflects reality.
|
|
1244
|
+
expect(endLine).toMatch(/sends=[1-9]/)
|
|
1245
|
+
})
|
|
1246
|
+
|
|
1247
|
+
it('persist-chain bump counts toward sends (size-trigger fires + finalize)', async () => {
|
|
1248
|
+
// The sibling bug at the persist-chain callsite — its bare
|
|
1249
|
+
// send(chunk) also bypasses sendViaMessage. Without the fix
|
|
1250
|
+
// a stream that crosses the size boundary would show sends=1
|
|
1251
|
+
// (only the finalize materialize), missing the chain fire.
|
|
1252
|
+
// With the fix sends counts BOTH the persist send AND the
|
|
1253
|
+
// finalize materialize → sends>=2.
|
|
1254
|
+
const m = makeMock()
|
|
1255
|
+
const sendMessageDraft = vi.fn(async () => {})
|
|
1256
|
+
const stream = createDraftStream(m.send, m.edit, {
|
|
1257
|
+
throttleMs: 50,
|
|
1258
|
+
previewTransport: 'draft',
|
|
1259
|
+
sendMessageDraft,
|
|
1260
|
+
chatId: 'chat-chain',
|
|
1261
|
+
persistSizeLimit: 200,
|
|
1262
|
+
})
|
|
1263
|
+
void stream.update('a'.repeat(100))
|
|
1264
|
+
await microtaskFlush()
|
|
1265
|
+
vi.advanceTimersByTime(300)
|
|
1266
|
+
void stream.update('a'.repeat(250)) // size trigger fires (tail=250 ≥ 200)
|
|
1267
|
+
await microtaskFlush()
|
|
1268
|
+
vi.advanceTimersByTime(300)
|
|
1269
|
+
await microtaskFlush()
|
|
1270
|
+
// Extra text after persist so finalize-materialize tail is non-empty.
|
|
1271
|
+
void stream.update('a'.repeat(250) + 'b'.repeat(50))
|
|
1272
|
+
await microtaskFlush()
|
|
1273
|
+
vi.advanceTimersByTime(300)
|
|
1274
|
+
await microtaskFlush()
|
|
1275
|
+
await stream.finalize()
|
|
1276
|
+
const endLine = captured.find((c) => c.includes('gw-trace stream-end'))
|
|
1277
|
+
expect(endLine).toBeDefined()
|
|
1278
|
+
// Persist send + finalize materialize = at least 2.
|
|
1279
|
+
expect(endLine).toMatch(/sends=[2-9]/)
|
|
1280
|
+
expect(endLine).toMatch(/persists=[1-9]/)
|
|
1281
|
+
})
|
|
1282
|
+
})
|
|
1283
|
+
|
|
1205
1284
|
})
|