switchroom 0.19.13 → 0.19.15

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.
Files changed (35) hide show
  1. package/dist/cli/switchroom.js +1 -1
  2. package/dist/host-control/main.js +4 -2
  3. package/package.json +1 -1
  4. package/telegram-plugin/bridge/bridge.ts +1 -1
  5. package/telegram-plugin/dist/bridge/bridge.js +1 -1
  6. package/telegram-plugin/dist/gateway/gateway.js +1027 -509
  7. package/telegram-plugin/dist/server.js +1 -1
  8. package/telegram-plugin/gateway/forward-origin.ts +6 -1
  9. package/telegram-plugin/gateway/gateway.ts +4 -0
  10. package/telegram-plugin/gateway/narrative-lane.ts +11 -0
  11. package/telegram-plugin/gateway/outbound-send-path.ts +9 -3
  12. package/telegram-plugin/gateway/outbox-sweep.ts +73 -5
  13. package/telegram-plugin/gateway/rich-message-handler.ts +235 -0
  14. package/telegram-plugin/gateway/stream-render.ts +107 -15
  15. package/telegram-plugin/gateway/unhandled-message.ts +14 -0
  16. package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
  17. package/telegram-plugin/hooks/narration-classify.mjs +210 -0
  18. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +33 -7
  19. package/telegram-plugin/hooks/silent-end-scan.mjs +171 -85
  20. package/telegram-plugin/narrative-flush.ts +35 -0
  21. package/telegram-plugin/outbox.ts +87 -0
  22. package/telegram-plugin/shown-ledger.ts +145 -0
  23. package/telegram-plugin/silent-end.ts +42 -0
  24. package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
  25. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
  26. package/telegram-plugin/tests/forward-origin.test.ts +20 -0
  27. package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
  28. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
  29. package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
  30. package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +600 -0
  31. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +19 -11
  32. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
  33. package/telegram-plugin/tests/silent-end.test.ts +7 -1
  34. package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
  35. package/telegram-plugin/turn-flush-safety.ts +66 -53
@@ -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', () => {