switchroom 0.19.13 → 0.19.14

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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.19.13", COMMIT_SHA = "fda10488";
2123
+ var VERSION = "0.19.14", COMMIT_SHA = "1f1f69f8";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -26664,7 +26664,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
26664
26664
  import { dirname as dirname5, join as join7 } from "node:path";
26665
26665
 
26666
26666
  // src/build-info.ts
26667
- var VERSION = "0.19.13";
26667
+ var VERSION = "0.19.14";
26668
26668
 
26669
26669
  // src/cli/resolve-version.ts
26670
26670
  function readPackageVersion() {
@@ -30310,6 +30310,8 @@ class LogTailRolloutNarrator {
30310
30310
  return;
30311
30311
  st.lastAppliedSeq = seq;
30312
30312
  this.trackAgentProgress(st, phase);
30313
+ const doneNames = st.agents.filter((a) => a.status === "done").map((a) => a.name);
30314
+ const rolledSoFar = entry.rolled && entry.rolled.length > 0 ? entry.rolled : doneNames;
30313
30315
  st.render = {
30314
30316
  ...st.render,
30315
30317
  target: phase.target,
@@ -30317,7 +30319,7 @@ class LogTailRolloutNarrator {
30317
30319
  ...phase.n !== undefined ? { n: phase.n } : {},
30318
30320
  ...phase.m !== undefined ? { m: phase.m } : {},
30319
30321
  ...phase.agent !== undefined ? { agent: phase.agent } : {},
30320
- rolled: entry.rolled,
30322
+ rolled: rolledSoFar,
30321
30323
  agents: st.agents,
30322
30324
  requestId: entry.request_id,
30323
30325
  ...entry.prior_pin ? { fromVersion: entry.prior_pin } : {},
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.19.13",
4
+ "version": "0.19.14",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -74206,7 +74206,14 @@ function journalExternalDelivery(args, stateDir, now = Date.now()) {
74206
74206
  const nonce = args.turnNonce;
74207
74207
  if (nonce == null || nonce === "")
74208
74208
  return;
74209
- appendDelivered({ turnNonce: nonce, textSha256: sha256Hex(args.text), tgMessageId: args.tgMessageId, ts: now }, stateDir);
74209
+ appendDelivered({
74210
+ turnNonce: nonce,
74211
+ textSha256: sha256Hex(args.text),
74212
+ tgMessageId: args.tgMessageId,
74213
+ ts: now,
74214
+ deliverySource: "reply-tool",
74215
+ ...args.replyAlreadyDeliveredThisTurn == null ? {} : { replyAlreadyDeliveredThisTurn: args.replyAlreadyDeliveredThisTurn }
74216
+ }, stateDir);
74210
74217
  clearOutboxRecord(nonce, stateDir);
74211
74218
  }
74212
74219
 
@@ -75377,7 +75384,7 @@ ${url}`;
75377
75384
  }
75378
75385
  outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now(), turn2?.registryKey ?? null);
75379
75386
  if (isFinalAnswerReply({ text: decision.mergedText, disableNotification: modelDisableNotification })) {
75380
- journalExternalDelivery({ turnNonce: turn2?.turnId ?? null, text: decision.mergedText, tgMessageId: decision.messageId });
75387
+ journalExternalDelivery({ turnNonce: turn2?.turnId ?? null, text: decision.mergedText, tgMessageId: decision.messageId, replyAlreadyDeliveredThisTurn: true });
75381
75388
  }
75382
75389
  silentAnchorEditDone = true;
75383
75390
  } catch (err) {
@@ -75655,7 +75662,7 @@ ${url}`;
75655
75662
  const t = getCurrentTurn();
75656
75663
  outboundDedup.record(chat_id, threadId, text4, Date.now(), t?.registryKey ?? null);
75657
75664
  if (shouldJournalReplySiteDelivery({ text: rawText, disableNotification: modelDisableNotification })) {
75658
- journalExternalDelivery({ turnNonce: t?.turnId ?? null, text: text4, tgMessageId: sentIds[sentIds.length - 1] });
75665
+ journalExternalDelivery({ turnNonce: t?.turnId ?? null, text: text4, tgMessageId: sentIds[sentIds.length - 1], replyAlreadyDeliveredThisTurn: true });
75659
75666
  }
75660
75667
  }
75661
75668
  return { content: [{ type: "text", text: result }] };
@@ -75709,7 +75716,7 @@ async function deliverCapturedProse(deps, args) {
75709
75716
  } catch {}
75710
75717
  }
75711
75718
  outboundDedup.record(chatId, threadId, text4, now, registryKey);
75712
- journalExternalDelivery({ turnNonce: originTurnId, text: text4, tgMessageId: sentIds[sentIds.length - 1] });
75719
+ journalExternalDelivery({ turnNonce: originTurnId, text: text4, tgMessageId: sentIds[sentIds.length - 1], replyAlreadyDeliveredThisTurn: false });
75713
75720
  process.stderr.write(`telegram gateway: captured-prose delivery \u2014 sent ${out.length} chars recovered from ` + `transcript scan (chat=${chatId} origin=${originTurnId})
75714
75721
  `);
75715
75722
  outcome = "sent";
@@ -93894,7 +93901,13 @@ async function sweepOutbox(deps) {
93894
93901
  if (decision.action === "skip-journaled") {
93895
93902
  clearOutboxRecord(record2.turnNonce, deps.stateDir);
93896
93903
  } else if (decision.action === "skip-dedup") {
93897
- appendDelivered({ turnNonce: record2.turnNonce, textSha256: record2.textSha256, ts: now }, deps.stateDir);
93904
+ appendDelivered({
93905
+ turnNonce: record2.turnNonce,
93906
+ textSha256: record2.textSha256,
93907
+ ts: now,
93908
+ deliverySource: "sweep",
93909
+ replyAlreadyDeliveredThisTurn: record2.replyAlreadyDeliveredThisTurn === true
93910
+ }, deps.stateDir);
93898
93911
  clearOutboxRecord(record2.turnNonce, deps.stateDir);
93899
93912
  }
93900
93913
  continue;
@@ -93907,7 +93920,14 @@ async function sweepOutbox(deps) {
93907
93920
  const resolvedChat = resolved;
93908
93921
  try {
93909
93922
  const messageId = await deps.send(resolvedChat.chatId, resolvedChat.threadId, decision.text ?? record2.text);
93910
- appendDelivered({ turnNonce: record2.turnNonce, textSha256: record2.textSha256, tgMessageId: messageId, ts: now }, deps.stateDir);
93923
+ appendDelivered({
93924
+ turnNonce: record2.turnNonce,
93925
+ textSha256: record2.textSha256,
93926
+ tgMessageId: messageId,
93927
+ ts: now,
93928
+ deliverySource: "sweep",
93929
+ replyAlreadyDeliveredThisTurn: record2.replyAlreadyDeliveredThisTurn === true
93930
+ }, deps.stateDir);
93911
93931
  removeClaimed(record2.turnNonce, deps.stateDir);
93912
93932
  summary.delivered++;
93913
93933
  log(`outbox-sweep: delivered nonce=${record2.turnNonce} via=${resolvedChat.via} ` + `source=${record2.source} chars=${record2.text.length}${decision.action === "send-delayed" ? " (delayed)" : ""}
@@ -93966,10 +93986,10 @@ function startOutboxSweep(deps) {
93966
93986
  }
93967
93987
 
93968
93988
  // ../src/build-info.ts
93969
- var VERSION = "0.19.13";
93970
- var COMMIT_SHA = "fda10488";
93971
- var COMMIT_DATE = "2026-07-23T04:34:01Z";
93972
- var LATEST_PR = 3508;
93989
+ var VERSION = "0.19.14";
93990
+ var COMMIT_SHA = "1f1f69f8";
93991
+ var COMMIT_DATE = "2026-07-24T07:19:40+10:00";
93992
+ var LATEST_PR = 3514;
93973
93993
  var COMMITS_AHEAD_OF_TAG = 0;
93974
93994
 
93975
93995
  // gateway/boot-version.ts
@@ -1805,7 +1805,7 @@ export async function sendReply(
1805
1805
  // text differs. Gated so an interim-ack edit never journals the turn
1806
1806
  // nonce (which would suppress a later genuine answer for the turn).
1807
1807
  if (isFinalAnswerReply({ text: decision.mergedText, disableNotification: modelDisableNotification })) {
1808
- journalExternalDelivery({ turnNonce: turn?.turnId ?? null, text: decision.mergedText, tgMessageId: decision.messageId })
1808
+ journalExternalDelivery({ turnNonce: turn?.turnId ?? null, text: decision.mergedText, tgMessageId: decision.messageId, replyAlreadyDeliveredThisTurn: true })
1809
1809
  }
1810
1810
 
1811
1811
  silentAnchorEditDone = true
@@ -2372,7 +2372,7 @@ export async function sendReply(
2372
2372
  // won't double-post it) while an interim ack never journals (so a later
2373
2373
  // genuinely-undelivered final answer is delivered by the sweep).
2374
2374
  if (shouldJournalReplySiteDelivery({ text: rawText, disableNotification: modelDisableNotification })) {
2375
- journalExternalDelivery({ turnNonce: t?.turnId ?? null, text, tgMessageId: sentIds[sentIds.length - 1] })
2375
+ journalExternalDelivery({ turnNonce: t?.turnId ?? null, text, tgMessageId: sentIds[sentIds.length - 1], replyAlreadyDeliveredThisTurn: true })
2376
2376
  }
2377
2377
  }
2378
2378
  return { content: [{ type: 'text', text: result }] }
@@ -2483,7 +2483,13 @@ export async function deliverCapturedProse(
2483
2483
  outboundDedup.record(chatId, threadId, text, now, registryKey)
2484
2484
  // F1: captured-prose delivery journals + clears under the shared nonce
2485
2485
  // (`originTurnId` === turn.turnId === deriveTurnId, the hook's nonce).
2486
- journalExternalDelivery({ turnNonce: originTurnId, text, tgMessageId: sentIds[sentIds.length - 1] })
2486
+ // #3510/#3511: the bridge only runs when NO genuine final answer was
2487
+ // delivered this turn (`decideTurnEndGate` → 'reprompt' requires
2488
+ // `finalAnswerDelivered === false`), so stamp `false` explicitly. A
2489
+ // journal line from this site following a reply-tool line under the same
2490
+ // nonce (replyAlreadyDeliveredThisTurn:true) is direct, journal-only
2491
+ // proof of a bridge double-send.
2492
+ journalExternalDelivery({ turnNonce: originTurnId, text, tgMessageId: sentIds[sentIds.length - 1], replyAlreadyDeliveredThisTurn: false })
2487
2493
  process.stderr.write(
2488
2494
  `telegram gateway: captured-prose delivery — sent ${out.length} chars recovered from ` +
2489
2495
  `transcript scan (chat=${chatId} origin=${originTurnId})\n`,
@@ -133,7 +133,13 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
133
133
  // exactly-once, independent of cache lifetime and surviving a restart
134
134
  // (the record is gone from disk, the nonce is durably journaled).
135
135
  appendDelivered(
136
- { turnNonce: record.turnNonce, textSha256: record.textSha256, ts: now },
136
+ {
137
+ turnNonce: record.turnNonce,
138
+ textSha256: record.textSha256,
139
+ ts: now,
140
+ deliverySource: 'sweep',
141
+ replyAlreadyDeliveredThisTurn: record.replyAlreadyDeliveredThisTurn === true,
142
+ },
137
143
  deps.stateDir,
138
144
  )
139
145
  clearOutboxRecord(record.turnNonce, deps.stateDir)
@@ -156,7 +162,18 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
156
162
  decision.text ?? record.text,
157
163
  )
158
164
  appendDelivered(
159
- { turnNonce: record.turnNonce, textSha256: record.textSha256, tgMessageId: messageId, ts: now },
165
+ {
166
+ turnNonce: record.turnNonce,
167
+ textSha256: record.textSha256,
168
+ tgMessageId: messageId,
169
+ ts: now,
170
+ // #3510 instrumentation: a sweep delivery journals its machine and the
171
+ // record's capture-time reply-already-delivered flag, so a duplicate
172
+ // (deliverySource:'sweep' after a reply-tool delivery of the same
173
+ // turn) is provable from the journal alone.
174
+ deliverySource: 'sweep',
175
+ replyAlreadyDeliveredThisTurn: record.replyAlreadyDeliveredThisTurn === true,
176
+ },
160
177
  deps.stateDir,
161
178
  )
162
179
  removeClaimed(record.turnNonce, deps.stateDir)
@@ -266,14 +283,35 @@ export function startOutboxSweep(deps: {
266
283
  * Best-effort; never throws. A null/empty nonce is ignored (nothing to journal).
267
284
  */
268
285
  export function journalExternalDelivery(
269
- args: { turnNonce: string | null; text: string; tgMessageId?: number },
286
+ args: {
287
+ turnNonce: string | null
288
+ text: string
289
+ tgMessageId?: number
290
+ /**
291
+ * #3510 instrumentation: whether a qualifying reply had already delivered
292
+ * this turn at the time of this delivery. Reply-send sites pass `true`
293
+ * (they ARE that delivery); the captured-prose bridge omits it (its turn
294
+ * shape is decided upstream). Journaled so a later sweep entry under the
295
+ * same nonce is provably a double-send from the journal alone.
296
+ */
297
+ replyAlreadyDeliveredThisTurn?: boolean
298
+ },
270
299
  stateDir?: string,
271
300
  now: number = Date.now(),
272
301
  ): void {
273
302
  const nonce = args.turnNonce
274
303
  if (nonce == null || nonce === '') return
275
304
  appendDelivered(
276
- { turnNonce: nonce, textSha256: sha256Hex(args.text), tgMessageId: args.tgMessageId, ts: now },
305
+ {
306
+ turnNonce: nonce,
307
+ textSha256: sha256Hex(args.text),
308
+ tgMessageId: args.tgMessageId,
309
+ ts: now,
310
+ deliverySource: 'reply-tool',
311
+ ...(args.replyAlreadyDeliveredThisTurn == null
312
+ ? {}
313
+ : { replyAlreadyDeliveredThisTurn: args.replyAlreadyDeliveredThisTurn }),
314
+ },
277
315
  stateDir,
278
316
  )
279
317
  clearOutboxRecord(nonce, stateDir)
@@ -182,6 +182,11 @@ function writeOutboxRecord(stateDir, capture) {
182
182
  // F2: per-session origin chat for envelope-less routing (fail-closed).
183
183
  originChatId: capture.chatId == null ? (capture.originChatId ?? null) : undefined,
184
184
  originThreadId: capture.chatId == null ? (capture.originThreadId ?? null) : undefined,
185
+ // #3510 instrumentation: carried into every delivered.jsonl entry the
186
+ // sweep writes for this record, so a double-send is provable from the
187
+ // journal alone. Driven by the SAME boolean that gates capture-vs-election
188
+ // in main() — fix and telemetry cannot drift.
189
+ replyAlreadyDeliveredThisTurn: capture.replyAlreadyDeliveredThisTurn === true,
185
190
  }
186
191
  const tmpPath = join(outboxDir, `.${capture.turnNonce}.${process.pid}.tmp`)
187
192
  writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
@@ -242,13 +247,34 @@ function main() {
242
247
  if (process.env.SWITCHROOM_TG_OUTBOX_DELIVERY !== '0') try {
243
248
  const capture = scanForOutboxCapture(jsonl)
244
249
  if (capture.capture === true) {
245
- writeOutboxRecord(stateDir, capture)
246
- process.stderr.write(
247
- `[silent-end-interrupt] captured undelivered final answer to outbox ` +
248
- `(nonce=${capture.turnNonce} source=${capture.source} chars=${capture.text.length}) ` +
249
- `sweep will deliver; allowing stop\n`,
250
- )
251
- process.exit(0)
250
+ if (capture.replyAlreadyDeliveredThisTurn === true) {
251
+ // #3510: a qualifying reply ALREADY delivered through the gateway this
252
+ // turn, so a gateway anchor provably exists and the single-writer
253
+ // election below ('trailing-text-after-reply') is reachable. Writing an
254
+ // outbox record + self-exiting here would create a third, uncoordinated
255
+ // delivery path that bypasses the #3469 election and re-sends a
256
+ // trailing recap of the reply as a second (unformatted) message. Do NOT
257
+ // capture; fall through so the election is the actual single writer.
258
+ // The gateway-blind backstop (#3502) is untouched: it is the
259
+ // replyAlreadyDeliveredThisTurn === false branch below.
260
+ // Log both hashes so a double-send is provable from logs alone.
261
+ process.stderr.write(
262
+ `[silent-end-interrupt] capture found trailing prose after a delivered reply ` +
263
+ `(nonce=${capture.turnNonce} source=${capture.source} chars=${capture.text.length} ` +
264
+ `replyAlreadyDeliveredThisTurn=true ` +
265
+ `capturedTextSha256=${createHash('sha256').update(capture.text, 'utf8').digest('hex')} ` +
266
+ `deliveredReplySha256=${capture.deliveredReplySha256 ?? 'unknown'}) — ` +
267
+ `deferring to single-writer election (#3510)\n`,
268
+ )
269
+ } else {
270
+ writeOutboxRecord(stateDir, capture)
271
+ process.stderr.write(
272
+ `[silent-end-interrupt] captured undelivered final answer to outbox ` +
273
+ `(nonce=${capture.turnNonce} source=${capture.source} chars=${capture.text.length} ` +
274
+ `replyAlreadyDeliveredThisTurn=false) — sweep will deliver; allowing stop\n`,
275
+ )
276
+ process.exit(0)
277
+ }
252
278
  }
253
279
  } catch (err) {
254
280
  process.stderr.write(`[silent-end-interrupt] outbox capture error (fail-open): ${err.message}\n`)
@@ -894,7 +894,7 @@ function resolveSessionOriginChat(lines) {
894
894
  *
895
895
  * @param {string} jsonl
896
896
  * @param {number} [now]
897
- * @returns {{ capture: false, reason: string } | { capture: true, text: string, turnNonce: string, chatId: string|null, threadId: number|null, source: string, anchorContent: string }}
897
+ * @returns {{ capture: false, reason: string } | { capture: true, text: string, turnNonce: string, chatId: string|null, threadId: number|null, source: string, anchorContent: string, replyAlreadyDeliveredThisTurn: boolean, deliveredReplySha256: string|null }}
898
898
  */
899
899
  export function scanForOutboxCapture(jsonl, now = Date.now()) {
900
900
  const lines = jsonl.split('\n')
@@ -948,16 +948,46 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
948
948
  disableNotification: input.disable_notification === true,
949
949
  done: input.done === true,
950
950
  })) {
951
- blocks.push({ kind: 'deliver', reason: 'final-reply' })
951
+ // #3510: keep the delivered reply's text so the caller can log its
952
+ // sha256 next to the captured trailing prose's sha256 — a double-send
953
+ // becomes provable from logs alone, without transcript reconstruction.
954
+ blocks.push({ kind: 'deliver', reason: 'final-reply', text })
952
955
  }
953
956
  // interim ack — neither delivery nor undelivered prose.
954
957
  }
955
958
  }
956
959
 
957
960
  let lastDeliverIdx = -1
961
+ let lastFinalReplyBlock = null
958
962
  for (let i = 0; i < blocks.length; i++) {
959
- if (blocks[i].kind === 'deliver') lastDeliverIdx = i
963
+ if (blocks[i].kind !== 'deliver') continue
964
+ lastDeliverIdx = i
965
+ if (blocks[i].reason === 'final-reply') lastFinalReplyBlock = blocks[i]
960
966
  }
967
+ // #3510: single source of truth for BOTH the fix's branching and the
968
+ // instrumentation. When true, a genuine FINAL-ANSWER reply/stream_reply
969
+ // already went through the gateway THIS turn — so a gateway anchor provably
970
+ // exists (`turn.finalAnswerDelivered` is set by the SAME `isFinalAnswerReply`
971
+ // classifier at outbound-send-path.ts ~2293) and the single-writer election
972
+ // in the Stop hook is reachable and safe. The caller
973
+ // (silent-end-interrupt-stop.mjs) MUST NOT self-exit a capture in that case;
974
+ // doing so creates a third, uncoordinated delivery path that re-sends a
975
+ // trailing recap of the reply as a second message (the #3510 double-send).
976
+ //
977
+ // #3511 review finding 1: ONLY a `'final-reply'` deliver block counts. A
978
+ // `'silent-marker'` block (reply-tool NO_REPLY / HEARTBEAT_OK) delivered
979
+ // NOTHING to the user, and the gateway routes such turns to sentinel
980
+ // suppression (`decideTurnEndGate` → 'silent_end') BEFORE the captured-prose
981
+ // bridge ever runs — deferring to the election there would orphan the
982
+ // trailing answer (a drop). A silent marker still advances `lastDeliverIdx`
983
+ // (the trailing-prose CURSOR — H6 semantics: NO_REPLY intentionally silences
984
+ // what came BEFORE it), but it must keep the shape on the durable
985
+ // outbox/sweep path, never route it to the election.
986
+ const replyAlreadyDeliveredThisTurn = lastFinalReplyBlock != null
987
+ const deliveredReplySha256 =
988
+ lastFinalReplyBlock != null && typeof lastFinalReplyBlock.text === 'string'
989
+ ? createHash('sha256').update(lastFinalReplyBlock.text, 'utf8').digest('hex')
990
+ : null
961
991
  const trailing = blocks
962
992
  .slice(lastDeliverIdx + 1)
963
993
  .filter((b) => b.kind === 'text' && typeof b.text === 'string' && b.text.length > 0)
@@ -995,5 +1025,7 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
995
1025
  anchorContent: anchor.anchorContent,
996
1026
  originChatId: origin.chatId,
997
1027
  originThreadId: origin.threadId,
1028
+ replyAlreadyDeliveredThisTurn,
1029
+ deliveredReplySha256,
998
1030
  }
999
1031
  }
@@ -104,6 +104,15 @@ export interface OutboxRecord {
104
104
  originChatId?: string | null
105
105
  /** Forum thread of the per-session origin chat (see `originChatId`). */
106
106
  originThreadId?: number | null
107
+ /**
108
+ * #3510 instrumentation: was a qualifying reply already delivered through
109
+ * the gateway in the turn that produced this record? Stamped by the Stop
110
+ * hook from the SAME boolean that gates its capture-vs-election branch.
111
+ * After #3510 this is always `false` for a written record (a `true` routes
112
+ * to the single-writer election instead of the outbox), so a `true` here —
113
+ * or in a sweep journal entry — is direct evidence of a regression.
114
+ */
115
+ replyAlreadyDeliveredThisTurn?: boolean
107
116
  }
108
117
 
109
118
  /** One line of the delivered-keys journal (`outbox/delivered.jsonl`). */
@@ -112,6 +121,14 @@ export interface DeliveredEntry {
112
121
  textSha256: string
113
122
  tgMessageId?: number
114
123
  ts: number
124
+ /**
125
+ * #3510 instrumentation: which machine delivered — the outbox sweep, or the
126
+ * gateway reply-path machinery (reply/stream_reply send, silent-anchor edit,
127
+ * captured-prose bridge). Absent on pre-#3510 journal lines.
128
+ */
129
+ deliverySource?: 'sweep' | 'reply-tool'
130
+ /** #3510 instrumentation: see `OutboxRecord.replyAlreadyDeliveredThisTurn`. */
131
+ replyAlreadyDeliveredThisTurn?: boolean
115
132
  }
116
133
 
117
134
  export function sha256Hex(s: string): string {
@@ -0,0 +1,600 @@
1
+ /**
2
+ * outbox-reply-then-recap-e2e.test.ts — end-to-end regression net for #3510
3
+ * (Telegram double-send) AND its opposite failure mode (#3502 silent drop),
4
+ * driven from ONE harness through the real delivery decision path:
5
+ *
6
+ * transcript on disk
7
+ * → gateway reply-site simulation (send + conditional journal, mirroring
8
+ * `outbound-send-path.ts:2374-2376` — replies go out BEFORE Stop fires)
9
+ * → the REAL Stop hook (`hooks/silent-end-interrupt-stop.mjs`), spawned as
10
+ * a subprocess exactly as Claude Code runs it
11
+ * → the REAL `sweepOutbox` against the real on-disk outbox/journal, ticked
12
+ * repeatedly across the OUTBOX_QUIET_MS boundary (age-1 / age / age+1 /
13
+ * past the in-memory dedup TTL) to pressure-test the one-tick race
14
+ * window rather than a single happy-path tick.
15
+ *
16
+ * The oracle is deliberately FUNCTION-AGNOSTIC: assertions are on what the
17
+ * user observably received — how many messages, with what content, via which
18
+ * transport (`reply-tool` = the formatted reply; `sweep` = the plain-text
19
+ * outbox flush). No assertion names an internal decision function; renaming or
20
+ * refactoring the hook internals cannot green a real double-send or a real
21
+ * drop.
22
+ *
23
+ * Duplicate direction (#3510): a turn that delivers a reply and then emits a
24
+ * trailing prose recap (short / long / paraphrased — byte-exact dedup would
25
+ * miss the reworded ones) must yield EXACTLY ONE user-visible message: the
26
+ * formatted reply. On pre-fix main the sweep flushed the recap as a second,
27
+ * unformatted message.
28
+ *
29
+ * Silence direction (#3502 backstop): a gateway-blind turn (task-notification
30
+ * handback, cron, zero-reply channel turn) whose only answer is transcript
31
+ * prose must yield EXACTLY ONE delivery — never zero. This pins that the
32
+ * #3510 fix did not regress the 2026-07-22 data-loss class.
33
+ */
34
+
35
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
36
+ import { spawnSync } from 'node:child_process'
37
+ import {
38
+ mkdtempSync,
39
+ writeFileSync,
40
+ readdirSync,
41
+ readFileSync,
42
+ rmSync,
43
+ existsSync,
44
+ } from 'node:fs'
45
+ import { tmpdir } from 'node:os'
46
+ import { join, resolve } from 'node:path'
47
+ import { createHash } from 'node:crypto'
48
+
49
+ import { sweepOutbox, journalExternalDelivery } from '../gateway/outbox-sweep.js'
50
+ import { OUTBOX_QUIET_MS, type OutboxRecord, type DeliveredEntry } from '../outbox.js'
51
+ import { shouldJournalReplySiteDelivery, isFinalAnswerReply } from '../final-answer-detect.js'
52
+ import { decideTurnEndGate } from '../gateway/turn-end-gate.js'
53
+ import { deliverCapturedProse, type DeliverCapturedProseDeps } from '../gateway/outbound-send-path.js'
54
+ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
55
+ import type { FlushDecision } from '../turn-flush-safety.js'
56
+
57
+ const HOOK = resolve(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs')
58
+ const REPLY_TOOL = 'mcp__switchroom-telegram__reply'
59
+ const CHAT = '111'
60
+ const MSG_ID = '42'
61
+ /** The gateway's `deriveTurnId` nonce for the CHAT/MSG_ID envelope. */
62
+ const GATEWAY_NONCE = `${CHAT}:_#${MSG_ID}`
63
+ /** Mirrors the gateway's in-memory `outboundDedup` TTL (~60s). */
64
+ const DEDUP_TTL_MS = 60_000
65
+
66
+ const sha256 = (s: string) => createHash('sha256').update(s, 'utf8').digest('hex')
67
+
68
+ interface Delivered {
69
+ via: 'reply-tool' | 'sweep' | 'bridge'
70
+ text: string
71
+ }
72
+
73
+ /** Transcript-line builders. */
74
+ const enqueueChannel = (body: string, source = 'telegram', messageId = MSG_ID) => ({
75
+ type: 'queue-operation',
76
+ operation: 'enqueue',
77
+ content: `<channel source="${source}" chat_id="${CHAT}" message_id="${messageId}">${body}</channel>`,
78
+ timestamp: 1000,
79
+ })
80
+ const enqueueTask = () => ({
81
+ type: 'queue-operation',
82
+ operation: 'enqueue',
83
+ content: '<task-notification><task-id>a7cc7a0fa8f</task-id> worker done</task-notification>',
84
+ timestamp: 2000,
85
+ })
86
+ const prose = (text: string) => ({
87
+ type: 'assistant',
88
+ message: { content: [{ type: 'text', text }] },
89
+ })
90
+ const reply = (text: string, input: Record<string, unknown> = {}) => ({
91
+ type: 'assistant',
92
+ message: {
93
+ content: [{ type: 'tool_use', name: REPLY_TOOL, input: { chat_id: CHAT, text, ...input } }],
94
+ },
95
+ })
96
+
97
+ /**
98
+ * Run one full turn through the real delivery path. Returns everything the
99
+ * user received, in order, plus the on-disk journal/outbox for forensics.
100
+ */
101
+ async function runTurn(opts: {
102
+ dir: string
103
+ lines: object[]
104
+ gatewayAlive?: boolean
105
+ /** register a registry-chain hit for task anchors (default: unresolvable → origin route) */
106
+ registryHit?: boolean
107
+ }): Promise<{
108
+ delivered: Delivered[]
109
+ hookStdout: string
110
+ hookStderr: string
111
+ records: OutboxRecord[]
112
+ journal: DeliveredEntry[]
113
+ }> {
114
+ const { dir, lines, gatewayAlive = true } = opts
115
+ const delivered: Delivered[] = []
116
+
117
+ // 1. Gateway reply-site simulation — replies are sent (and conditionally
118
+ // journaled, same gate as outbound-send-path.ts:2374) BEFORE Stop fires.
119
+ for (const line of lines) {
120
+ const content = (line as { message?: { content?: Array<Record<string, unknown>> } }).message?.content
121
+ if (!Array.isArray(content)) continue
122
+ for (const c of content) {
123
+ if (c.type !== 'tool_use' || c.name !== REPLY_TOOL) continue
124
+ const input = c.input as { text?: string; done?: boolean; disable_notification?: boolean }
125
+ const text = String(input.text ?? '')
126
+ if (/^(NO_REPLY|HEARTBEAT_OK)[\s.!?]*$/i.test(text.trim())) continue
127
+ delivered.push({ via: 'reply-tool', text })
128
+ if (
129
+ shouldJournalReplySiteDelivery({
130
+ text,
131
+ disableNotification: input.disable_notification === true,
132
+ done: input.done === true,
133
+ })
134
+ ) {
135
+ journalExternalDelivery(
136
+ { turnNonce: GATEWAY_NONCE, text, tgMessageId: 1, replyAlreadyDeliveredThisTurn: true },
137
+ dir,
138
+ )
139
+ }
140
+ }
141
+ }
142
+
143
+ // 2. Gateway liveness heartbeat (fresh unless the case simulates a dead gateway).
144
+ if (gatewayAlive) writeFileSync(join(dir, 'gateway-heartbeat'), 'hb', 'utf8')
145
+
146
+ // 3. The REAL Stop hook, as a subprocess.
147
+ const transcriptPath = join(dir, 'transcript.jsonl')
148
+ writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n'), 'utf8')
149
+ const hook = spawnSync('node', [HOOK], {
150
+ input: JSON.stringify({ session_id: 's', transcript_path: transcriptPath }),
151
+ encoding: 'utf8',
152
+ timeout: 5000,
153
+ env: { ...process.env, TELEGRAM_STATE_DIR: dir },
154
+ })
155
+ expect(hook.status).toBe(0)
156
+
157
+ // 3.5 Gateway turn-end — the REAL gate + the REAL captured-prose bridge
158
+ // (#3511 review finding 2). `finalAnswerDelivered` is computed with the
159
+ // REAL `isFinalAnswerReply` classifier at the reply site — the exact code
160
+ // the gateway runs at outbound-send-path.ts ~2293 — and the disposition
161
+ // comes from the REAL `decideTurnEndGate`. When (and only when) the gate
162
+ // says 'reprompt' and the hook persisted `pendingText`, the REAL
163
+ // `deliverCapturedProse` runs against the same state dir/journal. This
164
+ // makes the load-bearing `isFinalAnswerReply` ⇔ `finalAnswerDelivered`
165
+ // agreement an asserted outcome: if the hook defers a shape the gateway
166
+ // does not consider delivered (or vice versa), the bridge fires and the
167
+ // exactly-one-message assertions go red.
168
+ // Only simulated for reply-called turns — a gateway-blind turn (no
169
+ // CurrentTurn) has no turn_end at all, which is the whole point of the
170
+ // outbox path.
171
+ const replyInputs: Array<{ text: string; done: boolean; disableNotification: boolean }> = []
172
+ for (const line of lines) {
173
+ const content = (line as { message?: { content?: Array<Record<string, unknown>> } }).message?.content
174
+ if (!Array.isArray(content)) continue
175
+ for (const c of content) {
176
+ if (c.type !== 'tool_use' || c.name !== REPLY_TOOL) continue
177
+ const input = c.input as { text?: string; done?: boolean; disable_notification?: boolean }
178
+ replyInputs.push({
179
+ text: String(input.text ?? ''),
180
+ done: input.done === true,
181
+ disableNotification: input.disable_notification === true,
182
+ })
183
+ }
184
+ }
185
+ const isSentinel = (t: string) => /^(NO_REPLY|HEARTBEAT_OK)[\s.!?]*$/i.test(t.trim())
186
+ const nonSentinelReplies = replyInputs.filter((r) => !isSentinel(r.text))
187
+ if (replyInputs.length > 0) {
188
+ // Same classifier + same site semantics as outbound-send-path.ts ~2293.
189
+ const finalAnswerDelivered = nonSentinelReplies.some((r) =>
190
+ isFinalAnswerReply({ text: r.text, disableNotification: r.disableNotification, done: r.done }),
191
+ )
192
+ // Sentinel-only turns take the gateway's sentinel-suppression path and
193
+ // return before the bridge (stream-render.ts ~1977 comment; verified live
194
+ // in the #3511 review). Reply-called turns take the 'reply-called' skip.
195
+ const flushDecision: FlushDecision =
196
+ nonSentinelReplies.length === 0
197
+ ? { kind: 'skip', reason: 'silent-marker' }
198
+ : { kind: 'skip', reason: 'reply-called' }
199
+ const gate = decideTurnEndGate({ flushDecision, finalAnswerDelivered })
200
+ if (gate === 'reprompt') {
201
+ const statePath = join(dir, 'silent-end-pending.json')
202
+ const pendingText = existsSync(statePath)
203
+ ? (JSON.parse(readFileSync(statePath, 'utf8')) as { pendingText?: string }).pendingText
204
+ : undefined
205
+ if (typeof pendingText === 'string' && pendingText.length > 0) {
206
+ // The gateway's silent-end machinery hands pendingText to the REAL bridge.
207
+ const dedup = new OutboundDedupCache()
208
+ for (const r of nonSentinelReplies) dedup.record(CHAT, undefined, r.text, Date.now(), null)
209
+ const bridgeDeps: DeliverCapturedProseDeps = {
210
+ outboundDedup: dedup,
211
+ bot: {
212
+ api: {
213
+ sendRichMessage: async (_chatId: string, rich: { markdown?: string }, _opts: object) => {
214
+ delivered.push({ via: 'bridge', text: rich.markdown ?? '' })
215
+ return { message_id: 2000 }
216
+ },
217
+ },
218
+ } as unknown as DeliverCapturedProseDeps['bot'],
219
+ robustApiCall: (fn) => fn(),
220
+ redactOutboundText: (text) => text,
221
+ recordOutbound: () => {},
222
+ HISTORY_ENABLED: false,
223
+ OBLIGATION_LEDGER_ENABLED: false,
224
+ obligationLedger: { close: () => {} },
225
+ clearSilentEndState: () => {},
226
+ recordUndeliveredTurnEnd: () => ({ exhausted: false }),
227
+ hasOutboundDeliveredSince: () => false,
228
+ }
229
+ // deliverCapturedProse journals via TELEGRAM_STATE_DIR — point it at
230
+ // this test's isolated state dir for the duration of the call.
231
+ const prevStateDir = process.env.TELEGRAM_STATE_DIR
232
+ process.env.TELEGRAM_STATE_DIR = dir
233
+ try {
234
+ await deliverCapturedProse(bridgeDeps, {
235
+ chatId: CHAT,
236
+ threadId: undefined,
237
+ statusKeyStr: `${CHAT}:main`,
238
+ registryKey: null,
239
+ originTurnId: GATEWAY_NONCE,
240
+ text: pendingText,
241
+ })
242
+ } finally {
243
+ if (prevStateDir == null) delete process.env.TELEGRAM_STATE_DIR
244
+ else process.env.TELEGRAM_STATE_DIR = prevStateDir
245
+ }
246
+ }
247
+ }
248
+ }
249
+
250
+ // 4. The REAL sweep, ticked aggressively across the quiet-window boundary.
251
+ // Tick times are anchored on the record's actual createdAt when one
252
+ // exists, so the boundary (age === quietMs - 1 / quietMs / quietMs + 1)
253
+ // is exercised exactly — an ordering regression is caught, not masked.
254
+ const recordsBefore = readOutboxRecords(dir)
255
+ const t0 = recordsBefore.length > 0 ? recordsBefore[0].createdAt : Date.now()
256
+ const tickTimes = [
257
+ t0, // age 0 — inside quiet window
258
+ t0 + OUTBOX_QUIET_MS - 1, // one ms before eligibility
259
+ t0 + OUTBOX_QUIET_MS, // boundary
260
+ t0 + OUTBOX_QUIET_MS + 1, // just past
261
+ t0 + DEDUP_TTL_MS + 1_000, // past the in-memory dedup TTL — journal must hold alone
262
+ ]
263
+ const sentLog: Array<{ text: string; at: number }> = []
264
+ for (const now of tickTimes) {
265
+ await sweepOutbox({
266
+ stateDir: dir,
267
+ now: () => now,
268
+ send: async (_chatId, _threadId, text) => {
269
+ delivered.push({ via: 'sweep', text })
270
+ sentLog.push({ text, at: now })
271
+ return 500
272
+ },
273
+ // Mirrors the gateway's TTL-bounded exact-text `outboundDedup` cache:
274
+ // anything sent (reply or sweep) within the TTL dedups; older evicts.
275
+ textAlreadyDelivered: (_chatId, _threadId, text) =>
276
+ delivered.some(
277
+ (d) =>
278
+ d.text === text &&
279
+ (d.via === 'reply-tool' || sentLog.some((s) => s.text === text && now - s.at < DEDUP_TTL_MS)),
280
+ ),
281
+ registryChainLookup: opts.registryHit ? () => ({ chatId: CHAT, threadId: null }) : () => null,
282
+ })
283
+ }
284
+
285
+ return {
286
+ delivered,
287
+ hookStdout: hook.stdout ?? '',
288
+ hookStderr: hook.stderr ?? '',
289
+ records: readOutboxRecords(dir),
290
+ journal: readJournal(dir),
291
+ }
292
+ }
293
+
294
+ function readOutboxRecords(dir: string): OutboxRecord[] {
295
+ const outbox = join(dir, 'outbox')
296
+ try {
297
+ return readdirSync(outbox)
298
+ .filter((f) => f.endsWith('.json') && f !== 'delivered.jsonl' && !f.startsWith('.'))
299
+ .map((f) => JSON.parse(readFileSync(join(outbox, f), 'utf8')) as OutboxRecord)
300
+ } catch {
301
+ return []
302
+ }
303
+ }
304
+
305
+ function readJournal(dir: string): DeliveredEntry[] {
306
+ const p = join(dir, 'outbox', 'delivered.jsonl')
307
+ if (!existsSync(p)) return []
308
+ return readFileSync(p, 'utf8')
309
+ .split('\n')
310
+ .filter((l) => l.length > 0)
311
+ .map((l) => JSON.parse(l) as DeliveredEntry)
312
+ }
313
+
314
+ // Reply/recap fixtures. The short reply pings (no disable_notification, no
315
+ // done) → user-visible final answer, but under the 200-char reply-site journal
316
+ // floor — the exact #3510 window (R1 High #1 + #2).
317
+ const SHORT_REPLY = 'Deployed the fix to staging — all 3 checks green. ' + 'Detail: '.padEnd(100, 'x')
318
+ const LONG_REPLY = 'Full answer with plenty of substance. '.padEnd(250, 'y')
319
+ const RECAP_LONG = 'To recap what I just sent: the fix is deployed to staging and all checks are green. '.padEnd(300, 'z')
320
+ const RECAP_PARAPHRASE =
321
+ 'Summary of the work above, in different words than the reply so byte-exact dedup can never match it. '.padEnd(400, 'w')
322
+ const RECAP_SHORT = 'Short recap under the floor.'
323
+ const REAL_ANSWER = 'The actual final answer that never went through a reply tool call. '.padEnd(300, 'a')
324
+ const HANDBACK_ANSWER = 'B'.repeat(1647)
325
+
326
+ describe('#3510 e2e — reply-then-recap turns yield EXACTLY ONE user-visible message', () => {
327
+ let dir: string
328
+ beforeEach(() => {
329
+ dir = mkdtempSync(join(tmpdir(), 'outbox-e2e-'))
330
+ })
331
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
332
+
333
+ // Table: every duplicate-direction shape must end with exactly the formatted
334
+ // reply — never a second sweep flush, whatever the recap's wording/length.
335
+ const duplicateCases: Array<{ name: string; replyText: string; replyInput?: Record<string, unknown>; recap: string }> = [
336
+ { name: 'short ping-final reply + long recap', replyText: SHORT_REPLY, recap: RECAP_LONG },
337
+ { name: 'short ping-final reply + paraphrased recap (defeats byte-exact dedup)', replyText: SHORT_REPLY, recap: RECAP_PARAPHRASE },
338
+ { name: 'short ping-final reply + short recap (below capture floor)', replyText: SHORT_REPLY, recap: RECAP_SHORT },
339
+ { name: 'substantive journaled reply + reworded recap', replyText: LONG_REPLY, recap: RECAP_PARAPHRASE },
340
+ { name: 'done:true reply + long recap', replyText: SHORT_REPLY, replyInput: { done: true }, recap: RECAP_LONG },
341
+ ]
342
+
343
+ for (const c of duplicateCases) {
344
+ it(c.name, async () => {
345
+ const r = await runTurn({
346
+ dir,
347
+ lines: [enqueueChannel('deploy it'), reply(c.replyText, c.replyInput), prose(c.recap)],
348
+ })
349
+ // THE user outcome: one message, it is the reply (formatted transport),
350
+ // and no sweep flush ever fires for this turn.
351
+ expect(r.delivered).toEqual([{ via: 'reply-tool', text: c.replyText }])
352
+ // Durable state agrees: nothing pending for the sweep to resurrect later.
353
+ expect(r.records).toHaveLength(0)
354
+ })
355
+ }
356
+
357
+ it('dead gateway: still never a second message (worst case is a re-prompt, not a duplicate)', async () => {
358
+ const r = await runTurn({
359
+ dir,
360
+ lines: [enqueueChannel('deploy it'), reply(SHORT_REPLY), prose(RECAP_LONG)],
361
+ gatewayAlive: false,
362
+ })
363
+ expect(r.delivered).toEqual([{ via: 'reply-tool', text: SHORT_REPLY }])
364
+ expect(r.records).toHaveLength(0)
365
+ })
366
+
367
+ it('logs both SHAs when deferring, so a double-send is provable from logs alone', async () => {
368
+ const r = await runTurn({
369
+ dir,
370
+ lines: [enqueueChannel('deploy it'), reply(SHORT_REPLY), prose(RECAP_LONG)],
371
+ })
372
+ expect(r.hookStderr).toContain('replyAlreadyDeliveredThisTurn=true')
373
+ expect(r.hookStderr).toContain(`capturedTextSha256=${sha256(RECAP_LONG.trim())}`)
374
+ expect(r.hookStderr).toContain(`deliveredReplySha256=${sha256(SHORT_REPLY)}`)
375
+ })
376
+ })
377
+
378
+ describe('#3502 backstop e2e — gateway-blind turns yield EXACTLY ONE delivery, never zero', () => {
379
+ let dir: string
380
+ beforeEach(() => {
381
+ dir = mkdtempSync(join(tmpdir(), 'outbox-e2e-'))
382
+ })
383
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
384
+
385
+ it('task-notification handback with prose-only answer (2026-07-22 incident shape) is delivered once', async () => {
386
+ const r = await runTurn({
387
+ dir,
388
+ // A prior real channel inbound gives the session its origin chat (F2).
389
+ lines: [enqueueChannel('earlier question', 'telegram', '7'), enqueueTask(), prose(HANDBACK_ANSWER)],
390
+ })
391
+ const sweeps = r.delivered.filter((d) => d.via === 'sweep')
392
+ expect(sweeps).toHaveLength(1)
393
+ expect(sweeps[0].text).toContain(HANDBACK_ANSWER)
394
+ expect(r.delivered).toHaveLength(1) // and nothing else — exactly one delivery
395
+ expect(r.records).toHaveLength(0) // record consumed, not resurrectable
396
+ })
397
+
398
+ it('cron turn ending in prose is delivered once', async () => {
399
+ const r = await runTurn({
400
+ dir,
401
+ lines: [enqueueChannel('digest time', 'cron', '9'), prose(REAL_ANSWER)],
402
+ })
403
+ expect(r.delivered).toEqual([{ via: 'sweep', text: REAL_ANSWER.trim() }])
404
+ })
405
+
406
+ it('zero-reply channel turn ending in prose is delivered once', async () => {
407
+ const r = await runTurn({ dir, lines: [enqueueChannel('question'), prose(REAL_ANSWER)] })
408
+ expect(r.delivered).toEqual([{ via: 'sweep', text: REAL_ANSWER.trim() }])
409
+ })
410
+
411
+ it('interim ack then a real prose answer: the answer is delivered exactly once (not suppressed by the ack)', async () => {
412
+ const ack = 'On it — digging in now.'
413
+ const r = await runTurn({
414
+ dir,
415
+ lines: [
416
+ enqueueChannel('question'),
417
+ reply(ack, { disable_notification: true }), // interim ack: silent, short, not done
418
+ prose(REAL_ANSWER),
419
+ ],
420
+ })
421
+ // User sees the ack (sent live) and the recovered answer — once each.
422
+ expect(r.delivered).toEqual([
423
+ { via: 'reply-tool', text: ack },
424
+ { via: 'sweep', text: REAL_ANSWER.trim() },
425
+ ])
426
+ })
427
+
428
+ it('pure NO_REPLY turn delivers nothing', async () => {
429
+ const r = await runTurn({ dir, lines: [enqueueChannel('ping'), prose('NO_REPLY')] })
430
+ expect(r.delivered).toHaveLength(0)
431
+ })
432
+
433
+ it('reply-tool NO_REPLY then a substantive prose answer: the answer is delivered exactly once (#3511 finding 1)', async () => {
434
+ // The silence regression the #3511 review caught: a `reply("NO_REPLY")`
435
+ // delivered NOTHING to the user, and the gateway suppresses sentinel turns
436
+ // BEFORE the bridge can run — so if the hook treats the sentinel as
437
+ // "reply already delivered" and defers to the election, the trailing
438
+ // answer is orphaned and DROPPED. The discriminator must count only
439
+ // genuine final-reply deliveries, keeping this shape on the durable
440
+ // outbox/sweep path.
441
+ const r = await runTurn({
442
+ dir,
443
+ lines: [enqueueChannel('question'), reply('NO_REPLY'), prose(REAL_ANSWER)],
444
+ })
445
+ expect(r.delivered).toEqual([{ via: 'sweep', text: REAL_ANSWER.trim() }])
446
+ // Durable path was used: the record existed and was consumed, not elected.
447
+ expect(r.journal.filter((e) => e.deliverySource === 'sweep')).toHaveLength(1)
448
+ expect(r.journal[r.journal.length - 1].replyAlreadyDeliveredThisTurn).toBe(false)
449
+ })
450
+
451
+ it('reply-tool NO_REPLY then prose survives a dead gateway (outbox is gateway-liveness-independent)', async () => {
452
+ // The old capture path's durability property must hold for this shape:
453
+ // no heartbeat, no election — the record is still written and swept.
454
+ const r = await runTurn({
455
+ dir,
456
+ lines: [enqueueChannel('question'), reply('NO_REPLY'), prose(REAL_ANSWER)],
457
+ gatewayAlive: false,
458
+ })
459
+ expect(r.delivered).toEqual([{ via: 'sweep', text: REAL_ANSWER.trim() }])
460
+ })
461
+ })
462
+
463
+ describe('#3511 finding 2 — the gateway BRIDGE path agrees with the hook (paraphrase safe end to end)', () => {
464
+ let dir: string
465
+ beforeEach(() => {
466
+ dir = mkdtempSync(join(tmpdir(), 'outbox-e2e-'))
467
+ })
468
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
469
+
470
+ it('genuine reply + paraphrased recap: pendingText IS persisted for the bridge, yet the bridge never fires — exactly one message', async () => {
471
+ // The load-bearing invariant: the hook's fall-through classifier
472
+ // (isFinalAnswerReply on the deliver block) and the gateway's bridge gate
473
+ // (turn.finalAnswerDelivered, set by the SAME classifier) must agree. The
474
+ // harness computes finalAnswerDelivered with the real classifier and runs
475
+ // the real decideTurnEndGate + real deliverCapturedProse when it says
476
+ // 'reprompt' — so if either side of the agreement breaks, the paraphrased
477
+ // recap goes out via the bridge and this goes red with 2 messages.
478
+ const r = await runTurn({
479
+ dir,
480
+ lines: [enqueueChannel('deploy it'), reply(SHORT_REPLY), prose(RECAP_PARAPHRASE)],
481
+ })
482
+ // The election really did elect the bridge as the would-be single writer…
483
+ const state = JSON.parse(readFileSync(join(dir, 'silent-end-pending.json'), 'utf8'))
484
+ expect(state.pendingText).toBe(RECAP_PARAPHRASE.trim())
485
+ // …but the gateway's gate (finalAnswerDelivered=true, same classifier)
486
+ // keeps the bridge off: the user got exactly the formatted reply.
487
+ expect(r.delivered).toEqual([{ via: 'reply-tool', text: SHORT_REPLY }])
488
+ expect(r.delivered.filter((d) => d.via === 'bridge')).toHaveLength(0)
489
+ })
490
+
491
+ it('interim ack + real answer: the bridge-eligible turn still yields the answer exactly once', async () => {
492
+ // Counter-case proving the bridge stage in the harness is live, not inert:
493
+ // an ack-only turn has finalAnswerDelivered=false (real classifier), the
494
+ // gate says reprompt — but the hook put the answer on the OUTBOX path
495
+ // (no pendingText persisted), so the sweep delivers and the bridge stays
496
+ // quiet. One ack + one answer, no third copy from any machine.
497
+ const ack = 'On it.'
498
+ const r = await runTurn({
499
+ dir,
500
+ lines: [enqueueChannel('question'), reply(ack, { disable_notification: true }), prose(REAL_ANSWER)],
501
+ })
502
+ const answers = r.delivered.filter((d) => d.text.includes(REAL_ANSWER.trim()))
503
+ expect(answers).toHaveLength(1)
504
+ expect(answers[0].via).toBe('sweep')
505
+ })
506
+ })
507
+
508
+ describe('#3510 instrumentation — the journal alone proves who delivered what', () => {
509
+ let dir: string
510
+ beforeEach(() => {
511
+ dir = mkdtempSync(join(tmpdir(), 'outbox-e2e-'))
512
+ })
513
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
514
+
515
+ it('sweep deliveries journal deliverySource=sweep with the record capture-time flag', async () => {
516
+ const r = await runTurn({
517
+ dir,
518
+ lines: [enqueueChannel('question'), prose(REAL_ANSWER)],
519
+ })
520
+ const sweepEntries = r.journal.filter((e) => e.deliverySource === 'sweep')
521
+ expect(sweepEntries).toHaveLength(1)
522
+ expect(sweepEntries[0].replyAlreadyDeliveredThisTurn).toBe(false)
523
+ expect(sweepEntries[0].textSha256).toBe(sha256(REAL_ANSWER.trim()))
524
+ })
525
+
526
+ it('reply-site deliveries journal deliverySource=reply-tool with replyAlreadyDeliveredThisTurn=true', async () => {
527
+ const r = await runTurn({
528
+ dir,
529
+ lines: [enqueueChannel('question'), reply(LONG_REPLY), prose(RECAP_PARAPHRASE)],
530
+ })
531
+ const replyEntries = r.journal.filter((e) => e.deliverySource === 'reply-tool')
532
+ expect(replyEntries).toHaveLength(1)
533
+ expect(replyEntries[0].replyAlreadyDeliveredThisTurn).toBe(true)
534
+ expect(replyEntries[0].textSha256).toBe(sha256(LONG_REPLY))
535
+ // And no sweep entry exists for the turn — one writer, one journal line.
536
+ expect(r.journal.filter((e) => e.deliverySource === 'sweep')).toHaveLength(0)
537
+ })
538
+
539
+ it('a bridge delivery journals replyAlreadyDeliveredThisTurn=false (#3511 finding 3)', async () => {
540
+ // The bridge only runs when no genuine final answer was delivered
541
+ // (decideTurnEndGate 'reprompt' requires finalAnswerDelivered === false),
542
+ // so its journal line must stamp the flag false — making a bridge
543
+ // double-send after a reply provable from the journal alone.
544
+ const dedup = new OutboundDedupCache()
545
+ const deps: DeliverCapturedProseDeps = {
546
+ outboundDedup: dedup,
547
+ bot: {
548
+ api: { sendRichMessage: async () => ({ message_id: 3000 }) },
549
+ } as unknown as DeliverCapturedProseDeps['bot'],
550
+ robustApiCall: (fn) => fn(),
551
+ redactOutboundText: (text) => text,
552
+ recordOutbound: () => {},
553
+ HISTORY_ENABLED: false,
554
+ OBLIGATION_LEDGER_ENABLED: false,
555
+ obligationLedger: { close: () => {} },
556
+ clearSilentEndState: () => {},
557
+ recordUndeliveredTurnEnd: () => ({ exhausted: false }),
558
+ hasOutboundDeliveredSince: () => false,
559
+ }
560
+ const prevStateDir = process.env.TELEGRAM_STATE_DIR
561
+ process.env.TELEGRAM_STATE_DIR = dir
562
+ try {
563
+ await deliverCapturedProse(deps, {
564
+ chatId: CHAT,
565
+ threadId: undefined,
566
+ statusKeyStr: `${CHAT}:main`,
567
+ registryKey: null,
568
+ originTurnId: GATEWAY_NONCE,
569
+ text: REAL_ANSWER,
570
+ })
571
+ } finally {
572
+ if (prevStateDir == null) delete process.env.TELEGRAM_STATE_DIR
573
+ else process.env.TELEGRAM_STATE_DIR = prevStateDir
574
+ }
575
+ const journal = readJournal(dir)
576
+ expect(journal).toHaveLength(1)
577
+ expect(journal[0].deliverySource).toBe('reply-tool')
578
+ expect(journal[0].replyAlreadyDeliveredThisTurn).toBe(false)
579
+ })
580
+
581
+ it('a captured (gateway-blind) record carries replyAlreadyDeliveredThisTurn=false on disk', async () => {
582
+ // Run only the hook (no sweep) so the record is observable before delivery.
583
+ const transcriptPath = join(dir, 'transcript.jsonl')
584
+ writeFileSync(
585
+ transcriptPath,
586
+ [enqueueTask(), prose(HANDBACK_ANSWER)].map((l) => JSON.stringify(l)).join('\n'),
587
+ 'utf8',
588
+ )
589
+ const hook = spawnSync('node', [HOOK], {
590
+ input: JSON.stringify({ session_id: 's', transcript_path: transcriptPath }),
591
+ encoding: 'utf8',
592
+ timeout: 5000,
593
+ env: { ...process.env, TELEGRAM_STATE_DIR: dir },
594
+ })
595
+ expect(hook.status).toBe(0)
596
+ const records = readOutboxRecords(dir)
597
+ expect(records).toHaveLength(1)
598
+ expect(records[0].replyAlreadyDeliveredThisTurn).toBe(false)
599
+ })
600
+ })
@@ -201,12 +201,18 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
201
201
  expect(state.retryCount).toBe(SILENT_END_MAX_RETRIES)
202
202
  })
203
203
 
204
- it('captures the trailing verdict to the outbox + allows when an early qualifying reply is followed by an undelivered verdict (trailing-content bug repro, collapsed design)', () => {
205
- // Confirmed-incident shape: (1) a background-task notification arrives,
206
- // (2) the agent calls reply ONCE early with a notification-bearing ack,
207
- // (3) it then writes a large substantive verdict as plain assistant text
208
- // with NO second reply call. The trailing verdict is now CAPTURED to the
209
- // outbox and the stop is ALLOWED the sweep delivers it deterministically.
204
+ it('routes the trailing verdict through the single-writer election (NOT the outbox) when an early qualifying reply already delivered (#3510)', () => {
205
+ // Pre-#3510 this shape (1) inbound, (2) an early notification-bearing
206
+ // (ping-final) reply, (3) a large trailing verdict as plain text — was
207
+ // CAPTURED to the outbox and self-exited, bypassing the #3469 election.
208
+ // Because the ping-final reply is delivered by the gateway but sits under
209
+ // the reply-site journal floor, the sweep then flushed the trailing prose
210
+ // as a SECOND message (the #3510 double-send). Now: a reply already
211
+ // delivered this turn ⇒ NO outbox record; the trailing prose goes through
212
+ // `decideStopHookDisposition`'s 'trailing-text-after-reply' branch, whose
213
+ // captured-prose bridge is the single writer (fresh heartbeat ⇒ elected
214
+ // allow, pendingText persisted as the bridge's input).
215
+ writeFileSync(join(stateDir, 'gateway-heartbeat'), 'hb', 'utf8')
210
216
  const transcript = writeTranscript(tmp, [
211
217
  ENQUEUE,
212
218
  reply('running now'),
@@ -222,11 +228,13 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
222
228
  stateDir,
223
229
  })
224
230
  expect(r.status).toBe(0)
225
- expect(r.stdout.trim()).toBe('')
226
- const rec = readOutboxRecord(stateDir)
227
- expect(rec).not.toBeNull()
228
- expect(rec!.text).toBe('Here is the actual verdict: ' + 'X'.repeat(300))
229
- expect(rec!.turnNonce).toBe('111:_#42')
231
+ expect(r.stdout.trim()).toBe('') // elected allow — no re-prompt
232
+ expect(r.stderr).toMatch(/replyAlreadyDeliveredThisTurn=true/)
233
+ // No outbox record: the sweep must have nothing to flush as a second message.
234
+ expect(readOutboxRecord(stateDir)).toBeNull()
235
+ // The verdict is handed to the bridge (single writer) via the state file.
236
+ const state = JSON.parse(readFileSync(join(stateDir, 'silent-end-pending.json'), 'utf8'))
237
+ expect(state.pendingText).toBe('Here is the actual verdict: ' + 'X'.repeat(300))
230
238
  })
231
239
 
232
240
  it('does NOT false-positive on a normal single-reply turn ending on the reply tool_use', () => {