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.
@@ -47248,8 +47248,8 @@ var {
47248
47248
  } = import__.default;
47249
47249
 
47250
47250
  // src/build-info.ts
47251
- var VERSION = "0.13.0";
47252
- var COMMIT_SHA = "9b3d62f2";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.0";
47681
- var COMMIT_SHA = "9b3d62f2";
47682
- var COMMIT_DATE = "2026-05-20T22:23:43Z";
47683
- var LATEST_PR = 1600;
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 purgeReactionTracking(key, endingTurn) {
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
- purgeReactionTracking(key);
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
- function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
1282
- // Phase 2b: turn end. The key was registered via setTurnStarted when
1283
- // the inbound arrived; purge is the canonical turn-end signal.
1284
- //
1285
- // outboundEmitted: read from the explicit `endingTurn` parameter when
1286
- // provided (canonical path via endCurrentTurnAtomic module-scope
1287
- // currentTurn is already null by the time we get here), falling back
1288
- // to `currentTurn?.replyCalled` for the legacy callsites that haven't
1289
- // been threaded yet (sibling-key purges, restart-init cleanup).
1290
- // Without this explicit-turn handoff the shadow trace would report
1291
- // outboundEmitted=false on every replied turn (the dominant happy
1292
- // path), producing strictly worse data than the blind `true` it
1293
- // replaced. Invariant #5's `lastOutboundAt` correctness depends on
1294
- // this signal being accurate.
1295
- const outboundEmitted = endingTurn != null
1296
- ? endingTurn.replyCalled === true
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
- purgeReactionTracking(key)
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
- purgeReactionTracking(fbKey)
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
- purgeReactionTracking(ceKey)
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
- purgeReactionTracking(statusKey(chatId, threadId))
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
- purgeReactionTracking(statusKey(backstopChatId, backstopThreadId))
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
- purgeReactionTracking(statusKey(backstopChatId, backstopThreadId))
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
- purgeReactionTracking(statusKey(chatId, threadId))
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
  })