switchroom 0.19.42 → 0.19.44

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 (75) hide show
  1. package/dist/agent-scheduler/index.js +4 -1
  2. package/dist/auth-broker/index.js +284 -124
  3. package/dist/cli/notion-write-pretool.mjs +4 -1
  4. package/dist/cli/switchroom.js +4094 -2711
  5. package/dist/host-control/main.js +472 -155
  6. package/dist/vault/approvals/kernel-server.js +231 -71
  7. package/dist/vault/broker/server.js +293 -133
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +93 -8
  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 +1682 -936
  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/boot-card.ts +7 -2
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  20. package/telegram-plugin/gateway/gateway.ts +49 -51
  21. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  22. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  23. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  24. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  25. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  26. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  27. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  28. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  29. package/telegram-plugin/gateway/stream-render.ts +5 -0
  30. package/telegram-plugin/gateway/update-announce.ts +11 -4
  31. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  32. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  33. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  34. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  35. package/telegram-plugin/outbox.ts +103 -2
  36. package/telegram-plugin/render/rich-render.ts +10 -2
  37. package/telegram-plugin/reply-quote.ts +150 -0
  38. package/telegram-plugin/retry-api-call.ts +254 -11
  39. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  40. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  41. package/telegram-plugin/silent-end.ts +34 -2
  42. package/telegram-plugin/status-no-truncate.ts +31 -16
  43. package/telegram-plugin/stream-controller.ts +44 -9
  44. package/telegram-plugin/stream-reply-handler.ts +5 -3
  45. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  46. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  47. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  48. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  49. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  50. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  51. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  52. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  53. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  54. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  55. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  56. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  57. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  58. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  59. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  60. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  61. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  62. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  63. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  64. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  65. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  66. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  68. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  69. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  70. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  71. package/telegram-plugin/tool-activity-summary.ts +58 -30
  72. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  73. package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
  75. package/vendor/hindsight-memory/settings.json +1 -1
@@ -0,0 +1,579 @@
1
+ /**
2
+ * outbox-audience-3865.test.ts — W1-d, "the audience bit" (issue #3865).
3
+ *
4
+ * THE BUG THIS GUARDS
5
+ * -------------------
6
+ * Nothing in capture → journal → sweep recorded WHO a piece of text was for.
7
+ * Capture picks the terminal assistant-prose run structurally, so when the
8
+ * reply tool throws (#3861) the agent's trailing WORKING NOTES become the
9
+ * "final answer", get journaled, and the sweep faithfully delivers internal
10
+ * orchestration prose into the operator's chat. That is a private-data leak.
11
+ *
12
+ * THE BAR
13
+ * -------
14
+ * Not "the right audience is delivered" — "the WRONG audience CANNOT receive".
15
+ * So every assertion below drives REAL machinery, never a stub of it:
16
+ *
17
+ * - the REAL Stop hook (`hooks/silent-end-interrupt-stop.mjs`) spawned as a
18
+ * subprocess against a REAL transcript, writing a REAL outbox record;
19
+ * - the REAL `sweepOutbox` tick, with the REAL `createOutboxSend` adapter
20
+ * wired to a RECORDING fake Bot API that counts every method that could
21
+ * put bytes in a chat (sendRichMessage / sendMessage / editMessageText);
22
+ * - the REAL fleet-health detectors from `src/fleet-health/detect.ts` for
23
+ * the honesty claims.
24
+ *
25
+ * `revert check` below is the load-bearing one: it flips the gate off and
26
+ * asserts the leak REPRODUCES, proving the zero-sends assertion in the primary
27
+ * test is actually testing the gate and not passing for some unrelated reason.
28
+ */
29
+
30
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
31
+ import { spawnSync } from 'node:child_process'
32
+ import {
33
+ mkdtempSync,
34
+ mkdirSync,
35
+ writeFileSync,
36
+ readdirSync,
37
+ readFileSync,
38
+ existsSync,
39
+ rmSync,
40
+ } from 'node:fs'
41
+ import { tmpdir } from 'node:os'
42
+ import { join, resolve } from 'node:path'
43
+
44
+ import { sweepOutbox, createOutboxSend, turnIdFromNonce } from '../gateway/outbox-sweep.js'
45
+ import { sha256Hex } from '../outbox.js'
46
+ import {
47
+ decideCaptureAudience,
48
+ resolveOpenObligation,
49
+ resolveRecordAudience,
50
+ shouldSuppressForAudience,
51
+ formatInternalSuppression,
52
+ } from '../hooks/audience-classify.mjs'
53
+ import { scanForOutboxCapture } from '../hooks/silent-end-scan.mjs'
54
+ import { buildTurnRecord } from '../gateway/turn-record-status.js'
55
+ import {
56
+ detectTurnFindings,
57
+ detectGatewayFindings,
58
+ extractTurnId,
59
+ GATEWAY_SIGNATURES,
60
+ } from '../../src/fleet-health/detect.js'
61
+
62
+ const HOOK = resolve(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs')
63
+
64
+ // Synthetic ids only (check-no-pii-secrets forbids real chat/user ids).
65
+ const DM_CHAT = '5550001'
66
+ const PRIOR_MSG_ID = 4242
67
+
68
+ /** The internal working-notes prose that must never reach a chat. */
69
+ const WORKING_NOTES = [
70
+ 'Dispatched the worker onto the rebase and it came back clean, so the next',
71
+ 'step is to re-run the scoped suite before touching the sweep at all. If the',
72
+ 'suite is still red after that I will bisect from the merge base rather than',
73
+ 'guessing, and I should double-check whether the earlier claim about the',
74
+ 'adapter ordering actually holds, because I never verified it against HEAD.',
75
+ ].join(' ')
76
+
77
+ /** A genuine answer, for the positive control. */
78
+ const REAL_ANSWER = [
79
+ 'The rebase is done and the suite is green. Two files changed: the sweep now',
80
+ 'checks the record before it routes anything, and the capture hook stamps the',
81
+ 'field it checks. Nothing else moved, and the flood queue behaves exactly as',
82
+ 'it did before because it stamps the same value it always implied.',
83
+ ].join(' ')
84
+
85
+ function makeStateDir(): string {
86
+ // NEVER ~/.switchroom — a test that writes there corrupts production state.
87
+ return mkdtempSync(join(tmpdir(), 'w1d-audience-'))
88
+ }
89
+
90
+ function writeTranscript(dir: string, lines: object[]): string {
91
+ const p = join(dir, 'transcript.jsonl')
92
+ writeFileSync(p, lines.map((l) => JSON.stringify(l)).join('\n'), 'utf8')
93
+ return p
94
+ }
95
+
96
+ function runHook(transcriptPath: string, stateDir: string) {
97
+ return spawnSync('node', [HOOK], {
98
+ input: JSON.stringify({ session_id: 's', transcript_path: transcriptPath }),
99
+ encoding: 'utf8',
100
+ timeout: 10_000,
101
+ env: { ...process.env, TELEGRAM_STATE_DIR: stateDir },
102
+ })
103
+ }
104
+
105
+ interface RecordShape {
106
+ turnNonce: string
107
+ text: string
108
+ audience?: string
109
+ chatId: string | null
110
+ originChatId?: string | null
111
+ source: string
112
+ textSha256: string
113
+ createdAt: number
114
+ }
115
+
116
+ function outboxRecords(dir: string): RecordShape[] {
117
+ const outbox = join(dir, 'outbox')
118
+ if (!existsSync(outbox)) return []
119
+ return readdirSync(outbox)
120
+ .filter((f) => f.endsWith('.json'))
121
+ .map((f) => JSON.parse(readFileSync(join(outbox, f), 'utf8')) as RecordShape)
122
+ }
123
+
124
+ function journalLines(dir: string): Array<Record<string, unknown>> {
125
+ const p = join(dir, 'outbox', 'delivered.jsonl')
126
+ if (!existsSync(p)) return []
127
+ return readFileSync(p, 'utf8')
128
+ .split('\n')
129
+ .filter((l) => l.trim().length > 0)
130
+ .map((l) => JSON.parse(l) as Record<string, unknown>)
131
+ }
132
+
133
+ function writeObligations(dir: string, obligations: object[]): void {
134
+ writeFileSync(join(dir, 'obligations.json'), JSON.stringify({ v: 1, obligations }), 'utf8')
135
+ }
136
+
137
+ function openObligation(chatId: string) {
138
+ return {
139
+ originTurnId: `${chatId}:_#${PRIOR_MSG_ID}`,
140
+ chatId,
141
+ messageId: PRIOR_MSG_ID,
142
+ text: 'where did the rebase land?',
143
+ openedAt: 1_000,
144
+ representCount: 0,
145
+ }
146
+ }
147
+
148
+ /**
149
+ * A RECORDING fake Bot API. Every method that can put bytes into a chat is
150
+ * counted, so "zero sends" means zero of ANY of them — not "zero of the one
151
+ * method I remembered to stub".
152
+ */
153
+ function recordingBot() {
154
+ const calls: Array<{ method: string; chatId: string; text: string }> = []
155
+ let nextId = 900
156
+ return {
157
+ calls,
158
+ /** Every chat-visible call, whatever the method. */
159
+ chatCalls: () => calls,
160
+ api: {
161
+ sendRichMessage: async (chatId: string, body: { markdown: string }) => {
162
+ calls.push({ method: 'sendRichMessage', chatId, text: body.markdown })
163
+ return { message_id: nextId++ }
164
+ },
165
+ sendMessage: async (chatId: string, text: string) => {
166
+ calls.push({ method: 'sendMessage', chatId, text })
167
+ return { message_id: nextId++ }
168
+ },
169
+ editMessageText: async (chatId: string, _mid: number, text: string) => {
170
+ calls.push({ method: 'editMessageText', chatId, text })
171
+ return { message_id: nextId++ }
172
+ },
173
+ },
174
+ }
175
+ }
176
+
177
+ const passthroughRetry = <U>(fn: () => Promise<U>): Promise<U> => fn()
178
+
179
+ /** Drive ONE real sweep tick against the recording bot. */
180
+ async function realSweep(
181
+ stateDir: string,
182
+ bot: ReturnType<typeof recordingBot>,
183
+ opts: { gateEnabled?: boolean; now?: number } = {},
184
+ ) {
185
+ const logLines: string[] = []
186
+ const escalations: string[] = []
187
+ const summary = await sweepOutbox({
188
+ send: createOutboxSend({ getBot: () => bot, retry: passthroughRetry }),
189
+ textAlreadyDelivered: () => false,
190
+ stateDir,
191
+ // Past the quiet window so the record is eligible this tick.
192
+ now: () => opts.now ?? Date.now() + 60_000,
193
+ quietMs: 0,
194
+ log: (l) => logLines.push(l),
195
+ ...(opts.gateEnabled === undefined ? {} : { audienceGateEnabled: () => opts.gateEnabled! }),
196
+ escalateInternalSuppression: (l) => escalations.push(l),
197
+ })
198
+ return { summary, logLines, escalations }
199
+ }
200
+
201
+ /**
202
+ * The #3861 transcript shape, verbatim in structure:
203
+ * 1. an EARLIER Telegram inbound (the session's origin chat, already answered
204
+ * — this is what `resolveSessionOriginChat` stamps as `originChatId`, and
205
+ * it is the chat the leak lands in);
206
+ * 2. a background wake (task-notification) — the anchor for THIS turn;
207
+ * 3. an interim reply-tool ack, which THROWS;
208
+ * 4. trailing working-notes prose, which capture then selects as "the answer".
209
+ */
210
+ function leakTranscript(dir: string, prose: string): string {
211
+ return writeTranscript(dir, [
212
+ {
213
+ type: 'queue-operation',
214
+ operation: 'enqueue',
215
+ content: `<channel source="telegram" chat_id="${DM_CHAT}" message_id="${PRIOR_MSG_ID}">where did the rebase land?</channel>`,
216
+ timestamp: 1000,
217
+ },
218
+ {
219
+ type: 'queue-operation',
220
+ operation: 'enqueue',
221
+ content: '<task-notification><task-id>a7cc7a0fa8f</task-id> worker done</task-notification>',
222
+ timestamp: 2000,
223
+ },
224
+ {
225
+ type: 'assistant',
226
+ message: {
227
+ content: [
228
+ {
229
+ type: 'tool_use',
230
+ id: 'toolu_reply_1',
231
+ name: 'mcp__switchroom-telegram__reply',
232
+ input: { text: 'On it.', disable_notification: true },
233
+ },
234
+ ],
235
+ },
236
+ },
237
+ {
238
+ type: 'user',
239
+ message: {
240
+ content: [
241
+ {
242
+ type: 'tool_result',
243
+ tool_use_id: 'toolu_reply_1',
244
+ is_error: true,
245
+ content: 'Error: FLOOD_WAIT_ACTIVE — send rejected',
246
+ },
247
+ ],
248
+ },
249
+ },
250
+ { type: 'assistant', message: { content: [{ type: 'text', text: prose }] } },
251
+ ])
252
+ }
253
+
254
+ describe('W1-d — audience classifier (pure)', () => {
255
+ it('marks internal ONLY on positive evidence: reply threw AND no open obligation', () => {
256
+ expect(
257
+ decideCaptureAudience({ replyToolThrewThisTurn: true, openInboundObligation: false }),
258
+ ).toBe('internal')
259
+ })
260
+
261
+ it('every other combination resolves to user (the conservative direction)', () => {
262
+ // A misclassified answer is a SILENT NO-OP (sev-3). A misclassified note is
263
+ // the status quo. So uncertainty must always land on `user`.
264
+ for (const oblig of [true, false, 'unknown'] as const) {
265
+ expect(
266
+ decideCaptureAudience({ replyToolThrewThisTurn: false, openInboundObligation: oblig }),
267
+ ).toBe('user')
268
+ }
269
+ expect(
270
+ decideCaptureAudience({ replyToolThrewThisTurn: true, openInboundObligation: true }),
271
+ ).toBe('user')
272
+ expect(
273
+ decideCaptureAudience({ replyToolThrewThisTurn: true, openInboundObligation: 'unknown' }),
274
+ ).toBe('user')
275
+ expect(decideCaptureAudience({})).toBe('user')
276
+ })
277
+
278
+ it('obligation state is three-valued: absent/corrupt snapshots are unknown, not empty', () => {
279
+ expect(resolveOpenObligation({ snapshotRaw: null, chatId: DM_CHAT })).toBe('unknown')
280
+ expect(resolveOpenObligation({ snapshotRaw: '{not json', chatId: DM_CHAT })).toBe('unknown')
281
+ expect(resolveOpenObligation({ snapshotRaw: '{"v":1}', chatId: DM_CHAT })).toBe('unknown')
282
+ expect(
283
+ resolveOpenObligation({ snapshotRaw: '{"v":1,"obligations":[]}', chatId: DM_CHAT }),
284
+ ).toBe(false)
285
+ expect(
286
+ resolveOpenObligation({
287
+ snapshotRaw: JSON.stringify({ v: 1, obligations: [openObligation(DM_CHAT)] }),
288
+ chatId: DM_CHAT,
289
+ }),
290
+ ).toBe(true)
291
+ // Scoped: an obligation in ANOTHER chat does not block this one...
292
+ expect(
293
+ resolveOpenObligation({
294
+ snapshotRaw: JSON.stringify({ v: 1, obligations: [openObligation('5550002')] }),
295
+ chatId: DM_CHAT,
296
+ }),
297
+ ).toBe(false)
298
+ // ...but an UNRESOLVED chat with someone waiting somewhere is unknown.
299
+ expect(
300
+ resolveOpenObligation({
301
+ snapshotRaw: JSON.stringify({ v: 1, obligations: [openObligation('5550002')] }),
302
+ chatId: null,
303
+ }),
304
+ ).toBe('unknown')
305
+ })
306
+
307
+ it('suppression requires the exact `internal` literal — nothing else counts', () => {
308
+ expect(resolveRecordAudience('internal')).toBe('internal')
309
+ for (const v of [undefined, null, '', 'user', 'INTERNAL', 'internal ', 1, {}, ['internal']]) {
310
+ expect(resolveRecordAudience(v)).toBe('user')
311
+ }
312
+ })
313
+ })
314
+
315
+ describe('W1-d — capture tags the audience at the real Stop hook', () => {
316
+ let dir: string
317
+ beforeEach(() => {
318
+ dir = makeStateDir()
319
+ })
320
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
321
+
322
+ it('reproduces #3861: the errored reply tool is visible in the transcript scan', () => {
323
+ const t = leakTranscript(dir, WORKING_NOTES)
324
+ const capture = scanForOutboxCapture(readFileSync(t, 'utf8')) as {
325
+ capture: boolean
326
+ text: string
327
+ replyToolThrewThisTurn: boolean
328
+ originChatId: string | null
329
+ }
330
+ // The bug shape: capture still fires (structural selection of terminal
331
+ // prose) — the working notes ARE what would be delivered.
332
+ expect(capture.capture).toBe(true)
333
+ expect(capture.text).toBe(WORKING_NOTES)
334
+ expect(capture.originChatId).toBe(DM_CHAT)
335
+ // The new signal. Pre-change the scanner never read tool_result at all.
336
+ expect(capture.replyToolThrewThisTurn).toBe(true)
337
+ })
338
+
339
+ it('the real hook writes audience:internal for the #3861 shape', () => {
340
+ writeObligations(dir, [])
341
+ const t = leakTranscript(dir, WORKING_NOTES)
342
+ const r = runHook(t, dir)
343
+ expect(r.status).toBe(0)
344
+ const records = outboxRecords(dir)
345
+ expect(records).toHaveLength(1)
346
+ expect(records[0].audience).toBe('internal')
347
+ expect(records[0].text).toBe(WORKING_NOTES)
348
+ })
349
+
350
+ it('the real hook writes audience:user when someone IS waiting on that chat', () => {
351
+ writeObligations(dir, [openObligation(DM_CHAT)])
352
+ const t = leakTranscript(dir, REAL_ANSWER)
353
+ const r = runHook(t, dir)
354
+ expect(r.status).toBe(0)
355
+ const records = outboxRecords(dir)
356
+ expect(records).toHaveLength(1)
357
+ expect(records[0].audience).toBe('user')
358
+ })
359
+
360
+ it('the real hook writes audience:user when no reply tool threw', () => {
361
+ writeObligations(dir, [])
362
+ const t = writeTranscript(dir, [
363
+ {
364
+ type: 'queue-operation',
365
+ operation: 'enqueue',
366
+ content: '<task-notification><task-id>a7cc7a0fa8f</task-id> worker done</task-notification>',
367
+ timestamp: 2000,
368
+ },
369
+ { type: 'assistant', message: { content: [{ type: 'text', text: REAL_ANSWER }] } },
370
+ ])
371
+ const r = runHook(t, dir)
372
+ expect(r.status).toBe(0)
373
+ expect(outboxRecords(dir)[0].audience).toBe('user')
374
+ })
375
+ })
376
+
377
+ describe('W1-d — the sweep cannot deliver internal prose', () => {
378
+ let dir: string
379
+ beforeEach(() => {
380
+ dir = makeStateDir()
381
+ })
382
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
383
+
384
+ /** Capture the #3861 leak shape through the REAL hook. */
385
+ function captureLeak(): RecordShape {
386
+ writeObligations(dir, [])
387
+ const t = leakTranscript(dir, WORKING_NOTES)
388
+ expect(runHook(t, dir).status).toBe(0)
389
+ const records = outboxRecords(dir)
390
+ expect(records).toHaveLength(1)
391
+ expect(records[0].audience).toBe('internal')
392
+ return records[0]
393
+ }
394
+
395
+ it('PRIMARY: a real sweep tick makes ZERO chat calls, fires ONE telemetry line, and terminally claims the entry', async () => {
396
+ const rec = captureLeak()
397
+ const bot = recordingBot()
398
+
399
+ const first = await realSweep(dir, bot)
400
+
401
+ // 1. The bar: the wrong audience CANNOT receive. Not "no sendMessage" —
402
+ // no call of ANY chat-visible method, carrying that content or not.
403
+ expect(bot.chatCalls()).toEqual([])
404
+ expect(first.summary.delivered).toBe(0)
405
+ expect(first.summary.audienceSuppressed).toBe(1)
406
+
407
+ // 2. Exactly one structured telemetry line, on the NON-CHAT channel.
408
+ expect(first.escalations).toHaveLength(1)
409
+ expect(first.escalations[0]).toContain('audience=internal')
410
+ expect(first.escalations[0]).toContain(`nonce=${rec.turnNonce}`)
411
+
412
+ // 3. Terminally claimed: the pending record is gone and the nonce is
413
+ // journaled with an explicit, named suppression outcome.
414
+ expect(outboxRecords(dir)).toHaveLength(0)
415
+ const journal = journalLines(dir)
416
+ expect(journal).toHaveLength(1)
417
+ expect(journal[0].turnNonce).toBe(rec.turnNonce)
418
+ expect(journal[0].suppressedAudience).toBe('internal')
419
+ expect(journal[0].deliverySource).toBe('sweep')
420
+ expect(journal[0].tgMessageId).toBeUndefined()
421
+
422
+ // 4. A SECOND tick sends nothing and re-escalates nothing.
423
+ const second = await realSweep(dir, bot)
424
+ expect(bot.chatCalls()).toEqual([])
425
+ expect(second.summary.scanned).toBe(0)
426
+ expect(second.escalations).toHaveLength(0)
427
+ })
428
+
429
+ it('REVERT CHECK: with the gate disabled the leak REPRODUCES (so the assertion above is load-bearing)', async () => {
430
+ captureLeak()
431
+ const bot = recordingBot()
432
+
433
+ const run = await realSweep(dir, bot, { gateEnabled: false })
434
+
435
+ // The pre-change behaviour, verbatim: the working notes go to the chat.
436
+ expect(bot.chatCalls().length).toBe(1)
437
+ expect(bot.chatCalls()[0].chatId).toBe(DM_CHAT)
438
+ expect(bot.chatCalls()[0].text).toContain('I will bisect from the merge base')
439
+ expect(run.summary.delivered).toBe(1)
440
+ expect(run.summary.audienceSuppressed).toBeUndefined()
441
+ expect(run.escalations).toHaveLength(0)
442
+ })
443
+
444
+ it('POSITIVE CONTROL: an audience:user record with an open obligation delivers unchanged', async () => {
445
+ writeObligations(dir, [openObligation(DM_CHAT)])
446
+ const t = leakTranscript(dir, REAL_ANSWER)
447
+ expect(runHook(t, dir).status).toBe(0)
448
+ const rec = outboxRecords(dir)[0]
449
+ expect(rec.audience).toBe('user')
450
+
451
+ const bot = recordingBot()
452
+ const run = await realSweep(dir, bot)
453
+
454
+ expect(run.summary.delivered).toBe(1)
455
+ expect(run.summary.audienceSuppressed).toBeUndefined()
456
+ expect(bot.chatCalls().length).toBe(1)
457
+ expect(bot.chatCalls()[0].chatId).toBe(DM_CHAT)
458
+ expect(bot.chatCalls()[0].text).toContain('The rebase is done')
459
+ const journal = journalLines(dir)
460
+ expect(journal).toHaveLength(1)
461
+ expect(journal[0].suppressedAudience).toBeUndefined()
462
+ expect(journal[0].tgMessageId).toBeDefined()
463
+ expect(run.escalations).toHaveLength(0)
464
+ })
465
+
466
+ it('LEGACY: a record with NO audience field follows the documented default (user) and delivers', async () => {
467
+ // Byte-for-byte a pre-change record: no `audience` key at all.
468
+ const outbox = join(dir, 'outbox')
469
+ mkdirSync(outbox, { recursive: true })
470
+ const nonce = `${DM_CHAT}:_#5150`
471
+ const legacy = {
472
+ turnNonce: nonce,
473
+ chatId: DM_CHAT,
474
+ threadId: null,
475
+ text: REAL_ANSWER,
476
+ textSha256: sha256Hex(REAL_ANSWER),
477
+ createdAt: 1000,
478
+ source: 'channel',
479
+ }
480
+ expect(Object.keys(legacy)).not.toContain('audience')
481
+ writeFileSync(join(outbox, `${nonce}.json`), JSON.stringify(legacy), 'utf8')
482
+
483
+ const bot = recordingBot()
484
+ const run = await realSweep(dir, bot)
485
+
486
+ expect(run.summary.delivered).toBe(1)
487
+ expect(run.summary.audienceSuppressed).toBeUndefined()
488
+ expect(bot.chatCalls().length).toBe(1)
489
+ expect(bot.chatCalls()[0].text).toContain('The rebase is done')
490
+ })
491
+
492
+ it('the gate is a property of the RECORD, not of the send adapter (W1-b swap safety)', async () => {
493
+ captureLeak()
494
+ // A send adapter that would EXPLODE if it were ever reached. The gate sits
495
+ // upstream at entry selection, so swapping the adapter (W1-b) cannot
496
+ // reintroduce the leak.
497
+ const summary = await sweepOutbox({
498
+ send: async () => {
499
+ throw new Error('send adapter must never be reached for an internal record')
500
+ },
501
+ textAlreadyDelivered: () => false,
502
+ stateDir: dir,
503
+ now: () => Date.now() + 60_000,
504
+ quietMs: 0,
505
+ escalateInternalSuppression: () => {},
506
+ })
507
+ expect(summary.audienceSuppressed).toBe(1)
508
+ expect(summary.sendFailures).toBe(0)
509
+ })
510
+ })
511
+
512
+ describe('W1-d — suppression is honest telemetry, not a delivery failure', () => {
513
+ let dir: string
514
+ beforeEach(() => {
515
+ dir = makeStateDir()
516
+ })
517
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
518
+
519
+ it('the suppression line matches NO gateway alarm signature', () => {
520
+ const line = formatInternalSuppression({
521
+ turnNonce: `${DM_CHAT}:_#${PRIOR_MSG_ID}`,
522
+ turnId: `${DM_CHAT}:_#${PRIOR_MSG_ID}`,
523
+ chatId: DM_CHAT,
524
+ textSha256: sha256Hex(WORKING_NOTES),
525
+ ageMs: 61_000,
526
+ source: 'task-notification',
527
+ pastWindow: true,
528
+ })
529
+ for (const [name, re] of Object.entries(GATEWAY_SIGNATURES)) {
530
+ expect(re.test(line), `${name} must not match a by-design suppression`).toBe(false)
531
+ }
532
+ const gw = detectGatewayFindings('klanker', line)
533
+ expect(gw.findings).toEqual([])
534
+ })
535
+
536
+ it('the suppression line carries a turn id the fleet-health scanner can join on', () => {
537
+ const nonce = `${DM_CHAT}:_#${PRIOR_MSG_ID}`
538
+ expect(turnIdFromNonce(nonce)).toBe(nonce)
539
+ // The sha256 fallback nonce (envelope-less anchor) is NOT a turn id and
540
+ // must not be emitted as one.
541
+ expect(turnIdFromNonce('a'.repeat(64))).toBeNull()
542
+ const line = formatInternalSuppression({ turnNonce: nonce, turnId: nonce, chatId: DM_CHAT })
543
+ expect(extractTurnId(line)).toBe(nonce)
544
+ })
545
+
546
+ it('a suppressed turn cannot present as silent-no-op:completed-zero-tools', () => {
547
+ // Structural argument, checked rather than asserted by hand: suppression is
548
+ // only reachable when the reply tool was CALLED and threw, so the turn has
549
+ // >= 1 tool call — and the detector's silent-no-op branch requires
550
+ // `tools === 0` (src/fleet-health/detect.ts:305-333). The two shapes are
551
+ // disjoint by construction.
552
+ const now = Math.floor(Date.now() / 1000)
553
+ const row = buildTurnRecord(
554
+ {
555
+ agent: 'klanker',
556
+ startedAt: 1000,
557
+ toolCallCount: 1, // the reply tool call that threw
558
+ turnId: `${DM_CHAT}:_#${PRIOR_MSG_ID}`,
559
+ finalAnswerDelivered: false,
560
+ replyCalled: true,
561
+ },
562
+ Date.now(),
563
+ )
564
+ expect(row.tools).toBe(1)
565
+ // The turn row is written honestly at turn end: nothing reached the user.
566
+ expect(row.status).toBe('no_reply')
567
+ expect(row.route).toBe('none')
568
+ const findings = detectTurnFindings('klanker', [{ ...row, ts: now }])
569
+ expect(findings.filter((f) => f.signal === 'silent-no-op-candidate')).toEqual([])
570
+ })
571
+
572
+ it('the sweep gate itself is pure and record-scoped', () => {
573
+ expect(shouldSuppressForAudience({ audience: 'internal' })).toBe(true)
574
+ expect(shouldSuppressForAudience({ audience: 'user' })).toBe(false)
575
+ expect(shouldSuppressForAudience({})).toBe(false)
576
+ // Kill switch (revert-check seam).
577
+ expect(shouldSuppressForAudience({ audience: 'internal' }, { gateEnabled: false })).toBe(false)
578
+ })
579
+ })