switchroom 0.19.41 → 0.19.43

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 (90) hide show
  1. package/dist/agent-scheduler/index.js +23 -3
  2. package/dist/auth-broker/index.js +54 -9
  3. package/dist/cli/notion-write-pretool.mjs +23 -3
  4. package/dist/cli/switchroom.js +1956 -900
  5. package/dist/host-control/main.js +227 -30
  6. package/dist/vault/approvals/kernel-server.js +54 -9
  7. package/dist/vault/broker/server.js +54 -9
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +82 -4
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/card-layout.ts +63 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +4 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +1318 -693
  14. package/telegram-plugin/dist/server.js +5 -2
  15. package/telegram-plugin/flood-429-ledger.ts +5 -3
  16. package/telegram-plugin/flood-circuit-breaker.ts +11 -3
  17. package/telegram-plugin/format.ts +223 -13
  18. package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
  19. package/telegram-plugin/gateway/boot-card.ts +7 -2
  20. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  21. package/telegram-plugin/gateway/gateway.ts +56 -58
  22. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  23. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  24. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  25. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  26. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  27. package/telegram-plugin/gateway/model-command.ts +21 -109
  28. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  29. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  30. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  31. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  32. package/telegram-plugin/gateway/stream-render.ts +62 -0
  33. package/telegram-plugin/gateway/update-announce.ts +11 -4
  34. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  35. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  36. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  37. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  38. package/telegram-plugin/outbox.ts +103 -2
  39. package/telegram-plugin/render/rich-render.ts +10 -2
  40. package/telegram-plugin/reply-quote.ts +150 -0
  41. package/telegram-plugin/retry-api-call.ts +254 -11
  42. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  43. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  44. package/telegram-plugin/silent-end.ts +34 -2
  45. package/telegram-plugin/status-no-truncate.ts +31 -16
  46. package/telegram-plugin/stream-controller.ts +44 -9
  47. package/telegram-plugin/stream-reply-handler.ts +5 -3
  48. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  49. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  50. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  51. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  52. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  53. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  54. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  55. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  56. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  57. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  58. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  59. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  60. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  61. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  62. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  63. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  64. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  65. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  66. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  67. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  68. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  69. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  70. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  71. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  72. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  73. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  74. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  75. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  76. package/telegram-plugin/tool-activity-summary.ts +58 -30
  77. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  78. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  79. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  80. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  81. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  82. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  83. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  84. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  85. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  88. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  90. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -0,0 +1,1126 @@
1
+ /**
2
+ * outbox-provenance-4141.test.ts — the FOREGROUND reply-throw case (issue
3
+ * #4141, follow-up to #3865 / PR #4140).
4
+ *
5
+ * THE GAP THIS GUARDS
6
+ * -------------------
7
+ * #4140's audience gate marks a record `'internal'` only on positive evidence:
8
+ * the reply tool threw AND no inbound obligation is open. An obligation only
9
+ * closes on a substantive DELIVERED reply (`gateway/obligation-ledger.ts:153`),
10
+ * so on a FOREGROUND Telegram turn where the reply tool threw, the obligation
11
+ * is still OPEN, the record classifies `'user'`, and the agent's trailing
12
+ * working notes are delivered to the waiting human presented as if they were
13
+ * the answer.
14
+ *
15
+ * THE DESIGN UNDER TEST — FRAMING, NOT SUPPRESSION
16
+ * -----------------------------------------------
17
+ * Suppressing here would convert a visible wrong-content failure into an
18
+ * invisible no-answer one, against a human who is provably still waiting. So
19
+ * the rule is: state the provenance in front of the text and deliver it. The
20
+ * two invariants this file exists to hold are therefore:
21
+ *
22
+ * A. FRAMING NEVER SILENCES. Every framed case sends exactly as many
23
+ * chat-visible calls as the unframed case did, and the body still
24
+ * contains the captured prose verbatim.
25
+ * B. FRAMING IS OBSERVABLE. Every framed delivery carries a durable terminal
26
+ * stamp on the journal line plus one structured telemetry line.
27
+ *
28
+ * Every assertion drives REAL machinery: the REAL Stop hook spawned as a
29
+ * subprocess against a REAL transcript, the REAL `sweepOutbox` with the REAL
30
+ * `createOutboxSend` adapter against a RECORDING fake Bot API, the REAL flood
31
+ * producer, and the REAL fleet-health detectors.
32
+ */
33
+
34
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
35
+ import { spawnSync } from 'node:child_process'
36
+ import {
37
+ mkdtempSync,
38
+ mkdirSync,
39
+ writeFileSync,
40
+ readdirSync,
41
+ readFileSync,
42
+ existsSync,
43
+ rmSync,
44
+ } from 'node:fs'
45
+ import { tmpdir } from 'node:os'
46
+ import { join, resolve } from 'node:path'
47
+
48
+ import { sweepOutbox, createOutboxSend, turnIdFromNonce } from '../gateway/outbox-sweep.js'
49
+ import { decideOutboxSweep, sha256Hex } from '../outbox.js'
50
+ import { queueFloodBlockedReply } from '../gateway/flood-reply-queue.js'
51
+ import {
52
+ REPLY_THROW_PROVENANCE_NOTICE,
53
+ applyReplyThrowFraming,
54
+ decideCaptureAudience,
55
+ formatReplyThrowFraming,
56
+ resolveOpenObligation,
57
+ shouldFrameReplyThrow,
58
+ } from '../hooks/audience-classify.mjs'
59
+ import { scanForOutboxCapture } from '../hooks/silent-end-scan.mjs'
60
+ import { deliverCapturedProse } from '../gateway/outbound-send-path.js'
61
+ import { decideCapturedProseDelivery } from '../silent-end.js'
62
+ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
63
+ import { decideTurnFlush } from '../turn-flush-safety.js'
64
+ import { buildTurnRecord } from '../gateway/turn-record-status.js'
65
+ import {
66
+ detectTurnFindings,
67
+ detectGatewayFindings,
68
+ extractTurnId,
69
+ GATEWAY_SIGNATURES,
70
+ } from '../../src/fleet-health/detect.js'
71
+
72
+ const HOOK = resolve(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs')
73
+
74
+ // Synthetic ids only (check-no-pii-secrets forbids real chat/user ids).
75
+ const DM_CHAT = '5550001'
76
+ const INBOUND_MSG_ID = 7311
77
+ const NONCE = `${DM_CHAT}:_#${INBOUND_MSG_ID}`
78
+
79
+ /** Internal working notes — the text that must never masquerade as an answer. */
80
+ const WORKING_NOTES = [
81
+ 'Dispatched the worker onto the rebase and it came back clean, so the next',
82
+ 'step is to re-run the scoped suite before touching the sweep at all. If the',
83
+ 'suite is still red after that I will bisect from the merge base rather than',
84
+ 'guessing, and I should double-check whether the earlier claim about the',
85
+ 'adapter ordering actually holds, because I never verified it against HEAD.',
86
+ ].join(' ')
87
+
88
+ /**
89
+ * A genuine answer written as plain prose AFTER the reply tool errored — the
90
+ * shape that makes suppression unsafe here. Structurally indistinguishable
91
+ * from WORKING_NOTES to every classifier in this pipeline.
92
+ */
93
+ const REAL_ANSWER = [
94
+ 'The rebase landed on the merge base and the suite is green. Two files moved:',
95
+ 'the sweep now checks the record before it routes anything, and the capture',
96
+ 'hook stamps the field it checks. Nothing else changed, and the flood queue',
97
+ 'behaves exactly as it did before because it stamps the same value it implied.',
98
+ ].join(' ')
99
+
100
+ function makeStateDir(): string {
101
+ // NEVER ~/.switchroom — a test that writes there corrupts production state.
102
+ return mkdtempSync(join(tmpdir(), 'w1d-provenance-'))
103
+ }
104
+
105
+ function writeTranscript(dir: string, lines: object[]): string {
106
+ const p = join(dir, 'transcript.jsonl')
107
+ writeFileSync(p, lines.map((l) => JSON.stringify(l)).join('\n'), 'utf8')
108
+ return p
109
+ }
110
+
111
+ function runHook(
112
+ transcriptPath: string,
113
+ stateDir: string,
114
+ extraEnv: Record<string, string> = {},
115
+ ) {
116
+ return spawnSync('node', [HOOK], {
117
+ input: JSON.stringify({ session_id: 's', transcript_path: transcriptPath }),
118
+ encoding: 'utf8',
119
+ timeout: 10_000,
120
+ env: { ...process.env, TELEGRAM_STATE_DIR: stateDir, ...extraEnv },
121
+ })
122
+ }
123
+
124
+ interface RecordShape {
125
+ turnNonce: string
126
+ text: string
127
+ audience?: string
128
+ replyToolThrewThisTurn?: unknown
129
+ chatId: string | null
130
+ source: string
131
+ textSha256: string
132
+ createdAt: number
133
+ }
134
+
135
+ function outboxRecords(dir: string): RecordShape[] {
136
+ const outbox = join(dir, 'outbox')
137
+ if (!existsSync(outbox)) return []
138
+ return readdirSync(outbox)
139
+ .filter((f) => f.endsWith('.json') && f !== 'delivered.jsonl')
140
+ .map((f) => JSON.parse(readFileSync(join(outbox, f), 'utf8')) as RecordShape)
141
+ }
142
+
143
+ function journalLines(dir: string): Array<Record<string, unknown>> {
144
+ const p = join(dir, 'outbox', 'delivered.jsonl')
145
+ if (!existsSync(p)) return []
146
+ return readFileSync(p, 'utf8')
147
+ .split('\n')
148
+ .filter((l) => l.trim().length > 0)
149
+ .map((l) => JSON.parse(l) as Record<string, unknown>)
150
+ }
151
+
152
+ /** The OPEN inbound obligation that makes this the foreground case. */
153
+ function writeOpenObligation(dir: string): void {
154
+ writeFileSync(
155
+ join(dir, 'obligations.json'),
156
+ JSON.stringify({
157
+ v: 1,
158
+ obligations: [
159
+ {
160
+ originTurnId: NONCE,
161
+ chatId: DM_CHAT,
162
+ messageId: INBOUND_MSG_ID,
163
+ text: 'where did the rebase land?',
164
+ openedAt: 1_000,
165
+ representCount: 0,
166
+ },
167
+ ],
168
+ }),
169
+ 'utf8',
170
+ )
171
+ }
172
+
173
+ /**
174
+ * A RECORDING fake Bot API. Every method that can put bytes into a chat is
175
+ * counted, so a send-count assertion covers ALL of them, not the one method
176
+ * the test remembered to stub.
177
+ */
178
+ function recordingBot() {
179
+ const calls: Array<{ method: string; chatId: string; text: string }> = []
180
+ let nextId = 900
181
+ return {
182
+ calls,
183
+ chatCalls: () => calls,
184
+ api: {
185
+ sendRichMessage: async (chatId: string, body: { markdown: string }) => {
186
+ calls.push({ method: 'sendRichMessage', chatId, text: body.markdown })
187
+ return { message_id: nextId++ }
188
+ },
189
+ sendMessage: async (chatId: string, text: string) => {
190
+ calls.push({ method: 'sendMessage', chatId, text })
191
+ return { message_id: nextId++ }
192
+ },
193
+ editMessageText: async (chatId: string, _mid: number, text: string) => {
194
+ calls.push({ method: 'editMessageText', chatId, text })
195
+ return { message_id: nextId++ }
196
+ },
197
+ },
198
+ }
199
+ }
200
+
201
+ const passthroughRetry = <U>(fn: () => Promise<U>): Promise<U> => fn()
202
+
203
+ /** Drive ONE real sweep tick against the recording bot. */
204
+ async function realSweep(
205
+ stateDir: string,
206
+ bot: ReturnType<typeof recordingBot>,
207
+ opts: { framingEnabled?: boolean } = {},
208
+ ) {
209
+ const logLines: string[] = []
210
+ const framingLines: string[] = []
211
+ const escalations: string[] = []
212
+ const summary = await sweepOutbox({
213
+ send: createOutboxSend({ getBot: () => bot, retry: passthroughRetry }),
214
+ textAlreadyDelivered: () => false,
215
+ stateDir,
216
+ now: () => Date.now() + 60_000,
217
+ quietMs: 0,
218
+ log: (l) => logLines.push(l),
219
+ ...(opts.framingEnabled === undefined
220
+ ? {}
221
+ : { provenanceFramingEnabled: () => opts.framingEnabled! }),
222
+ logProvenanceFraming: (l) => framingLines.push(l),
223
+ escalateInternalSuppression: (l) => escalations.push(l),
224
+ })
225
+ return { summary, logLines, framingLines, escalations }
226
+ }
227
+
228
+ /**
229
+ * THE FOREGROUND SHAPE (#4141), verbatim in structure:
230
+ * 1. a Telegram inbound — the anchor for THIS turn, and the reason an
231
+ * obligation is open;
232
+ * 2. a reply-tool call, which THROWS;
233
+ * 3. trailing prose, which capture then selects as "the answer".
234
+ *
235
+ * Note there is NO background wake here — that is the whole difference from
236
+ * #4140's leak transcript, and it is what keeps the obligation open.
237
+ */
238
+ function foregroundThrowTranscript(dir: string, prose: string): string {
239
+ return writeTranscript(dir, [
240
+ {
241
+ type: 'queue-operation',
242
+ operation: 'enqueue',
243
+ content: `<channel source="telegram" chat_id="${DM_CHAT}" message_id="${INBOUND_MSG_ID}">where did the rebase land?</channel>`,
244
+ timestamp: 1000,
245
+ },
246
+ {
247
+ type: 'assistant',
248
+ message: {
249
+ content: [
250
+ {
251
+ type: 'tool_use',
252
+ id: 'toolu_reply_1',
253
+ name: 'mcp__switchroom-telegram__reply',
254
+ input: { text: 'On it.', disable_notification: true },
255
+ },
256
+ ],
257
+ },
258
+ },
259
+ {
260
+ type: 'user',
261
+ message: {
262
+ content: [
263
+ {
264
+ type: 'tool_result',
265
+ tool_use_id: 'toolu_reply_1',
266
+ is_error: true,
267
+ content: 'Error: FLOOD_WAIT_ACTIVE — send rejected',
268
+ },
269
+ ],
270
+ },
271
+ },
272
+ { type: 'assistant', message: { content: [{ type: 'text', text: prose }] } },
273
+ ])
274
+ }
275
+
276
+ describe('#4141 — the framing rule is pure, positive-evidence-only, and additive', () => {
277
+ it('frames ONLY on an exact boolean true', () => {
278
+ expect(shouldFrameReplyThrow({ replyToolThrewThisTurn: true })).toBe(true)
279
+ // Anything else is no evidence → no change at all. A legacy record (the
280
+ // field did not exist before this PR) is the `undefined` row.
281
+ for (const v of [undefined, null, false, 'true', 'internal', 1, {}, []]) {
282
+ expect(shouldFrameReplyThrow({ replyToolThrewThisTurn: v })).toBe(false)
283
+ }
284
+ expect(shouldFrameReplyThrow({})).toBe(false)
285
+ // Kill switch — the seam the revert-check flips.
286
+ expect(
287
+ shouldFrameReplyThrow({ replyToolThrewThisTurn: true }, { frameEnabled: false }),
288
+ ).toBe(false)
289
+ })
290
+
291
+ it('framing is ADDITIVE: the captured prose survives verbatim', () => {
292
+ const framed = applyReplyThrowFraming(WORKING_NOTES)
293
+ expect(framed).toContain(WORKING_NOTES)
294
+ expect(framed.startsWith(REPLY_THROW_PROVENANCE_NOTICE)).toBe(true)
295
+ expect(framed.length).toBeGreaterThan(WORKING_NOTES.length)
296
+ })
297
+
298
+ it('the banner carries no markdown specials that could parse-reject a rich send', () => {
299
+ expect(REPLY_THROW_PROVENANCE_NOTICE).not.toMatch(/[*_`[\]]/)
300
+ })
301
+
302
+ it('an empty body is left alone — a banner about nothing is not a message', () => {
303
+ expect(applyReplyThrowFraming('')).toBe('')
304
+ expect(applyReplyThrowFraming(' \n ')).toBe(' \n ')
305
+ })
306
+
307
+ it('the pure sweep decision can never turn a send into a skip because of framing', () => {
308
+ const base = {
309
+ now: 100_000,
310
+ deliveredNonces: new Set<string>(),
311
+ textAlreadyDelivered: false,
312
+ routable: true,
313
+ quietMs: 0,
314
+ }
315
+ const rec = {
316
+ turnNonce: NONCE,
317
+ text: WORKING_NOTES,
318
+ createdAt: 1000,
319
+ replyToolThrewThisTurn: true,
320
+ }
321
+ const on = decideOutboxSweep({ ...base, record: rec })
322
+ const off = decideOutboxSweep({ ...base, record: rec, provenanceFraming: false })
323
+ expect(on.action).toBe(off.action)
324
+ expect(on.action).toBe('send')
325
+ expect(on.framedProvenance).toBe('reply-throw')
326
+ expect(off.framedProvenance).toBeUndefined()
327
+ expect(on.text).toContain(off.text!)
328
+ })
329
+
330
+ it('the banner sits between the delivery prefixes and the body, not in front of them', () => {
331
+ const d = decideOutboxSweep({
332
+ record: {
333
+ turnNonce: NONCE,
334
+ text: WORKING_NOTES,
335
+ createdAt: 0,
336
+ replyToolThrewThisTurn: true,
337
+ },
338
+ now: 60 * 60_000,
339
+ deliveredNonces: new Set<string>(),
340
+ textAlreadyDelivered: false,
341
+ routable: true,
342
+ routePrefix: '(from background task) ',
343
+ quietMs: 0,
344
+ })
345
+ expect(d.action).toBe('send-delayed')
346
+ expect(d.text!.startsWith('(delayed) (from background task) ')).toBe(true)
347
+ expect(d.text).toContain(REPLY_THROW_PROVENANCE_NOTICE)
348
+ expect(d.text).toContain(WORKING_NOTES)
349
+ })
350
+ })
351
+
352
+ describe('#4141 — capture persists the raw reply-throw signal onto the record', () => {
353
+ let dir: string
354
+ beforeEach(() => {
355
+ dir = makeStateDir()
356
+ })
357
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
358
+
359
+ it('the REAL hook stamps replyToolThrewThisTurn alongside audience:user', () => {
360
+ writeOpenObligation(dir)
361
+ const t = foregroundThrowTranscript(dir, WORKING_NOTES)
362
+ const scan = scanForOutboxCapture(readFileSync(t, 'utf8')) as {
363
+ capture: boolean
364
+ replyToolThrewThisTurn: boolean
365
+ }
366
+ expect(scan.capture).toBe(true)
367
+ expect(scan.replyToolThrewThisTurn).toBe(true)
368
+
369
+ expect(runHook(t, dir).status).toBe(0)
370
+ const records = outboxRecords(dir)
371
+ expect(records).toHaveLength(1)
372
+ // The #4141 premise, confirmed against the real hook: the foreground case
373
+ // classifies `user` (someone is waiting) — #4140's gate does NOT bind here.
374
+ expect(records[0].audience).toBe('user')
375
+ // ...and the raw signal survives the write. THIS is the hop that would
376
+ // silently make the whole feature inert if it were dropped.
377
+ expect(records[0].replyToolThrewThisTurn).toBe(true)
378
+ })
379
+
380
+ it('a turn where the reply tool did NOT throw stamps false — no framing anywhere', () => {
381
+ writeOpenObligation(dir)
382
+ const t = writeTranscript(dir, [
383
+ {
384
+ type: 'queue-operation',
385
+ operation: 'enqueue',
386
+ content: `<channel source="telegram" chat_id="${DM_CHAT}" message_id="${INBOUND_MSG_ID}">where did the rebase land?</channel>`,
387
+ timestamp: 1000,
388
+ },
389
+ { type: 'assistant', message: { content: [{ type: 'text', text: REAL_ANSWER }] } },
390
+ ])
391
+ expect(runHook(t, dir).status).toBe(0)
392
+ const records = outboxRecords(dir)
393
+ expect(records).toHaveLength(1)
394
+ expect(records[0].replyToolThrewThisTurn).toBe(false)
395
+ expect(shouldFrameReplyThrow(records[0])).toBe(false)
396
+ })
397
+ })
398
+
399
+ describe('#4141 — the foreground leak, end to end through the real sweep', () => {
400
+ let dir: string
401
+ beforeEach(() => {
402
+ dir = makeStateDir()
403
+ })
404
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
405
+
406
+ /** Capture the foreground reply-throw shape through the REAL hook. */
407
+ function captureForeground(prose: string): RecordShape {
408
+ writeOpenObligation(dir)
409
+ const t = foregroundThrowTranscript(dir, prose)
410
+ expect(runHook(t, dir).status).toBe(0)
411
+ const records = outboxRecords(dir)
412
+ expect(records).toHaveLength(1)
413
+ expect(records[0].audience).toBe('user')
414
+ expect(records[0].replyToolThrewThisTurn).toBe(true)
415
+ return records[0]
416
+ }
417
+
418
+ it('PRIMARY: the prose is delivered WITH its provenance stated, journaled terminally, and telemetered once', async () => {
419
+ const rec = captureForeground(WORKING_NOTES)
420
+ const bot = recordingBot()
421
+
422
+ const run = await realSweep(dir, bot)
423
+
424
+ // INVARIANT A — framing never silences. Exactly one chat-visible call.
425
+ expect(bot.chatCalls()).toHaveLength(1)
426
+ expect(run.summary.delivered).toBe(1)
427
+ expect(run.summary.audienceSuppressed).toBeUndefined()
428
+
429
+ // The user is told what this text IS before they read it...
430
+ const body = bot.chatCalls()[0].text
431
+ expect(body).toContain(REPLY_THROW_PROVENANCE_NOTICE)
432
+ // ...and none of the captured prose was destroyed to say so.
433
+ expect(body).toContain('I will bisect from the merge base')
434
+ expect(body.indexOf(REPLY_THROW_PROVENANCE_NOTICE)).toBeLessThan(
435
+ body.indexOf('Dispatched the worker'),
436
+ )
437
+ expect(bot.chatCalls()[0].chatId).toBe(DM_CHAT)
438
+
439
+ // INVARIANT B — observable. A durable terminal stamp on the journal line...
440
+ const journal = journalLines(dir)
441
+ expect(journal).toHaveLength(1)
442
+ expect(journal[0].turnNonce).toBe(rec.turnNonce)
443
+ expect(journal[0].framedProvenance).toBe('reply-throw')
444
+ expect(journal[0].deliverySource).toBe('sweep')
445
+ // A framed delivery IS a delivery — it carries a message id, unlike a
446
+ // #4140 suppression line.
447
+ expect(journal[0].tgMessageId).toBeDefined()
448
+ expect(journal[0].suppressedAudience).toBeUndefined()
449
+
450
+ // ...plus exactly one structured telemetry line, on its own channel.
451
+ expect(run.framingLines).toHaveLength(1)
452
+ expect(run.framingLines[0]).toContain(`nonce=${rec.turnNonce}`)
453
+ expect(run.framingLines[0]).toContain('replyToolThrew=true')
454
+ expect(run.escalations).toHaveLength(0)
455
+ expect(run.summary.provenanceFramed).toBe(1)
456
+
457
+ // A SECOND tick sends nothing more (exactly-once is untouched).
458
+ const second = await realSweep(dir, bot)
459
+ expect(bot.chatCalls()).toHaveLength(1)
460
+ expect(second.summary.scanned).toBe(0)
461
+ expect(second.framingLines).toHaveLength(0)
462
+ })
463
+
464
+ it('REVERT CHECK: with framing disabled the leak reproduces UNLABELLED (so the assertion above is load-bearing)', async () => {
465
+ captureForeground(WORKING_NOTES)
466
+ const bot = recordingBot()
467
+
468
+ const run = await realSweep(dir, bot, { framingEnabled: false })
469
+
470
+ // The pre-change behaviour, verbatim: the working notes go to the waiting
471
+ // human with nothing to say they are not the answer.
472
+ expect(bot.chatCalls()).toHaveLength(1)
473
+ expect(run.summary.delivered).toBe(1)
474
+ expect(bot.chatCalls()[0].text).not.toContain(REPLY_THROW_PROVENANCE_NOTICE)
475
+ expect(bot.chatCalls()[0].text).toContain('Dispatched the worker')
476
+ expect(run.summary.provenanceFramed).toBeUndefined()
477
+ expect(run.framingLines).toHaveLength(0)
478
+ expect(journalLines(dir)[0].framedProvenance).toBeUndefined()
479
+ })
480
+
481
+ it('ANTI-SILENCE: a genuine answer re-written as prose after the throw still reaches the user in full', async () => {
482
+ // This is why the rule is framing and not suppression. The transcript is
483
+ // structurally IDENTICAL to the working-notes case; only the prose differs,
484
+ // and no classifier in this pipeline can tell them apart. A suppression
485
+ // rule would silently swallow this answer from a waiting human.
486
+ captureForeground(REAL_ANSWER)
487
+ const bot = recordingBot()
488
+
489
+ const run = await realSweep(dir, bot)
490
+
491
+ expect(run.summary.delivered).toBe(1)
492
+ expect(bot.chatCalls()).toHaveLength(1)
493
+ expect(bot.chatCalls()[0].text).toContain(REAL_ANSWER)
494
+ })
495
+
496
+ it('LEGACY: a record with no replyToolThrewThisTurn field delivers byte-for-byte unframed', async () => {
497
+ const outbox = join(dir, 'outbox')
498
+ mkdirSync(outbox, { recursive: true })
499
+ const nonce = `${DM_CHAT}:_#5150`
500
+ const legacy = {
501
+ turnNonce: nonce,
502
+ chatId: DM_CHAT,
503
+ threadId: null,
504
+ text: REAL_ANSWER,
505
+ textSha256: sha256Hex(REAL_ANSWER),
506
+ // Inside the max-age window, so the body carries NO prefix at all and
507
+ // "byte-for-byte unframed" can be asserted as exact equality.
508
+ createdAt: Date.now(),
509
+ source: 'channel',
510
+ audience: 'user',
511
+ }
512
+ expect(Object.keys(legacy)).not.toContain('replyToolThrewThisTurn')
513
+ writeFileSync(join(outbox, `${nonce}.json`), JSON.stringify(legacy), 'utf8')
514
+
515
+ const bot = recordingBot()
516
+ const run = await realSweep(dir, bot)
517
+
518
+ expect(run.summary.delivered).toBe(1)
519
+ expect(bot.chatCalls()).toHaveLength(1)
520
+ expect(bot.chatCalls()[0].text).toBe(REAL_ANSWER)
521
+ expect(run.framingLines).toHaveLength(0)
522
+ })
523
+
524
+ it('#4140 still wins where it binds: an internal record is suppressed, never framed-and-sent', async () => {
525
+ // Interaction check between the two gates. A background record can carry
526
+ // BOTH signals (reply threw, nobody waiting); suppression is upstream at
527
+ // entry selection, so framing must never resurrect it as a send.
528
+ const outbox = join(dir, 'outbox')
529
+ mkdirSync(outbox, { recursive: true })
530
+ const nonce = `${DM_CHAT}:_#6160`
531
+ writeFileSync(
532
+ join(outbox, `${nonce}.json`),
533
+ JSON.stringify({
534
+ turnNonce: nonce,
535
+ chatId: DM_CHAT,
536
+ threadId: null,
537
+ text: WORKING_NOTES,
538
+ textSha256: sha256Hex(WORKING_NOTES),
539
+ createdAt: 1000,
540
+ source: 'task-notification',
541
+ audience: 'internal',
542
+ replyToolThrewThisTurn: true,
543
+ }),
544
+ 'utf8',
545
+ )
546
+
547
+ const bot = recordingBot()
548
+ const run = await realSweep(dir, bot)
549
+
550
+ expect(bot.chatCalls()).toEqual([])
551
+ expect(run.summary.audienceSuppressed).toBe(1)
552
+ expect(run.summary.provenanceFramed).toBeUndefined()
553
+ expect(run.framingLines).toHaveLength(0)
554
+ expect(journalLines(dir)[0].suppressedAudience).toBe('internal')
555
+ expect(journalLines(dir)[0].framedProvenance).toBeUndefined()
556
+ })
557
+
558
+ it('the OTHER live producer (flood-reply-queue) writes no throw signal and delivers unframed', async () => {
559
+ // `gateway/flood-reply-queue.ts` is LIVE (imported by
560
+ // gateway/outbound-send-path.ts). Its records are replies the agent
561
+ // ALREADY chose to send — framing them would be a lie.
562
+ const res = queueFloodBlockedReply({
563
+ err: new Error('FLOOD_WAIT_ACTIVE — send rejected, retry_after 30'),
564
+ chatId: DM_CHAT,
565
+ threadId: null,
566
+ text: REAL_ANSWER,
567
+ createdAt: 1000,
568
+ stateDir: dir,
569
+ })
570
+ expect(res).not.toBeNull()
571
+ const rec = outboxRecords(dir)[0]
572
+ expect(rec.audience).toBe('user')
573
+ expect(rec.replyToolThrewThisTurn).toBeUndefined()
574
+
575
+ const bot = recordingBot()
576
+ const run = await realSweep(dir, bot)
577
+
578
+ expect(run.summary.delivered).toBe(1)
579
+ expect(bot.chatCalls()).toHaveLength(1)
580
+ expect(bot.chatCalls()[0].text).not.toContain(REPLY_THROW_PROVENANCE_NOTICE)
581
+ expect(bot.chatCalls()[0].text).toContain(REAL_ANSWER)
582
+ expect(run.framingLines).toHaveLength(0)
583
+ })
584
+ })
585
+
586
+ /**
587
+ * The BACKGROUND shape #4140's gate is built for: a task-notification wake
588
+ * (never opens an obligation), a reply-tool throw, trailing working notes.
589
+ * Used here to prove the stale-snapshot hole (#4146), because this is the only
590
+ * shape where a bad "nobody is waiting" verdict can actually suppress.
591
+ */
592
+ function backgroundThrowTranscript(dir: string, prose: string): string {
593
+ return writeTranscript(dir, [
594
+ {
595
+ type: 'queue-operation',
596
+ operation: 'enqueue',
597
+ content: `<channel source="telegram" chat_id="${DM_CHAT}" message_id="${INBOUND_MSG_ID}">where did the rebase land?</channel>`,
598
+ timestamp: 1000,
599
+ },
600
+ {
601
+ type: 'queue-operation',
602
+ operation: 'enqueue',
603
+ content: '<task-notification><task-id>a7cc7a0fa8f</task-id> worker done</task-notification>',
604
+ timestamp: 2000,
605
+ },
606
+ {
607
+ type: 'assistant',
608
+ message: {
609
+ content: [
610
+ {
611
+ type: 'tool_use',
612
+ id: 'toolu_reply_1',
613
+ name: 'mcp__switchroom-telegram__reply',
614
+ input: { text: 'On it.', disable_notification: true },
615
+ },
616
+ ],
617
+ },
618
+ },
619
+ {
620
+ type: 'user',
621
+ message: {
622
+ content: [
623
+ {
624
+ type: 'tool_result',
625
+ tool_use_id: 'toolu_reply_1',
626
+ is_error: true,
627
+ content: 'Error: FLOOD_WAIT_ACTIVE — send rejected',
628
+ },
629
+ ],
630
+ },
631
+ },
632
+ { type: 'assistant', message: { content: [{ type: 'text', text: prose }] } },
633
+ ])
634
+ }
635
+
636
+ describe('#4146 — a snapshot that is not being maintained is not evidence', () => {
637
+ let dir: string
638
+ beforeEach(() => {
639
+ dir = makeStateDir()
640
+ })
641
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
642
+
643
+ it('an explicit distrust beats anything on disk, including an empty open set', () => {
644
+ const emptyButStale = JSON.stringify({ v: 1, obligations: [] })
645
+ // Trusted: the pre-#4146 reading — positive proof that nobody is waiting.
646
+ expect(
647
+ resolveOpenObligation({ snapshotRaw: emptyButStale, chatId: DM_CHAT }),
648
+ ).toBe(false)
649
+ // Distrusted: the SAME bytes prove nothing.
650
+ expect(
651
+ resolveOpenObligation({
652
+ snapshotRaw: emptyButStale,
653
+ chatId: DM_CHAT,
654
+ snapshotTrusted: false,
655
+ }),
656
+ ).toBe('unknown')
657
+ // ...which flips the classifier back to the delivering direction.
658
+ expect(
659
+ decideCaptureAudience({
660
+ replyToolThrewThisTurn: true,
661
+ openInboundObligation: resolveOpenObligation({
662
+ snapshotRaw: emptyButStale,
663
+ chatId: DM_CHAT,
664
+ snapshotTrusted: false,
665
+ }),
666
+ }),
667
+ ).toBe('user')
668
+ // Only an exact `false` distrusts — an unaware caller cannot weaken it.
669
+ expect(
670
+ resolveOpenObligation({ snapshotRaw: emptyButStale, chatId: DM_CHAT, snapshotTrusted: true }),
671
+ ).toBe(false)
672
+ expect(
673
+ resolveOpenObligation({
674
+ snapshotRaw: emptyButStale,
675
+ chatId: DM_CHAT,
676
+ snapshotTrusted: undefined,
677
+ }),
678
+ ).toBe(false)
679
+ })
680
+
681
+ it('OUTCOME: with the ledger off, a lingering empty snapshot no longer suppresses — the text still reaches the chat', async () => {
682
+ // The exact #4146 configuration: the agent once ran with the ledger on, so
683
+ // an empty `obligations.json` is on disk; the ledger is now off, so
684
+ // obligations are going untracked and that file is a leftover.
685
+ writeFileSync(join(dir, 'obligations.json'), JSON.stringify({ v: 1, obligations: [] }), 'utf8')
686
+ const t = backgroundThrowTranscript(dir, REAL_ANSWER)
687
+ expect(runHook(t, dir, { SWITCHROOM_OBLIGATION_LEDGER: '0' }).status).toBe(0)
688
+
689
+ const rec = outboxRecords(dir)[0]
690
+ expect(rec.audience).toBe('user')
691
+
692
+ const bot = recordingBot()
693
+ const run = await realSweep(dir, bot)
694
+ expect(run.summary.delivered).toBe(1)
695
+ expect(run.summary.audienceSuppressed).toBeUndefined()
696
+ expect(bot.chatCalls()).toHaveLength(1)
697
+ expect(bot.chatCalls()[0].text).toContain(REAL_ANSWER)
698
+ })
699
+
700
+ it('REVERT CHECK (#4146): with the ledger ON, the same lingering snapshot DOES suppress', async () => {
701
+ // Same bytes, same transcript — only the trust signal differs. This is what
702
+ // makes the assertion above load-bearing: without the guard the message is
703
+ // swallowed with zero chat calls.
704
+ writeFileSync(join(dir, 'obligations.json'), JSON.stringify({ v: 1, obligations: [] }), 'utf8')
705
+ const t = backgroundThrowTranscript(dir, REAL_ANSWER)
706
+ expect(runHook(t, dir, { SWITCHROOM_OBLIGATION_LEDGER: '1' }).status).toBe(0)
707
+
708
+ expect(outboxRecords(dir)[0].audience).toBe('internal')
709
+
710
+ const bot = recordingBot()
711
+ const run = await realSweep(dir, bot)
712
+ expect(bot.chatCalls()).toEqual([])
713
+ expect(run.summary.audienceSuppressed).toBe(1)
714
+ })
715
+ })
716
+
717
+ describe('#4141 — framing is honest telemetry and cannot launder a broken turn', () => {
718
+ it('the framing line matches NO gateway alarm signature (a framed delivery is a delivery)', () => {
719
+ const line = formatReplyThrowFraming({
720
+ turnNonce: NONCE,
721
+ turnId: NONCE,
722
+ chatId: DM_CHAT,
723
+ textSha256: sha256Hex(WORKING_NOTES),
724
+ source: 'channel',
725
+ })
726
+ for (const [name, re] of Object.entries(GATEWAY_SIGNATURES)) {
727
+ expect(re.test(line), `${name} must not match a by-design framed delivery`).toBe(false)
728
+ }
729
+ expect(detectGatewayFindings('klanker', line).findings).toEqual([])
730
+ })
731
+
732
+ it('the framing line carries a turn id the fleet-health scanner can join on', () => {
733
+ expect(turnIdFromNonce(NONCE)).toBe(NONCE)
734
+ const line = formatReplyThrowFraming({ turnNonce: NONCE, turnId: NONCE, chatId: DM_CHAT })
735
+ expect(extractTurnId(line)).toBe(NONCE)
736
+ })
737
+
738
+ it('DISJOINTNESS: a framed turn has tools>=1, so it can never enter the silent-no-op branch', () => {
739
+ // Checked through the REAL builder + REAL detector, not by inspection.
740
+ // Framing is only reachable when the reply tool was CALLED and threw, so
741
+ // the turn has >= 1 tool call; the silent-no-op branch requires
742
+ // `tools === 0` (src/fleet-health/detect.ts:305-333).
743
+ const now = Math.floor(Date.now() / 1000)
744
+ const row = buildTurnRecord(
745
+ {
746
+ agent: 'klanker',
747
+ startedAt: 1000,
748
+ toolCallCount: 1, // the reply tool call that threw
749
+ turnId: NONCE,
750
+ finalAnswerDelivered: false,
751
+ replyCalled: true,
752
+ },
753
+ Date.now(),
754
+ )
755
+ expect(row.tools).toBe(1)
756
+ expect(row.route).toBe('none')
757
+ const findings = detectTurnFindings('klanker', [{ ...row, ts: now }])
758
+ expect(findings.filter((f) => f.signal === 'silent-no-op-candidate')).toEqual([])
759
+ })
760
+
761
+ it('CONTROL: the silent-no-op detector is NOT blinded — a zero-tool no-op still scores', () => {
762
+ // The disjointness claim above is only meaningful if the detector would
763
+ // otherwise fire on the SAME call. This is that control: same detector,
764
+ // tools=0, complete, and no honest `route` — the regression shape
765
+ // `detect.ts:333-343` treats as `none` and scores sev-3.
766
+ const now = Math.floor(Date.now() / 1000)
767
+ const row = buildTurnRecord(
768
+ {
769
+ agent: 'klanker',
770
+ startedAt: 1000,
771
+ toolCallCount: 0,
772
+ turnId: `${DM_CHAT}:_#9999`,
773
+ finalAnswerDelivered: true,
774
+ },
775
+ Date.now(),
776
+ )
777
+ expect(row.tools).toBe(0)
778
+ expect(row.status).toBe('complete')
779
+ const { route: _dropped, ...legacyRow } = row
780
+ const findings = detectTurnFindings('klanker', [{ ...legacyRow, ts: now }])
781
+ expect(findings.filter((f) => f.signal === 'silent-no-op-candidate')).toHaveLength(1)
782
+ })
783
+ })
784
+
785
+ // ─────────────────────────────────────────────────────────────────────────────
786
+ // #4141 REVIEW FINDING — THE ELECTED PATH
787
+ //
788
+ // The sweep-path framing above binds only when the Stop hook writes an outbox
789
+ // RECORD. On the dominant live shape it does not:
790
+ //
791
+ // The reply tool is called with a QUALIFYING final answer and it THROWS.
792
+ // `scanForOutboxCapture` classifies a qualifying reply-tool call from its
793
+ // INPUT (`isFinalAnswerReply`, silent-end-scan.mjs:1008) and never consults
794
+ // the tool_result, so `replyAlreadyDeliveredThisTurn` is true even though the
795
+ // call errored. The hook therefore takes the #3510 branch, writes NO outbox
796
+ // record, and falls through to the single-writer election, which hands the
797
+ // trailing prose to the gateway's captured-prose bridge.
798
+ //
799
+ // So on a healthy gateway the FIRST, common occurrence goes out through a
800
+ // machine the outbox sweep never touches. These tests pin the label onto THAT
801
+ // machine, driving the real hook, the real `decideCapturedProseDelivery`, and
802
+ // the real `deliverCapturedProse` against the recording bot.
803
+ // ─────────────────────────────────────────────────────────────────────────────
804
+
805
+ /**
806
+ * The ELECTED foreground shape: a QUALIFYING final-answer reply (no
807
+ * `disable_notification`, so `isFinalAnswerReply` is true on the input alone)
808
+ * that throws, followed by trailing prose over the bridge's 200-char floor.
809
+ */
810
+ function electedThrowTranscript(dir: string, prose: string, opts: { throws?: boolean } = {}) {
811
+ const lines: object[] = [
812
+ {
813
+ type: 'queue-operation',
814
+ operation: 'enqueue',
815
+ content: `<channel source="telegram" chat_id="${DM_CHAT}" message_id="${INBOUND_MSG_ID}">where did the rebase land?</channel>`,
816
+ timestamp: 1000,
817
+ },
818
+ {
819
+ type: 'assistant',
820
+ message: {
821
+ content: [
822
+ {
823
+ type: 'tool_use',
824
+ id: 'toolu_reply_elected',
825
+ name: 'mcp__switchroom-telegram__reply',
826
+ // No `disable_notification` ⇒ qualifying final answer by input.
827
+ input: { text: 'The rebase landed cleanly on the merge base.' },
828
+ },
829
+ ],
830
+ },
831
+ },
832
+ ]
833
+ if (opts.throws !== false) {
834
+ lines.push({
835
+ type: 'user',
836
+ message: {
837
+ content: [
838
+ {
839
+ type: 'tool_result',
840
+ tool_use_id: 'toolu_reply_elected',
841
+ is_error: true,
842
+ content: 'Error: request timed out after 30000ms',
843
+ },
844
+ ],
845
+ },
846
+ })
847
+ } else {
848
+ lines.push({
849
+ type: 'user',
850
+ message: {
851
+ content: [
852
+ { type: 'tool_result', tool_use_id: 'toolu_reply_elected', content: 'sent' },
853
+ ],
854
+ },
855
+ })
856
+ }
857
+ lines.push({ type: 'assistant', message: { content: [{ type: 'text', text: prose }] } })
858
+ return writeTranscript(dir, lines)
859
+ }
860
+
861
+ /** Make the election's gateway-liveness gate pass. */
862
+ function writeFreshHeartbeat(dir: string): void {
863
+ writeFileSync(join(dir, 'gateway-heartbeat'), 'up', 'utf8')
864
+ }
865
+
866
+ function readElectedState(dir: string): Record<string, unknown> | null {
867
+ const p = join(dir, 'silent-end-pending.json')
868
+ if (!existsSync(p)) return null
869
+ return JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown>
870
+ }
871
+
872
+ /** Drive the REAL captured-prose bridge against the recording bot. */
873
+ async function realBridge(
874
+ stateDir: string,
875
+ bot: ReturnType<typeof recordingBot>,
876
+ decision: { text?: string; replyToolThrewThisTurn?: boolean },
877
+ ) {
878
+ const stderr: string[] = []
879
+ // `journalExternalDelivery` resolves its state dir from the env (the bridge
880
+ // takes no stateDir arg), so point it at this test's dir for the duration.
881
+ const prevStateDir = process.env.TELEGRAM_STATE_DIR
882
+ process.env.TELEGRAM_STATE_DIR = stateDir
883
+ const origWrite = process.stderr.write.bind(process.stderr)
884
+ ;(process.stderr as unknown as { write: (s: string) => boolean }).write = (s: string) => {
885
+ stderr.push(String(s))
886
+ return true
887
+ }
888
+ try {
889
+ await deliverCapturedProse(
890
+ {
891
+ outboundDedup: new OutboundDedupCache({}),
892
+ bot: bot as never,
893
+ robustApiCall: passthroughRetry,
894
+ redactOutboundText: (t: string) => t,
895
+ recordOutbound: () => {},
896
+ HISTORY_ENABLED: false,
897
+ OBLIGATION_LEDGER_ENABLED: false,
898
+ obligationLedger: { close: () => {} },
899
+ clearSilentEndState: () => {},
900
+ recordUndeliveredTurnEnd: () => ({ exhausted: false }),
901
+ hasOutboundDeliveredSince: () => false,
902
+ },
903
+ {
904
+ chatId: DM_CHAT,
905
+ threadId: undefined,
906
+ statusKeyStr: `${DM_CHAT}:_`,
907
+ registryKey: null,
908
+ originTurnId: NONCE,
909
+ text: decision.text!,
910
+ replyToolThrewThisTurn: decision.replyToolThrewThisTurn === true,
911
+ },
912
+ )
913
+ } finally {
914
+ ;(process.stderr as unknown as { write: typeof origWrite }).write = origWrite
915
+ if (prevStateDir === undefined) delete process.env.TELEGRAM_STATE_DIR
916
+ else process.env.TELEGRAM_STATE_DIR = prevStateDir
917
+ }
918
+ return { stderr }
919
+ }
920
+
921
+ describe('#4141 elected path — the bridge, not the sweep, is the common deliverer', () => {
922
+ let dir: string
923
+ beforeEach(() => {
924
+ dir = makeStateDir()
925
+ writeFreshHeartbeat(dir)
926
+ writeOpenObligation(dir)
927
+ })
928
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
929
+
930
+ it('PREMISE: a thrown QUALIFYING reply writes no outbox record and elects the bridge', () => {
931
+ const t = electedThrowTranscript(dir, WORKING_NOTES)
932
+ const res = runHook(t, dir)
933
+ expect(res.status).toBe(0)
934
+ // The sweep-path record — the only thing the outbox framing can label —
935
+ // is never written on this shape. This is exactly why the sweep-side fix
936
+ // alone left #4141 open.
937
+ expect(outboxRecords(dir)).toEqual([])
938
+ expect(res.stderr).toContain('deferring to single-writer election')
939
+ expect(res.stderr).toContain('single-writer election ALLOWED stop')
940
+ // ...and a real sweep tick therefore has nothing at all to deliver.
941
+ expect(outboxRecords(dir)).toHaveLength(0)
942
+ })
943
+
944
+ it('the elected state file carries the reply-throw signal to the bridge', () => {
945
+ runHook(electedThrowTranscript(dir, WORKING_NOTES), dir)
946
+ const state = readElectedState(dir)
947
+ expect(state).not.toBeNull()
948
+ expect(state!.replyToolThrewThisTurn).toBe(true)
949
+ expect(state!.pendingText).toBe(WORKING_NOTES)
950
+ expect(state!.turnId).toBe(NONCE)
951
+ })
952
+
953
+ it('a turn whose reply did NOT throw carries no signal — no stale carryover', () => {
954
+ // Seed a state file that already claims a throw, then run a CLEAN turn.
955
+ // `buildNextState` must DELETE the field, not spread it forward.
956
+ writeFileSync(
957
+ join(dir, 'silent-end-pending.json'),
958
+ JSON.stringify({ retryCount: 0, replyToolThrewThisTurn: true, turnKey: 'stale' }),
959
+ 'utf8',
960
+ )
961
+ runHook(electedThrowTranscript(dir, WORKING_NOTES, { throws: false }), dir)
962
+ const state = readElectedState(dir)
963
+ expect(state).not.toBeNull()
964
+ expect(state!.replyToolThrewThisTurn).toBeUndefined()
965
+ })
966
+
967
+ it('OUTCOME: the bridge delivers the prose WITH its provenance stated', async () => {
968
+ runHook(electedThrowTranscript(dir, WORKING_NOTES), dir)
969
+ const decision = decideCapturedProseDelivery(
970
+ { turnKey: `${DM_CHAT}:_`, turnId: NONCE },
971
+ { stateDir: dir },
972
+ )
973
+ expect(decision.deliver).toBe(true)
974
+ expect(decision.text).toBe(WORKING_NOTES)
975
+ expect(decision.replyToolThrewThisTurn).toBe(true)
976
+
977
+ const bot = recordingBot()
978
+ const { stderr } = await realBridge(dir, bot, decision)
979
+
980
+ // A. FRAMING NEVER SILENCES — exactly one chat-visible call, prose intact.
981
+ expect(bot.chatCalls()).toHaveLength(1)
982
+ const sent = bot.chatCalls()[0]!.text
983
+ expect(sent).toContain(WORKING_NOTES)
984
+ expect(sent).toContain(REPLY_THROW_PROVENANCE_NOTICE)
985
+ expect(sent.indexOf(REPLY_THROW_PROVENANCE_NOTICE)).toBeLessThan(sent.indexOf(WORKING_NOTES))
986
+
987
+ // B. FRAMING IS OBSERVABLE — durable journal stamp + one telemetry line.
988
+ const journal = journalLines(dir)
989
+ expect(journal).toHaveLength(1)
990
+ expect(journal[0]!.framedProvenance).toBe('reply-throw')
991
+ expect(journal[0]!.tgMessageId).toBeDefined()
992
+ // The journal key stays on the RAW text so exactly-once-among-backstops
993
+ // is unaffected by the label.
994
+ expect(journal[0]!.textSha256).toBe(sha256Hex(WORKING_NOTES))
995
+ expect(stderr.filter((l) => l.includes('outbox provenance framing'))).toHaveLength(1)
996
+ })
997
+
998
+ it('REVERT CHECK: with framing off the bridge reproduces the #4141 leak', async () => {
999
+ const prev = process.env.SWITCHROOM_TG_OUTBOX_PROVENANCE_FRAMING
1000
+ process.env.SWITCHROOM_TG_OUTBOX_PROVENANCE_FRAMING = '0'
1001
+ try {
1002
+ runHook(electedThrowTranscript(dir, WORKING_NOTES), dir)
1003
+ const decision = decideCapturedProseDelivery(
1004
+ { turnKey: `${DM_CHAT}:_`, turnId: NONCE },
1005
+ { stateDir: dir },
1006
+ )
1007
+ const bot = recordingBot()
1008
+ await realBridge(dir, bot, decision)
1009
+ expect(bot.chatCalls()).toHaveLength(1)
1010
+ // The bug: working notes delivered to a waiting human, unlabelled.
1011
+ expect(bot.chatCalls()[0]!.text).toBe(WORKING_NOTES)
1012
+ expect(bot.chatCalls()[0]!.text).not.toContain(REPLY_THROW_PROVENANCE_NOTICE)
1013
+ expect(journalLines(dir)[0]!.framedProvenance).toBeUndefined()
1014
+ } finally {
1015
+ if (prev === undefined) delete process.env.SWITCHROOM_TG_OUTBOX_PROVENANCE_FRAMING
1016
+ else process.env.SWITCHROOM_TG_OUTBOX_PROVENANCE_FRAMING = prev
1017
+ }
1018
+ })
1019
+
1020
+ it('ANTI-SILENCE: a genuine answer rewritten as prose still arrives in full', async () => {
1021
+ runHook(electedThrowTranscript(dir, REAL_ANSWER), dir)
1022
+ const decision = decideCapturedProseDelivery(
1023
+ { turnKey: `${DM_CHAT}:_`, turnId: NONCE },
1024
+ { stateDir: dir },
1025
+ )
1026
+ const bot = recordingBot()
1027
+ await realBridge(dir, bot, decision)
1028
+ expect(bot.chatCalls()).toHaveLength(1)
1029
+ expect(bot.chatCalls()[0]!.text).toContain(REAL_ANSWER)
1030
+ })
1031
+
1032
+ it('LEGACY: a state file with no signal delivers byte-for-byte unframed', async () => {
1033
+ writeFileSync(
1034
+ join(dir, 'silent-end-pending.json'),
1035
+ JSON.stringify({
1036
+ retryCount: 0,
1037
+ turnKey: `${DM_CHAT}:_`,
1038
+ turnId: NONCE,
1039
+ chatId: DM_CHAT,
1040
+ threadId: null,
1041
+ timestamp: Date.now(),
1042
+ pendingText: WORKING_NOTES,
1043
+ }),
1044
+ 'utf8',
1045
+ )
1046
+ const decision = decideCapturedProseDelivery(
1047
+ { turnKey: `${DM_CHAT}:_`, turnId: NONCE },
1048
+ { stateDir: dir },
1049
+ )
1050
+ expect(decision.replyToolThrewThisTurn).toBeUndefined()
1051
+ const bot = recordingBot()
1052
+ await realBridge(dir, bot, decision)
1053
+ expect(bot.chatCalls()[0]!.text).toBe(WORKING_NOTES)
1054
+ })
1055
+
1056
+ it('the label is presentation only — dedup still keys on the RAW prose', async () => {
1057
+ // Additive-by-construction, re-established on THIS path: this path has its
1058
+ // own send ladder and its own dedup, so the sweep's proof does not carry.
1059
+ // A second bridge call with the same raw text must dedup (zero new sends)
1060
+ // even though the first send carried the banner.
1061
+ const dedup = new OutboundDedupCache({})
1062
+ const bot = recordingBot()
1063
+ const deps = {
1064
+ outboundDedup: dedup,
1065
+ bot: bot as never,
1066
+ robustApiCall: passthroughRetry,
1067
+ redactOutboundText: (t: string) => t,
1068
+ recordOutbound: () => {},
1069
+ HISTORY_ENABLED: false,
1070
+ OBLIGATION_LEDGER_ENABLED: false,
1071
+ obligationLedger: { close: () => {} },
1072
+ clearSilentEndState: () => {},
1073
+ recordUndeliveredTurnEnd: () => ({ exhausted: false }),
1074
+ hasOutboundDeliveredSince: () => false,
1075
+ }
1076
+ const args = {
1077
+ chatId: DM_CHAT,
1078
+ threadId: undefined,
1079
+ statusKeyStr: `${DM_CHAT}:_`,
1080
+ registryKey: null,
1081
+ originTurnId: NONCE,
1082
+ text: WORKING_NOTES,
1083
+ replyToolThrewThisTurn: true,
1084
+ }
1085
+ await deliverCapturedProse(deps, args)
1086
+ expect(bot.chatCalls()).toHaveLength(1)
1087
+ // Same raw text arriving unframed (e.g. a late reply-tool retry) is still
1088
+ // recognised as already-sent — the banner did not change the identity.
1089
+ await deliverCapturedProse(deps, { ...args, replyToolThrewThisTurn: false })
1090
+ expect(bot.chatCalls()).toHaveLength(1)
1091
+ })
1092
+
1093
+ it('DISJOINTNESS: the turn-flush machine cannot deliver on a reply-throw turn', () => {
1094
+ // The elected `no-final-reply` leg names the flush as the deliverer, so it
1095
+ // is fair to ask whether IT also needs the label. It does not, and this is
1096
+ // structural rather than a judgement: `turn.replyCalled` is set on the
1097
+ // reply tool's CALL event (stream-render.ts:1196), before any result, so a
1098
+ // thrown reply still sets it — and `decideTurnFlush` skips outright on
1099
+ // `replyCalled`. A reply-throw turn can therefore never reach the flush
1100
+ // send site, which is why framing lives on the bridge instead.
1101
+ const flushed = decideTurnFlush({
1102
+ replyCalled: true,
1103
+ capturedText: [WORKING_NOTES],
1104
+ turnFlushSafetyEnabled: true,
1105
+ } as never)
1106
+ expect(flushed.kind).toBe('skip')
1107
+ expect(flushed.reason).toBe('reply-called')
1108
+ })
1109
+ })
1110
+
1111
+ describe('#4141 — the elected path is WIRED, not just implemented', () => {
1112
+ it('stream-render forwards the signal from the decision into the bridge call', () => {
1113
+ // The two ends of this path are covered by outcome tests above
1114
+ // (`decideCapturedProseDelivery` surfaces the signal; `deliverCapturedProse`
1115
+ // frames on it). The ONE link between them is a single argument in
1116
+ // stream-render's turn_end handler, which cannot be driven in isolation —
1117
+ // it lives inside a ~2000-line event handler with the whole gateway as its
1118
+ // closure. So it is pinned structurally: without this argument the feature
1119
+ // is inert in production while every other test in this file still passes,
1120
+ // which is exactly the failure mode this pin exists to catch.
1121
+ const src = readFileSync(resolve(__dirname, '..', 'gateway', 'stream-render.ts'), 'utf8')
1122
+ const call = src.slice(src.indexOf('void deliverCapturedProse({'))
1123
+ const body = call.slice(0, call.indexOf('\n })'))
1124
+ expect(body).toContain('replyToolThrewThisTurn: proseDecision.replyToolThrewThisTurn === true')
1125
+ })
1126
+ })