switchroom 0.20.11 → 0.20.13

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 (50) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +32 -25
  3. package/dist/cli/notion-write-pretool.mjs +5 -2
  4. package/dist/cli/self-improve-stop.mjs +13 -1
  5. package/dist/cli/switchroom.js +3069 -1146
  6. package/dist/host-control/main.js +33 -26
  7. package/dist/vault/approvals/kernel-server.js +32 -25
  8. package/dist/vault/broker/server.js +32 -25
  9. package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
  10. package/package.json +7 -4
  11. package/skills/switchroom-architecture/telegram.md +0 -1
  12. package/skills/switchroom-cli/SKILL.md +0 -1
  13. package/skills/switchroom-release/SKILL.md +3 -2
  14. package/telegram-plugin/README.md +2 -11
  15. package/telegram-plugin/bridge/bridge.ts +0 -12
  16. package/telegram-plugin/bunfig.toml +9 -5
  17. package/telegram-plugin/chat-lock.ts +1 -1
  18. package/telegram-plugin/dist/bridge/bridge.js +0 -12
  19. package/telegram-plugin/dist/gateway/gateway.js +219 -127
  20. package/telegram-plugin/dist/server.js +0 -12
  21. package/telegram-plugin/gateway/captured-answer-resume.ts +23 -1
  22. package/telegram-plugin/gateway/gateway.ts +25 -59
  23. package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
  24. package/telegram-plugin/gateway/outbound-send-path.ts +111 -6
  25. package/telegram-plugin/gateway/outbox-sweep.ts +69 -0
  26. package/telegram-plugin/gateway/stale-pin-sweep.ts +4 -3
  27. package/telegram-plugin/gateway/status-pin-store.ts +10 -9
  28. package/telegram-plugin/gateway/stream-render.ts +8 -4
  29. package/telegram-plugin/gateway/turn-record-status.ts +32 -1
  30. package/telegram-plugin/hooks/audience-classify.d.mts +26 -0
  31. package/telegram-plugin/hooks/audience-classify.mjs +193 -0
  32. package/telegram-plugin/hooks/hooks.json +13 -12
  33. package/telegram-plugin/hooks/narration-classify.mjs +1 -2
  34. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +31 -1
  35. package/telegram-plugin/hooks/silent-end-scan.mjs +9 -2
  36. package/telegram-plugin/outbox.ts +69 -3
  37. package/telegram-plugin/silent-end.ts +48 -5
  38. package/telegram-plugin/status-pin.ts +2 -5
  39. package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
  40. package/telegram-plugin/tests/captured-answer-resume.test.ts +26 -11
  41. package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
  42. package/telegram-plugin/tests/hindsight-bank-preload.test.ts +50 -0
  43. package/telegram-plugin/tests/outbox-live-path-review-4490.test.ts +613 -0
  44. package/telegram-plugin/tests/outbox-self-improve-review.test.ts +401 -0
  45. package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
  46. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
  47. package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
  48. package/telegram-plugin/worker-activity-feed.ts +1 -1
  49. package/vendor/hindsight-memory/scripts/recall.py +140 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
@@ -0,0 +1,613 @@
1
+ /**
2
+ * outbox-live-path-review-4490.test.ts — the self-improvement card gate on
3
+ * the LIVE (elected) send path, issue #4490 (follow-up to PR #4485 / #4141).
4
+ *
5
+ * THE GAP THIS CLOSES
6
+ * --------------------
7
+ * #4485 wired the audience gate + title framing into the OUTBOX SWEEP only
8
+ * (`decideOutboxSweep` in `outbox.ts`, exercised end-to-end in
9
+ * `outbox-self-improve-review.test.ts`). But the outbox sweep only ever
10
+ * fires when the Stop hook writes a durable outbox RECORD, and — exactly as
11
+ * #4141 already found for the reply-throw case — the DOMINANT live shape
12
+ * never does that:
13
+ *
14
+ * A self-improvement review turn calls the reply tool (itself a prompt
15
+ * violation — the review's contract is to act silently), and it throws.
16
+ * `scanForOutboxCapture` classifies the reply-tool call from its INPUT,
17
+ * sees a QUALIFYING final answer, and marks
18
+ * `replyAlreadyDeliveredThisTurn = true` even though the call errored. The
19
+ * Stop hook's single-writer election therefore takes the #3510 branch,
20
+ * writes NO outbox record, and hands the trailing prose to the gateway's
21
+ * captured-prose bridge (`deliverCapturedProse`,
22
+ * `gateway/outbound-send-path.ts`) — a machine the outbox sweep's card
23
+ * gate can never reach.
24
+ *
25
+ * Before this fix that bridge had NO audience gate and NO self-improvement
26
+ * framing at all: a non-card review turn's raw, unlabelled reasoning would
27
+ * be sent to the operator exactly like any other captured prose — the same
28
+ * leak #4485 closed on the sweep, still open on the bridge.
29
+ *
30
+ * WHAT THIS FILE PINS
31
+ * --------------------
32
+ * (0) `parseChannelEnvelope` recovers `source="self_improve_review"` from
33
+ * the REAL double-sourced envelope shape the gateway actually emits
34
+ * (`<channel source="switchroom-telegram" … source="self_improve_review"
35
+ * …>`), not just the single-sourced shape other fixtures use.
36
+ * (a) SURFACING: a review turn ending in a CARD is delivered, self-labelled.
37
+ * (b) NO-OP: a review turn ending in RAW REASONING is suppressed on the
38
+ * bridge — zero chat-visible calls.
39
+ * (b') REVERT CHECK: with the audience gate off, the same raw reasoning
40
+ * still reaches the operator (proving (b) is load-bearing) but titled
41
+ * (the residual belt-and-braces framing, so it can never be raw).
42
+ * (c) ANTI-SILENCE / SEVERITY-3: a genuine waiting operator's answer is
43
+ * NEVER swallowed by the review branch — both for a normal foreground
44
+ * turn, and for the specific shape the reviewer flagged: a user
45
+ * message arriving mid-review-turn becomes the LAST enqueue, so
46
+ * `resolveCaptureAnchor` anchors on it instead of the review's own
47
+ * enqueue, and the review branch must not fire at all.
48
+ * (d) WIRED: the four hops between the transcript scan and the bridge
49
+ * (`silent-end-scan.mjs` → `silent-end-interrupt-stop.mjs` →
50
+ * `silent-end.ts` → `stream-render.ts`) actually carry the signal in
51
+ * production, not just in a hand-built `deliverCapturedProse` call.
52
+ *
53
+ * Every assertion drives REAL machinery: the REAL Stop hook spawned as a
54
+ * subprocess against a REAL transcript, the REAL `decideCapturedProseDelivery`,
55
+ * and the REAL `deliverCapturedProse` against a RECORDING fake Bot API.
56
+ */
57
+
58
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
59
+ import { spawnSync } from 'node:child_process'
60
+ import { mkdtempSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'node:fs'
61
+ import { tmpdir } from 'node:os'
62
+ import { join, resolve } from 'node:path'
63
+
64
+ import {
65
+ SELF_IMPROVEMENT_TITLE,
66
+ REVIEW_SOURCE,
67
+ } from '../hooks/audience-classify.mjs'
68
+ import { scanTurnForFinalReply } from '../hooks/silent-end-scan.mjs'
69
+ import { deliverCapturedProse } from '../gateway/outbound-send-path.js'
70
+ import { decideCapturedProseDelivery } from '../silent-end.js'
71
+ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
72
+
73
+ const HOOK = resolve(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs')
74
+
75
+ // Synthetic ids only (check-no-pii-secrets forbids real chat/user ids).
76
+ const DM_CHAT = '5550002'
77
+ const INBOUND_MSG_ID = 9130
78
+ const USER_MSG_ID = 9131
79
+
80
+ /** Raw review reasoning — must never reach the operator unlabelled. */
81
+ const REVIEW_REASONING = [
82
+ 'The failing assertion traced back to a stale fixture, not the code path I',
83
+ 'was reviewing, so I am not proposing a change here — logging this as a',
84
+ 'non-finding rather than forcing a suggestion just to have said something.',
85
+ ].join(' ')
86
+
87
+ /** A well-formed card, exactly as `buildReviewPrompt` instructs the model. */
88
+ const REVIEW_CARD = [
89
+ `${SELF_IMPROVEMENT_TITLE} — pending suggestion logged`,
90
+ '- **Signal:** repeated manual retry of the same flaky assertion',
91
+ '- **Suggestion:** add a fixture reset before the retry loop',
92
+ '- **Status:** T2, logged for your review, nothing auto-applied',
93
+ ].join('\n')
94
+
95
+ /**
96
+ * A normal operator answer on a normal (non-review) turn. Must clear the
97
+ * scanner's 200-char `FINAL_ANSWER_MIN_CHARS` substance floor for the
98
+ * `trailing-text-after-reply` path (silent-end-scan.mjs:578) — a qualifying
99
+ * reply-tool call already happened in these fixtures (it just threw), so
100
+ * anything shorter is amnestied as a mere "interim ack closer" and the scan
101
+ * returns `{decided:'allow'}` instead of electing the bridge, which starved
102
+ * these fixtures of any elected state to test against.
103
+ */
104
+ const NORMAL_ANSWER =
105
+ 'The deploy went out cleanly and the health check came back green a minute ' +
106
+ 'later, all three services reporting healthy and no errors in the logs ' +
107
+ 'since the rollout finished. Latency held steady through the rollout window ' +
108
+ 'and no alerts fired on any of the dashboards I checked afterward.'
109
+
110
+ function makeStateDir(): string {
111
+ // NEVER ~/.switchroom — a test that writes there corrupts production state.
112
+ return mkdtempSync(join(tmpdir(), 'w4490-live-review-'))
113
+ }
114
+
115
+ function writeTranscript(dir: string, lines: object[]): string {
116
+ const p = join(dir, 'transcript.jsonl')
117
+ writeFileSync(p, lines.map((l) => JSON.stringify(l)).join('\n'), 'utf8')
118
+ return p
119
+ }
120
+
121
+ function runHook(transcriptPath: string, stateDir: string, extraEnv: Record<string, string> = {}) {
122
+ return spawnSync('node', [HOOK], {
123
+ input: JSON.stringify({ session_id: 's', transcript_path: transcriptPath }),
124
+ encoding: 'utf8',
125
+ timeout: 10_000,
126
+ env: { ...process.env, TELEGRAM_STATE_DIR: stateDir, ...extraEnv },
127
+ })
128
+ }
129
+
130
+ function writeFreshHeartbeat(dir: string): void {
131
+ writeFileSync(join(dir, 'gateway-heartbeat'), 'up', 'utf8')
132
+ }
133
+
134
+ function readElectedState(dir: string): Record<string, unknown> | null {
135
+ const p = join(dir, 'silent-end-pending.json')
136
+ if (!existsSync(p)) return null
137
+ return JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown>
138
+ }
139
+
140
+ function journalLines(dir: string): Array<Record<string, unknown>> {
141
+ const p = join(dir, 'outbox', 'delivered.jsonl')
142
+ if (!existsSync(p)) return []
143
+ return readFileSync(p, 'utf8')
144
+ .split('\n')
145
+ .filter((l) => l.trim().length > 0)
146
+ .map((l) => JSON.parse(l) as Record<string, unknown>)
147
+ }
148
+
149
+ function outboxRecordCount(dir: string): number {
150
+ const outbox = join(dir, 'outbox')
151
+ if (!existsSync(outbox)) return 0
152
+ return readdirSync(outbox).filter((f) => f.endsWith('.json') && f !== 'delivered.jsonl').length
153
+ }
154
+
155
+ /**
156
+ * A RECORDING fake Bot API — every chat-visible method is counted, so a
157
+ * send-count assertion covers all of them, not just the one the test
158
+ * remembered to stub.
159
+ */
160
+ function recordingBot() {
161
+ const calls: Array<{ method: string; chatId: string; text: string }> = []
162
+ let nextId = 900
163
+ return {
164
+ calls,
165
+ chatCalls: () => calls,
166
+ api: {
167
+ sendRichMessage: async (chatId: string, body: { markdown: string }) => {
168
+ calls.push({ method: 'sendRichMessage', chatId, text: body.markdown })
169
+ return { message_id: nextId++ }
170
+ },
171
+ sendMessage: async (chatId: string, text: string) => {
172
+ calls.push({ method: 'sendMessage', chatId, text })
173
+ return { message_id: nextId++ }
174
+ },
175
+ editMessageText: async (chatId: string, _mid: number, text: string) => {
176
+ calls.push({ method: 'editMessageText', chatId, text })
177
+ return { message_id: nextId++ }
178
+ },
179
+ },
180
+ }
181
+ }
182
+
183
+ const passthroughRetry = <U>(fn: () => Promise<U>): Promise<U> => fn()
184
+
185
+ /**
186
+ * The REAL review-turn ELECTED shape, using the REAL double-sourced envelope
187
+ * (`source="switchroom-telegram"` from the outer wrapper, THEN
188
+ * `source="self_improve_review"` from the synthesized-inbound meta the
189
+ * gateway hoists onto the same tag — `review-prompt.ts:60-61`), a QUALIFYING
190
+ * reply-tool call (no `disable_notification`, so `isFinalAnswerReply` is true
191
+ * on the input alone — the review turn violating its own silent-turn
192
+ * contract), which THROWS, followed by trailing prose over the bridge's
193
+ * floor.
194
+ */
195
+ function electedReviewThrowTranscript(dir: string, prose: string): string {
196
+ return writeTranscript(dir, [
197
+ {
198
+ type: 'queue-operation',
199
+ operation: 'enqueue',
200
+ content:
201
+ `<channel source="switchroom-telegram" chat_id="${DM_CHAT}" ` +
202
+ `message_id="${INBOUND_MSG_ID}" source="${REVIEW_SOURCE}">` +
203
+ `[self-improvement review] The turn-end gate detected a learning signal. ` +
204
+ `Run a focused, forked review.</channel>`,
205
+ timestamp: 1000,
206
+ },
207
+ {
208
+ type: 'assistant',
209
+ message: {
210
+ content: [
211
+ {
212
+ type: 'tool_use',
213
+ id: 'toolu_review_elected',
214
+ name: 'mcp__switchroom-telegram__reply',
215
+ input: { text: 'Here is what I found.' },
216
+ },
217
+ ],
218
+ },
219
+ },
220
+ {
221
+ type: 'user',
222
+ message: {
223
+ content: [
224
+ {
225
+ type: 'tool_result',
226
+ tool_use_id: 'toolu_review_elected',
227
+ is_error: true,
228
+ content: 'Error: request timed out after 30000ms',
229
+ },
230
+ ],
231
+ },
232
+ },
233
+ { type: 'assistant', message: { content: [{ type: 'text', text: prose }] } },
234
+ ])
235
+ }
236
+
237
+ /**
238
+ * The SEVERITY-3 shape: the review's own enqueue, THEN a genuine user
239
+ * message enqueued mid-turn (the LAST enqueue in the file), THEN the
240
+ * review's reply-tool throw and trailing prose. `resolveCaptureAnchor`
241
+ * anchors on the LAST enqueue, so this transcript's anchor is the user
242
+ * message, not the review — the review's `source` must never leak through.
243
+ */
244
+ function userMessageDuringReviewTranscript(dir: string, prose: string): string {
245
+ return writeTranscript(dir, [
246
+ {
247
+ type: 'queue-operation',
248
+ operation: 'enqueue',
249
+ content:
250
+ `<channel source="switchroom-telegram" chat_id="${DM_CHAT}" ` +
251
+ `message_id="${INBOUND_MSG_ID}" source="${REVIEW_SOURCE}">` +
252
+ `[self-improvement review] The turn-end gate detected a learning signal.</channel>`,
253
+ timestamp: 1000,
254
+ },
255
+ {
256
+ type: 'queue-operation',
257
+ operation: 'enqueue',
258
+ content: `<channel source="telegram" chat_id="${DM_CHAT}" message_id="${USER_MSG_ID}">are you still there?</channel>`,
259
+ timestamp: 2000,
260
+ },
261
+ {
262
+ type: 'assistant',
263
+ message: {
264
+ content: [
265
+ {
266
+ type: 'tool_use',
267
+ id: 'toolu_review_mid',
268
+ name: 'mcp__switchroom-telegram__reply',
269
+ input: { text: 'One sec.' },
270
+ },
271
+ ],
272
+ },
273
+ },
274
+ {
275
+ type: 'user',
276
+ message: {
277
+ content: [
278
+ {
279
+ type: 'tool_result',
280
+ tool_use_id: 'toolu_review_mid',
281
+ is_error: true,
282
+ content: 'Error: request timed out after 30000ms',
283
+ },
284
+ ],
285
+ },
286
+ },
287
+ { type: 'assistant', message: { content: [{ type: 'text', text: prose }] } },
288
+ ])
289
+ }
290
+
291
+ /** Drive the REAL captured-prose bridge against the recording bot. */
292
+ async function realBridge(
293
+ stateDir: string,
294
+ bot: ReturnType<typeof recordingBot>,
295
+ decision: { text?: string; replyToolThrewThisTurn?: boolean; reviewOriginated?: boolean },
296
+ ) {
297
+ const stderr: string[] = []
298
+ const prevStateDir = process.env.TELEGRAM_STATE_DIR
299
+ process.env.TELEGRAM_STATE_DIR = stateDir
300
+ const origWrite = process.stderr.write.bind(process.stderr)
301
+ ;(process.stderr as unknown as { write: (s: string) => boolean }).write = (s: string) => {
302
+ stderr.push(String(s))
303
+ return true
304
+ }
305
+ try {
306
+ await deliverCapturedProse(
307
+ {
308
+ outboundDedup: new OutboundDedupCache({}),
309
+ bot: bot as never,
310
+ robustApiCall: passthroughRetry,
311
+ redactOutboundText: (t: string) => t,
312
+ recordOutbound: () => {},
313
+ HISTORY_ENABLED: false,
314
+ OBLIGATION_LEDGER_ENABLED: false,
315
+ obligationLedger: { close: () => {} },
316
+ clearSilentEndState: () => {},
317
+ recordUndeliveredTurnEnd: () => ({ exhausted: false }),
318
+ hasOutboundDeliveredSince: () => false,
319
+ },
320
+ {
321
+ chatId: DM_CHAT,
322
+ threadId: undefined,
323
+ statusKeyStr: `${DM_CHAT}:_`,
324
+ registryKey: null,
325
+ originTurnId: NONCE,
326
+ text: decision.text!,
327
+ replyToolThrewThisTurn: decision.replyToolThrewThisTurn === true,
328
+ reviewOriginated: decision.reviewOriginated === true,
329
+ },
330
+ )
331
+ } finally {
332
+ ;(process.stderr as unknown as { write: typeof origWrite }).write = origWrite
333
+ if (prevStateDir === undefined) delete process.env.TELEGRAM_STATE_DIR
334
+ else process.env.TELEGRAM_STATE_DIR = prevStateDir
335
+ }
336
+ return { stderr }
337
+ }
338
+
339
+ const NONCE = `${DM_CHAT}:_#${INBOUND_MSG_ID}`
340
+
341
+ describe('#4490 (0) — the envelope parser recovers the review source from the REAL double-sourced tag', () => {
342
+ it('scanTurnForFinalReply carries the double-sourced envelope\'s source through to the block result', () => {
343
+ const dir = makeStateDir()
344
+ try {
345
+ const t = electedReviewThrowTranscript(dir, REVIEW_REASONING)
346
+ const result = scanTurnForFinalReply(readFileSync(t, 'utf8')) as {
347
+ decided: string
348
+ source?: string
349
+ replyToolThrewThisTurn?: boolean
350
+ }
351
+ expect(result.decided).toBe('block')
352
+ expect(result.source).toBe(REVIEW_SOURCE)
353
+ expect(result.replyToolThrewThisTurn).toBe(true)
354
+ } finally {
355
+ rmSync(dir, { recursive: true, force: true })
356
+ }
357
+ })
358
+ })
359
+
360
+ describe('#4490 (d) — the signal is WIRED end to end through the real Stop hook', () => {
361
+ let dir: string
362
+ beforeEach(() => {
363
+ dir = makeStateDir()
364
+ writeFreshHeartbeat(dir)
365
+ })
366
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
367
+
368
+ it('PREMISE: a thrown QUALIFYING reply on a review turn writes no outbox record and elects the bridge', () => {
369
+ const t = electedReviewThrowTranscript(dir, REVIEW_REASONING)
370
+ const res = runHook(t, dir)
371
+ expect(res.status).toBe(0)
372
+ expect(outboxRecordCount(dir)).toBe(0)
373
+ expect(res.stderr).toContain('deferring to single-writer election')
374
+ })
375
+
376
+ it('the elected state file carries reviewOriginated to the bridge', () => {
377
+ runHook(electedReviewThrowTranscript(dir, REVIEW_REASONING), dir)
378
+ const state = readElectedState(dir)
379
+ expect(state).not.toBeNull()
380
+ expect(state!.reviewOriginated).toBe(true)
381
+ expect(state!.pendingText).toBe(REVIEW_REASONING)
382
+ })
383
+
384
+ it('a NON-review turn carries no reviewOriginated field — no stale carryover', () => {
385
+ // Seed a state file that already claims review-origin, then run a CLEAN
386
+ // (non-review) turn. `buildNextState` must DELETE the field, not spread
387
+ // it forward.
388
+ writeFileSync(
389
+ join(dir, 'silent-end-pending.json'),
390
+ JSON.stringify({ retryCount: 0, reviewOriginated: true, turnKey: 'stale' }),
391
+ 'utf8',
392
+ )
393
+ const t = writeTranscript(dir, [
394
+ {
395
+ type: 'queue-operation',
396
+ operation: 'enqueue',
397
+ content: `<channel source="telegram" chat_id="${DM_CHAT}" message_id="${INBOUND_MSG_ID}">status?</channel>`,
398
+ timestamp: 1000,
399
+ },
400
+ {
401
+ type: 'assistant',
402
+ message: {
403
+ content: [
404
+ {
405
+ type: 'tool_use',
406
+ id: 'toolu_normal',
407
+ name: 'mcp__switchroom-telegram__reply',
408
+ input: { text: 'checking' },
409
+ },
410
+ ],
411
+ },
412
+ },
413
+ {
414
+ type: 'user',
415
+ message: {
416
+ content: [{ type: 'tool_result', tool_use_id: 'toolu_normal', is_error: true, content: 'Error: timeout' }],
417
+ },
418
+ },
419
+ { type: 'assistant', message: { content: [{ type: 'text', text: NORMAL_ANSWER }] } },
420
+ ])
421
+ runHook(t, dir)
422
+ const state = readElectedState(dir)
423
+ expect(state).not.toBeNull()
424
+ expect(state!.reviewOriginated).toBeUndefined()
425
+ })
426
+
427
+ it('decideCapturedProseDelivery surfaces reviewOriginated from the state file', () => {
428
+ runHook(electedReviewThrowTranscript(dir, REVIEW_REASONING), dir)
429
+ const decision = decideCapturedProseDelivery(
430
+ { turnKey: `${DM_CHAT}:_`, turnId: NONCE },
431
+ { stateDir: dir },
432
+ )
433
+ expect(decision.deliver).toBe(true)
434
+ expect(decision.reviewOriginated).toBe(true)
435
+ expect(decision.text).toBe(REVIEW_REASONING)
436
+ })
437
+
438
+ it('stream-render forwards reviewOriginated from the decision into the bridge call (structural pin)', () => {
439
+ // Mirrors the #4141 structural pin for `replyToolThrewThisTurn`: the ONE
440
+ // link between the decision and the bridge lives inside a ~2000-line
441
+ // event handler that cannot be driven in isolation, so pin it by source
442
+ // inspection — without this argument the feature is inert in production
443
+ // while every decision-side / bridge-side test in this file still passes.
444
+ const src = readFileSync(resolve(__dirname, '..', 'gateway', 'stream-render.ts'), 'utf8')
445
+ const call = src.slice(src.indexOf('void deliverCapturedProse({'))
446
+ const body = call.slice(0, call.indexOf('\n })'))
447
+ expect(body).toContain('reviewOriginated: proseDecision.reviewOriginated === true')
448
+ })
449
+ })
450
+
451
+ describe('#4490 (a)/(b)/(b\') — the card gate on the LIVE bridge, end to end', () => {
452
+ let dir: string
453
+ beforeEach(() => {
454
+ dir = makeStateDir()
455
+ writeFreshHeartbeat(dir)
456
+ })
457
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
458
+
459
+ it('(a) SURFACING: a review turn ending in a CARD is delivered on the bridge, self-labelled', async () => {
460
+ runHook(electedReviewThrowTranscript(dir, REVIEW_CARD), dir)
461
+ const decision = decideCapturedProseDelivery(
462
+ { turnKey: `${DM_CHAT}:_`, turnId: NONCE },
463
+ { stateDir: dir },
464
+ )
465
+ expect(decision.reviewOriginated).toBe(true)
466
+
467
+ const bot = recordingBot()
468
+ await realBridge(dir, bot, decision)
469
+
470
+ expect(bot.chatCalls()).toHaveLength(1)
471
+ const sent = bot.chatCalls()[0]!.text
472
+ // This fixture's qualifying reply also THREW (#4141 provenance banner),
473
+ // so the composed body is [banner][card] — the banner is prepended
474
+ // before the card, not the title. Assert exactly one title occurrence
475
+ // (no double-framing of an already-titled card, per #4489) rather than
476
+ // `startsWith`, which would be a false requirement here.
477
+ const titleOccurrences = sent.split(SELF_IMPROVEMENT_TITLE).length - 1
478
+ expect(titleOccurrences).toBe(1)
479
+ expect(sent).toContain('add a fixture reset before the retry loop')
480
+ expect(journalLines(dir)[0]!.suppressedAudience).toBeUndefined()
481
+ expect(journalLines(dir)[0]!.audience).toBe('user')
482
+ })
483
+
484
+ it('(b) NO-OP — THE GAP THIS CLOSES: a review turn ending in RAW REASONING is suppressed on the bridge, zero sends', async () => {
485
+ runHook(electedReviewThrowTranscript(dir, REVIEW_REASONING), dir)
486
+ const decision = decideCapturedProseDelivery(
487
+ { turnKey: `${DM_CHAT}:_`, turnId: NONCE },
488
+ { stateDir: dir },
489
+ )
490
+ expect(decision.reviewOriginated).toBe(true)
491
+
492
+ const bot = recordingBot()
493
+ await realBridge(dir, bot, decision)
494
+
495
+ // THE assertion this whole file exists to make: before #4490's fix this
496
+ // path had no audience gate at all, so the raw reasoning was sent here
497
+ // exactly like any other captured prose.
498
+ expect(bot.chatCalls()).toEqual([])
499
+ const journal = journalLines(dir)
500
+ expect(journal).toHaveLength(1)
501
+ expect(journal[0]!.suppressedAudience).toBe('internal')
502
+ expect(journal[0]!.audience).toBe('internal')
503
+ expect(journal[0]!.tgMessageId).toBeUndefined()
504
+ })
505
+
506
+ it('(b\') REVERT CHECK: with the audience gate OFF the same reasoning still reaches the operator — but TITLED, never raw', async () => {
507
+ const prev = process.env.SWITCHROOM_TG_OUTBOX_AUDIENCE_GATE
508
+ process.env.SWITCHROOM_TG_OUTBOX_AUDIENCE_GATE = '0'
509
+ try {
510
+ runHook(electedReviewThrowTranscript(dir, REVIEW_REASONING), dir)
511
+ const decision = decideCapturedProseDelivery(
512
+ { turnKey: `${DM_CHAT}:_`, turnId: NONCE },
513
+ { stateDir: dir },
514
+ )
515
+ const bot = recordingBot()
516
+ await realBridge(dir, bot, decision)
517
+
518
+ expect(bot.chatCalls()).toHaveLength(1)
519
+ const sent = bot.chatCalls()[0]!.text
520
+ // The raw reasoning is present (degraded-config leak surface) BUT
521
+ // titled — the belt-and-braces residual guarantee.
522
+ expect(sent).toContain(REVIEW_REASONING)
523
+ expect(sent.startsWith(SELF_IMPROVEMENT_TITLE)).toBe(true)
524
+ expect(journalLines(dir)[0]!.framedSelfImprovement).toBe('self-improve')
525
+ } finally {
526
+ if (prev === undefined) delete process.env.SWITCHROOM_TG_OUTBOX_AUDIENCE_GATE
527
+ else process.env.SWITCHROOM_TG_OUTBOX_AUDIENCE_GATE = prev
528
+ }
529
+ })
530
+ })
531
+
532
+ describe('#4490 (c) — ANTI-SILENCE / SEVERITY-3: a genuine waiting operator is never swallowed', () => {
533
+ let dir: string
534
+ beforeEach(() => {
535
+ dir = makeStateDir()
536
+ writeFreshHeartbeat(dir)
537
+ })
538
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
539
+
540
+ it('a normal (non-review) foreground turn is delivered in full, unsuppressed', async () => {
541
+ const t = writeTranscript(dir, [
542
+ {
543
+ type: 'queue-operation',
544
+ operation: 'enqueue',
545
+ content: `<channel source="telegram" chat_id="${DM_CHAT}" message_id="${INBOUND_MSG_ID}">how did the deploy go?</channel>`,
546
+ timestamp: 1000,
547
+ },
548
+ {
549
+ type: 'assistant',
550
+ message: {
551
+ content: [
552
+ {
553
+ type: 'tool_use',
554
+ id: 'toolu_normal_2',
555
+ name: 'mcp__switchroom-telegram__reply',
556
+ input: { text: 'checking' },
557
+ },
558
+ ],
559
+ },
560
+ },
561
+ {
562
+ type: 'user',
563
+ message: {
564
+ content: [{ type: 'tool_result', tool_use_id: 'toolu_normal_2', is_error: true, content: 'Error: timeout' }],
565
+ },
566
+ },
567
+ { type: 'assistant', message: { content: [{ type: 'text', text: NORMAL_ANSWER }] } },
568
+ ])
569
+ runHook(t, dir)
570
+ const decision = decideCapturedProseDelivery(
571
+ { turnKey: `${DM_CHAT}:_`, turnId: `${DM_CHAT}:_#${INBOUND_MSG_ID}` },
572
+ { stateDir: dir },
573
+ )
574
+ expect(decision.reviewOriginated).toBeUndefined()
575
+
576
+ const bot = recordingBot()
577
+ await realBridge(dir, bot, decision)
578
+ expect(bot.chatCalls()).toHaveLength(1)
579
+ expect(bot.chatCalls()[0]!.text).toContain(NORMAL_ANSWER)
580
+ })
581
+
582
+ it('SEVERITY-3: a user message arriving mid-review-turn becomes the anchor — the review source never leaks through, and the answer is delivered, not swallowed', async () => {
583
+ // `resolveCaptureAnchor` (silent-end-scan.mjs) anchors on the LAST
584
+ // enqueue in the transcript. Here the review's own enqueue is FIRST and
585
+ // a genuine user message is enqueued SECOND (mid-turn), so the anchor —
586
+ // and therefore `envelope.source` — must be the user's `"telegram"`
587
+ // source, not the review's. If this regressed (e.g. anchoring on the
588
+ // FIRST enqueue instead), a real waiting operator's answer would be
589
+ // misclassified as review-internal and suppressed — strictly worse than
590
+ // the pre-#4490 behaviour on a turn that was never a review leak at all.
591
+ const t = userMessageDuringReviewTranscript(dir, NORMAL_ANSWER)
592
+ const scan = scanTurnForFinalReply(readFileSync(t, 'utf8')) as { source?: string }
593
+ expect(scan.source).toBe('telegram')
594
+ expect(scan.source).not.toBe(REVIEW_SOURCE)
595
+
596
+ runHook(t, dir)
597
+ const state = readElectedState(dir)
598
+ expect(state).not.toBeNull()
599
+ expect(state!.reviewOriginated).toBeUndefined()
600
+
601
+ const decision = decideCapturedProseDelivery(
602
+ { turnKey: `${DM_CHAT}:_`, turnId: `${DM_CHAT}:_#${USER_MSG_ID}` },
603
+ { stateDir: dir },
604
+ )
605
+ expect(decision.reviewOriginated).toBeUndefined()
606
+
607
+ const bot = recordingBot()
608
+ await realBridge(dir, bot, decision)
609
+ // The answer reaches the operator — not suppressed as review-internal.
610
+ expect(bot.chatCalls()).toHaveLength(1)
611
+ expect(bot.chatCalls()[0]!.text).toContain(NORMAL_ANSWER)
612
+ })
613
+ })