switchroom 0.18.31 → 0.18.33

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 (159) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +21 -3
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1410 -852
  5. package/dist/host-control/main.js +22 -4
  6. package/dist/vault/approvals/kernel-server.js +21 -3
  7. package/dist/vault/broker/server.js +48 -4
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +148 -23
  10. package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
  11. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  12. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  13. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  14. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  15. package/telegram-plugin/gateway/boot-card.ts +169 -1
  16. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  17. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  19. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  20. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  21. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  22. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  23. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  24. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  25. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  26. package/telegram-plugin/gateway/gateway.ts +6403 -13456
  27. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  28. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  29. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  30. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  32. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  33. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  34. package/telegram-plugin/gateway/model-command.ts +23 -0
  35. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  36. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  37. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  38. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  39. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  40. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  41. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  42. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  43. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  44. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  45. package/telegram-plugin/gateway/turn-end.ts +606 -0
  46. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  47. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  48. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  49. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  50. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  51. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  52. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  53. package/telegram-plugin/narrative-dedup.ts +24 -1
  54. package/telegram-plugin/narrative-flush.ts +2 -2
  55. package/telegram-plugin/pending-user-notice.ts +59 -13
  56. package/telegram-plugin/render/render.ts +25 -1
  57. package/telegram-plugin/status-no-truncate.ts +13 -0
  58. package/telegram-plugin/subagent-watcher.ts +297 -31
  59. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  60. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  61. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  62. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  63. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  64. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  65. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  66. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  67. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  68. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  69. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
  70. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  71. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
  72. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  73. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  74. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  75. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  76. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  77. package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
  78. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  79. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
  80. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  81. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
  82. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
  83. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  85. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
  86. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  87. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
  88. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  89. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  90. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  91. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  92. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  93. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  94. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  95. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
  96. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  97. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  98. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  99. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  100. package/telegram-plugin/tests/model-command.test.ts +30 -0
  101. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
  102. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  103. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  104. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  105. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  106. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  107. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  108. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
  109. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  111. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  112. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  113. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  114. package/telegram-plugin/tests/render/render.test.ts +42 -0
  115. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  116. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  117. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  118. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
  119. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  120. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  121. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  122. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  123. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  124. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  125. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
  126. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  127. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  128. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  129. package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
  130. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
  131. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  132. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  133. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  134. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  135. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  136. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  137. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  138. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  139. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  140. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  141. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
  142. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  143. package/telegram-plugin/tool-activity-summary.ts +110 -38
  144. package/telegram-plugin/turn-flush-safety.ts +80 -14
  145. package/telegram-plugin/uat/restart-capability.ts +76 -0
  146. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  147. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  148. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  149. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  150. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  151. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  152. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  153. package/telegram-plugin/worker-activity-feed.ts +34 -4
  154. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  155. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  156. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  157. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  158. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  159. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -227,39 +227,94 @@ describe('PendingUserNoticeGate — notice fires IFF the turn dies reply-less',
227
227
  }
228
228
  }
229
229
 
230
+ // The single-turn norm: the error surfaced while topic A's turn was live, so
231
+ // the notice is keyed to A and topic A's turn end resolves it. `TOPIC_A` is a
232
+ // `statusKey(chatId, threadId)`-shaped value.
233
+ const TOPIC_A = '5000000002'
234
+ const TOPIC_B = '5000000009:42'
235
+
230
236
  it('DROPS the notice when the turn recovered (delivered a reply)', () => {
231
237
  const gate = new PendingUserNoticeGate()
232
- gate.schedule(makeNotice())
233
- expect(gate.resolveTurnEnd(true, 1_001_000)).toEqual([])
238
+ gate.schedule(makeNotice({ key: TOPIC_A }))
239
+ expect(gate.resolveTurnEnd(TOPIC_A, true, 1_001_000)).toEqual([])
234
240
  // And it does not resurrect on a later failed turn end.
235
- expect(gate.resolveTurnEnd(false, 1_002_000)).toEqual([])
241
+ expect(gate.resolveTurnEnd(TOPIC_A, false, 1_002_000)).toEqual([])
236
242
  })
237
243
 
238
244
  it('SENDS the notice exactly once when the turn ends without a reply', () => {
239
245
  const gate = new PendingUserNoticeGate()
240
- const notice = makeNotice()
241
- gate.schedule(notice)
242
- const flushed = gate.resolveTurnEnd(false, 1_001_000)
246
+ gate.schedule(makeNotice({ key: TOPIC_A }))
247
+ const flushed = gate.resolveTurnEnd(TOPIC_A, false, 1_001_000)
243
248
  expect(flushed).toHaveLength(1)
244
249
  expect(flushed[0].chatIds).toEqual(['5000000002', '5000000003'])
245
250
  // Exactly once — a second turn end flushes nothing.
246
- expect(gate.resolveTurnEnd(false, 1_002_000)).toEqual([])
251
+ expect(gate.resolveTurnEnd(TOPIC_A, false, 1_002_000)).toEqual([])
247
252
  })
248
253
 
249
- it('collapses an error burst within one turn to ONE pending notice per agent', () => {
254
+ it('collapses an error burst within one turn to ONE pending notice per topic', () => {
250
255
  const gate = new PendingUserNoticeGate()
251
- gate.schedule(makeNotice({ atMs: 1_000_000 }))
252
- gate.schedule(makeNotice({ atMs: 1_000_500 }))
253
- gate.schedule(makeNotice({ atMs: 1_001_000 }))
254
- expect(gate.resolveTurnEnd(false, 1_002_000)).toHaveLength(1)
256
+ gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
257
+ gate.schedule(makeNotice({ atMs: 1_000_500, key: TOPIC_A }))
258
+ gate.schedule(makeNotice({ atMs: 1_001_000, key: TOPIC_A }))
259
+ expect(gate.resolveTurnEnd(TOPIC_A, false, 1_002_000)).toHaveLength(1)
255
260
  })
256
261
 
257
262
  it('expires an unresolved notice after the TTL (bias to silence over false failure)', () => {
258
263
  const gate = new PendingUserNoticeGate()
259
- gate.schedule(makeNotice({ atMs: 1_000_000 }))
264
+ gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
260
265
  const after = 1_000_000 + PENDING_USER_NOTICE_TTL_MS + 1
261
266
  expect(gate.hasPending(after)).toBe(false)
262
- expect(gate.resolveTurnEnd(false, after)).toEqual([])
267
+ expect(gate.resolveTurnEnd(TOPIC_A, false, after)).toEqual([])
268
+ })
269
+
270
+ // ── #3294 — keyed by turn/topic so concurrent per-topic turns don't
271
+ // cross-resolve each other's pending notices. ─────────────────────────────
272
+ it("does NOT flush topic A's notice when a DIFFERENT topic's turn ends reply-less", () => {
273
+ const gate = new PendingUserNoticeGate()
274
+ gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
275
+ // Topic B's turn ends WITHOUT a reply. Pre-#3294 this flushed A's notice
276
+ // (a false "couldn't complete" for a turn that may still recover). Now B's
277
+ // end resolves nothing keyed to A.
278
+ expect(gate.resolveTurnEnd(TOPIC_B, false, 1_000_500)).toEqual([])
279
+ // A's notice is still pending, and A's OWN reply-less end flushes it.
280
+ expect(gate.hasPending(1_000_600)).toBe(true)
281
+ const flushed = gate.resolveTurnEnd(TOPIC_A, false, 1_000_700)
282
+ expect(flushed).toHaveLength(1)
283
+ expect(flushed[0].key).toBe(TOPIC_A)
284
+ })
285
+
286
+ it("does NOT drop topic A's notice when a DIFFERENT topic's turn recovers", () => {
287
+ const gate = new PendingUserNoticeGate()
288
+ gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
289
+ // Topic B's turn recovered (delivered a reply). It must not drop A's notice.
290
+ expect(gate.resolveTurnEnd(TOPIC_B, true, 1_000_500)).toEqual([])
291
+ // A's notice survives and still flushes on A's reply-less end.
292
+ expect(gate.resolveTurnEnd(TOPIC_A, false, 1_000_700)).toHaveLength(1)
293
+ })
294
+
295
+ it('keeps a per-topic pending notice for each concurrent topic (no cross-collapse)', () => {
296
+ const gate = new PendingUserNoticeGate()
297
+ gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A, chatIds: ['5000000002'] }))
298
+ gate.schedule(makeNotice({ atMs: 1_000_100, key: TOPIC_B, chatIds: ['5000000009'] }))
299
+ // A's end flushes only A.
300
+ const a = gate.resolveTurnEnd(TOPIC_A, false, 1_000_200)
301
+ expect(a).toHaveLength(1)
302
+ expect(a[0].chatIds).toEqual(['5000000002'])
303
+ // B is still pending and flushes on B's end.
304
+ const b = gate.resolveTurnEnd(TOPIC_B, false, 1_000_300)
305
+ expect(b).toHaveLength(1)
306
+ expect(b[0].chatIds).toEqual(['5000000009'])
307
+ })
308
+
309
+ it('an UNATTRIBUTED (undefined-key) notice resolves on any turn end (legacy single-turn behavior)', () => {
310
+ const gate = new PendingUserNoticeGate()
311
+ // No key — the error arrived with no live turn (between turns / post-poke).
312
+ gate.schedule(makeNotice({ atMs: 1_000_000 }))
313
+ // Any topic's reply-less end flushes it (preserves pre-#3294 behavior so a
314
+ // genuine death on a single-turn gateway still notifies the user).
315
+ const flushed = gate.resolveTurnEnd(TOPIC_B, false, 1_000_500)
316
+ expect(flushed).toHaveLength(1)
317
+ expect(flushed[0].key).toBeUndefined()
263
318
  })
264
319
  })
265
320
 
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Outcome pins for the media-envelope inbound handlers extracted from
3
+ * gateway.ts (switchroom#2996 P6 cluster A). Each handler is driven with a
4
+ * fake grammy ctx + mock dispatch deps; the assertions pin the EXACT text
5
+ * envelope forwarded to `handleInbound` (and the stderr log line, and the
6
+ * ack/refuse dispatch) — the behaviour the gateway previously produced inline.
7
+ */
8
+
9
+ import { describe, it, expect, vi } from 'vitest'
10
+ import type { Context } from 'grammy'
11
+ import {
12
+ safeName,
13
+ PASSPORT_REFUSAL_TEXT,
14
+ handleContactMessage,
15
+ handleLocationMessage,
16
+ handleVenueMessage,
17
+ handlePollMessage,
18
+ handleWebAppDataMessage,
19
+ handleUsersSharedMessage,
20
+ handleChatSharedMessage,
21
+ handleSuccessfulPaymentMessage,
22
+ handlePassportDataMessage,
23
+ handleDiceMessage,
24
+ handleGameMessage,
25
+ handleStoryMessage,
26
+ handlePaidMediaMessage,
27
+ type MediaEnvelopeDeps,
28
+ } from '../gateway/media-message-handlers.js'
29
+
30
+ function makeDeps() {
31
+ const handleInbound = vi.fn(async () => {})
32
+ const handleAckOnly = vi.fn(async () => {})
33
+ const handleRefusal = vi.fn(async () => {})
34
+ const logs: string[] = []
35
+ const deps: MediaEnvelopeDeps = {
36
+ handleInbound,
37
+ handleAckOnly,
38
+ handleRefusal,
39
+ log: line => logs.push(line),
40
+ }
41
+ return { deps, handleInbound, handleAckOnly, handleRefusal, logs }
42
+ }
43
+
44
+ // Build a fake ctx whose `message` carries the given content key + a chat id.
45
+ function ctxWith(content: Record<string, unknown>, chatId: number | null = 42): any {
46
+ return {
47
+ message: content,
48
+ chat: chatId == null ? undefined : { id: chatId },
49
+ }
50
+ }
51
+
52
+ describe('safeName', () => {
53
+ it('strips framing/injection characters', () => {
54
+ expect(safeName('a<b>[c]\r\nd;e')).toBe('a_b__c___d_e')
55
+ })
56
+ it('passes undefined through', () => {
57
+ expect(safeName(undefined)).toBeUndefined()
58
+ })
59
+ })
60
+
61
+ describe('handleContactMessage', () => {
62
+ it('renders the contact envelope and forwards it', async () => {
63
+ const { deps, handleInbound, logs } = makeDeps()
64
+ const ctx = ctxWith({
65
+ contact: { phone_number: '+15551234', first_name: 'Ada', last_name: 'Love', user_id: 7 },
66
+ })
67
+ await handleContactMessage(ctx, deps)
68
+ expect(handleInbound).toHaveBeenCalledWith(
69
+ ctx,
70
+ '(contact: name="Ada Love" phone="+15551234" user_id=7)',
71
+ undefined,
72
+ )
73
+ expect(logs[0]).toBe('telegram gateway: inbound contact from chat=42\n')
74
+ })
75
+
76
+ it('falls back to ? for missing fields and omits user_id', async () => {
77
+ const { deps, handleInbound } = makeDeps()
78
+ const ctx = ctxWith({ contact: {} })
79
+ await handleContactMessage(ctx, deps)
80
+ expect(handleInbound).toHaveBeenCalledWith(ctx, '(contact: name="?" phone="?")', undefined)
81
+ })
82
+
83
+ it('logs and swallows a builder error without forwarding', async () => {
84
+ const { deps, handleInbound, logs } = makeDeps()
85
+ // ctx.message is null → property access throws inside try.
86
+ const ctx = { message: null, chat: { id: 42 } } as unknown as Context
87
+ await handleContactMessage(ctx as any, deps)
88
+ expect(handleInbound).not.toHaveBeenCalled()
89
+ expect(logs.some(l => l.startsWith('telegram gateway: contact handler error:'))).toBe(true)
90
+ })
91
+ })
92
+
93
+ describe('handleLocationMessage', () => {
94
+ it('renders lat/lon at 6dp with live_period', async () => {
95
+ const { deps, handleInbound } = makeDeps()
96
+ const ctx = ctxWith({ location: { latitude: 12.3, longitude: -45.6, live_period: 60 } })
97
+ await handleLocationMessage(ctx, deps)
98
+ expect(handleInbound).toHaveBeenCalledWith(
99
+ ctx,
100
+ '(location: lat=12.300000 lon=-45.600000 live_period=60s)',
101
+ undefined,
102
+ )
103
+ })
104
+ it('omits live_period when absent and ? for non-numeric coords', async () => {
105
+ const { deps, handleInbound } = makeDeps()
106
+ const ctx = ctxWith({ location: {} })
107
+ await handleLocationMessage(ctx, deps)
108
+ expect(handleInbound).toHaveBeenCalledWith(ctx, '(location: lat=? lon=?)', undefined)
109
+ })
110
+ })
111
+
112
+ describe('handleVenueMessage', () => {
113
+ it('renders the venue envelope', async () => {
114
+ const { deps, handleInbound } = makeDeps()
115
+ const ctx = ctxWith({
116
+ venue: { title: 'Cafe', address: '1 St', location: { latitude: 1, longitude: 2 } },
117
+ })
118
+ await handleVenueMessage(ctx, deps)
119
+ expect(handleInbound).toHaveBeenCalledWith(
120
+ ctx,
121
+ '(venue: title="Cafe" address="1 St" lat=1.000000 lon=2.000000)',
122
+ undefined,
123
+ )
124
+ })
125
+ })
126
+
127
+ describe('handlePollMessage', () => {
128
+ it('renders question, count, anonymity and first-10 choices', async () => {
129
+ const { deps, handleInbound } = makeDeps()
130
+ const ctx = ctxWith({
131
+ poll: {
132
+ question: 'Pick',
133
+ is_anonymous: true,
134
+ options: [{ text: 'A' }, { text: 'B' }],
135
+ },
136
+ })
137
+ await handlePollMessage(ctx, deps)
138
+ expect(handleInbound).toHaveBeenCalledWith(
139
+ ctx,
140
+ '(poll: question="Pick" options=2 anonymous choices=[A | B])',
141
+ undefined,
142
+ )
143
+ })
144
+ })
145
+
146
+ describe('handleWebAppDataMessage', () => {
147
+ it('renders JSON-encoded data and logs byte count', async () => {
148
+ const { deps, handleInbound, logs } = makeDeps()
149
+ const ctx = ctxWith({ web_app_data: { data: 'hi', button_text: 'Go' } })
150
+ await handleWebAppDataMessage(ctx, deps)
151
+ expect(handleInbound).toHaveBeenCalledWith(
152
+ ctx,
153
+ '(web_app_data: button="Go" data="hi")',
154
+ undefined,
155
+ )
156
+ expect(logs[0]).toContain('button="Go" bytes=2')
157
+ })
158
+ it('truncates oversized data at 4096 chars', async () => {
159
+ const { deps, handleInbound } = makeDeps()
160
+ const big = 'x'.repeat(5000)
161
+ const ctx = ctxWith({ web_app_data: { data: big, button_text: 'B' } })
162
+ await handleWebAppDataMessage(ctx, deps)
163
+ const arg = (handleInbound as any).mock.calls[0][1] as string
164
+ expect(arg).toContain('…(truncated)')
165
+ expect(arg).toContain(JSON.stringify('x'.repeat(4096) + '…(truncated)'))
166
+ })
167
+ })
168
+
169
+ describe('handleUsersSharedMessage', () => {
170
+ it('renders shared user ids and count', async () => {
171
+ const { deps, handleInbound } = makeDeps()
172
+ const ctx = ctxWith({ users_shared: { request_id: 9, users: [{ user_id: 1 }, { user_id: 2 }] } })
173
+ await handleUsersSharedMessage(ctx, deps)
174
+ expect(handleInbound).toHaveBeenCalledWith(
175
+ ctx,
176
+ '(users_shared: request_id=9 user_ids=[1,2] count=2)',
177
+ undefined,
178
+ )
179
+ })
180
+ })
181
+
182
+ describe('handleChatSharedMessage', () => {
183
+ it('renders shared chat with optional title', async () => {
184
+ const { deps, handleInbound } = makeDeps()
185
+ const ctx = ctxWith({ chat_shared: { request_id: 3, chat_id: -100, title: 'Grp' } })
186
+ await handleChatSharedMessage(ctx, deps)
187
+ expect(handleInbound).toHaveBeenCalledWith(
188
+ ctx,
189
+ '(chat_shared: request_id=3 chat_id=-100 title="Grp")',
190
+ undefined,
191
+ )
192
+ })
193
+ it('omits title when absent', async () => {
194
+ const { deps, handleInbound } = makeDeps()
195
+ const ctx = ctxWith({ chat_shared: { request_id: 3, chat_id: -100 } })
196
+ await handleChatSharedMessage(ctx, deps)
197
+ expect(handleInbound).toHaveBeenCalledWith(
198
+ ctx,
199
+ '(chat_shared: request_id=3 chat_id=-100)',
200
+ undefined,
201
+ )
202
+ })
203
+ })
204
+
205
+ describe('handleSuccessfulPaymentMessage', () => {
206
+ it('logs structured fields and ack-only-warns, never forwarding', async () => {
207
+ const { deps, handleInbound, handleAckOnly, logs } = makeDeps()
208
+ const ctx = ctxWith({
209
+ successful_payment: {
210
+ currency: 'USD',
211
+ total_amount: 500,
212
+ invoice_payload: 'order-1',
213
+ provider_payment_charge_id: 'pc',
214
+ telegram_payment_charge_id: 'tc',
215
+ },
216
+ })
217
+ await handleSuccessfulPaymentMessage(ctx, deps)
218
+ expect(handleInbound).not.toHaveBeenCalled()
219
+ expect(handleAckOnly).toHaveBeenCalledWith(ctx, 'successful_payment', { warn: true })
220
+ expect(logs[0]).toContain('currency=USD total_amount=500')
221
+ expect(logs[0]).toContain('payload="order-1"')
222
+ })
223
+ })
224
+
225
+ describe('ack-only leaves (dice/game/story/paid_media)', () => {
226
+ it('handleDiceMessage logs and reacts 🎲, never forwarding', async () => {
227
+ const { deps, handleInbound, handleAckOnly, logs } = makeDeps()
228
+ const ctx = ctxWith({ dice: { emoji: '🎲', value: 4 } })
229
+ await handleDiceMessage(ctx, deps)
230
+ expect(handleInbound).not.toHaveBeenCalled()
231
+ expect(handleAckOnly).toHaveBeenCalledWith(ctx, 'dice', { emoji: '🎲' })
232
+ expect(logs[0]).toBe('telegram gateway: inbound dice from chat=42\n')
233
+ })
234
+
235
+ it('handleGameMessage logs and acks with no options', async () => {
236
+ const { deps, handleAckOnly, logs } = makeDeps()
237
+ const ctx = ctxWith({ game: {} })
238
+ await handleGameMessage(ctx, deps)
239
+ expect(handleAckOnly).toHaveBeenCalledWith(ctx, 'game')
240
+ expect(logs[0]).toBe('telegram gateway: inbound game from chat=42\n')
241
+ })
242
+
243
+ it('handleStoryMessage logs and acks with no options', async () => {
244
+ const { deps, handleAckOnly, logs } = makeDeps()
245
+ const ctx = ctxWith({ story: {} })
246
+ await handleStoryMessage(ctx, deps)
247
+ expect(handleAckOnly).toHaveBeenCalledWith(ctx, 'story')
248
+ expect(logs[0]).toBe('telegram gateway: inbound story from chat=42\n')
249
+ })
250
+
251
+ it('handlePaidMediaMessage logs and acks warn:true', async () => {
252
+ const { deps, handleInbound, handleAckOnly, logs } = makeDeps()
253
+ const ctx = ctxWith({ paid_media: {} })
254
+ await handlePaidMediaMessage(ctx, deps)
255
+ expect(handleInbound).not.toHaveBeenCalled()
256
+ expect(handleAckOnly).toHaveBeenCalledWith(ctx, 'paid_media', { warn: true })
257
+ expect(logs[0]).toBe('telegram gateway: inbound paid_media from chat=42\n')
258
+ })
259
+
260
+ it('falls back to ? for a missing chat id', async () => {
261
+ const { deps, logs } = makeDeps()
262
+ const ctx = ctxWith({ dice: {} }, null)
263
+ await handleDiceMessage(ctx, deps)
264
+ expect(logs[0]).toBe('telegram gateway: inbound dice from chat=?\n')
265
+ })
266
+ })
267
+
268
+ describe('handlePassportDataMessage', () => {
269
+ it('refuses with the fixed message and never forwards', async () => {
270
+ const { deps, handleInbound, handleRefusal } = makeDeps()
271
+ const ctx = ctxWith({ passport_data: {} })
272
+ await handlePassportDataMessage(ctx, deps)
273
+ expect(handleInbound).not.toHaveBeenCalled()
274
+ expect(handleRefusal).toHaveBeenCalledWith(ctx, 'passport_data', PASSPORT_REFUSAL_TEXT)
275
+ })
276
+ })
@@ -20,13 +20,17 @@ import { readFileSync } from 'node:fs'
20
20
  import { resolve } from 'node:path'
21
21
 
22
22
  // #2996 Phase 5: the callback-query handler families moved verbatim to
23
- // gateway/callback-query-handlers.ts; these pins read the gateway source
24
- // COMBINED with that module so the wiring assertions keep covering the
25
- // same runtime source text.
23
+ // gateway/callback-query-handlers.ts, and the executeMentalModelPropose tool
24
+ // handler to gateway/card-tool-handlers.ts (P5-tail); these pins read the
25
+ // gateway source COMBINED with those modules so the wiring assertions keep
26
+ // covering the same runtime source text. (The MENTAL_MODEL_* cap constant
27
+ // DEFINITIONS stay in gateway.ts and are injected.)
26
28
  const gatewaySrc =
27
29
  readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
28
30
  '\n' +
29
- readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
31
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8') +
32
+ '\n' +
33
+ readFileSync(resolve(__dirname, '..', 'gateway', 'card-tool-handlers.ts'), 'utf-8')
30
34
 
31
35
  function proposeCallbackBlock(): string {
32
36
  return (
@@ -444,6 +444,36 @@ describe("SR_MODEL_ALIASES / expandSrAlias", () => {
444
444
  it("every alias target is a sr-* id", () => {
445
445
  for (const target of Object.values(SR_MODEL_ALIASES)) expect(isSrModel(target)).toBe(true);
446
446
  });
447
+ it("2026-07-19 grok/kimi/gpt flagship buttons expand to their sr-* ids", () => {
448
+ expect(expandSrAlias("grok")).toBe("sr-grok-4.5");
449
+ expect(expandSrAlias("kimi")).toBe("sr-kimi-k3");
450
+ expect(expandSrAlias("gpt")).toBe("sr-gpt-5.6-sol");
451
+ });
452
+ it("per-tier GPT-5.6 buttons sol/terra/luna expand to their sr-* ids", () => {
453
+ expect(expandSrAlias("sol")).toBe("sr-gpt-5.6-sol");
454
+ expect(expandSrAlias("terra")).toBe("sr-gpt-5.6-terra");
455
+ expect(expandSrAlias("luna")).toBe("sr-gpt-5.6-luna");
456
+ for (const a of ["sol", "terra", "luna"]) expect(isOfflineTrustedModelToken(a)).toBe(true);
457
+ });
458
+ it("new flagship buttons are offline-trustable carriers (#3042 gate)", () => {
459
+ for (const a of ["grok", "kimi", "gpt"]) expect(isOfflineTrustedModelToken(a)).toBe(true);
460
+ for (const id of ["sr-grok-4.5", "sr-kimi-k3", "sr-gpt-5.6-sol"])
461
+ expect(isOfflineTrustedModelToken(id)).toBe(true);
462
+ });
463
+ it("labels every new grok/kimi/gpt-5.6 sr-* id (typeable tiers included)", () => {
464
+ for (const id of [
465
+ "sr-grok-4.5",
466
+ "sr-grok-4.3",
467
+ "sr-kimi-k3",
468
+ "sr-kimi-k2.7-code",
469
+ "sr-gpt-5.6-sol",
470
+ "sr-gpt-5.6-terra",
471
+ "sr-gpt-5.6-luna",
472
+ ]) {
473
+ expect(SR_MODEL_LABELS[id]).toBeTypeOf("string");
474
+ expect(isValidModelArg(id)).toBe(true);
475
+ }
476
+ });
447
477
  });
448
478
 
449
479
  describe("classifyDiscoveredOptions", () => {
@@ -12,10 +12,33 @@ import { describe, it, expect } from 'vitest'
12
12
  import { readFileSync } from 'node:fs'
13
13
  import { resolve } from 'node:path'
14
14
 
15
- const gatewaySrc = readFileSync(
16
- resolve(__dirname, '..', 'gateway', 'gateway.ts'),
15
+ // #2996 P2: executeReply's body moved VERBATIM to outbound-send-path.ts
16
+ // (`sendReply`); the reply-path routing assertions read the window there.
17
+ const sendPathSrc = readFileSync(
18
+ resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
17
19
  'utf-8',
18
20
  )
21
+ const sendReplyFn = sendPathSrc.split('export async function sendReply(')[1]?.split('\nexport ')[0] ?? ''
22
+ const gatewaySrc =
23
+ readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
24
+ '\n' +
25
+ // P7 PR-10 (#2996): the InboundMessage envelope assembly (origin_turn_id,
26
+ // topic_scope, meta lane) moved verbatim into gateway/inbound-router.ts
27
+ // (buildInboundEnvelope) — include it in the scraped corpus.
28
+ readFileSync(resolve(__dirname, '..', 'gateway', 'inbound-router.ts'), 'utf-8')
29
+ // #2996 P4-A: the enqueue handler (turn ctor: `const turnId = deriveTurnId`,
30
+ // `rememberRecentTurn(next)`, `promoteQueuedStatus`) moved VERBATIM into
31
+ // stream-render.ts with handleSessionEvent. Enqueue-seam assertions span both.
32
+ // #2996 P8 PR-B: the turn-end funnel bodies moved verbatim to turn-end.ts.
33
+ const turnEndSrc = readFileSync(
34
+ resolve(__dirname, '..', 'gateway', 'turn-end.ts'),
35
+ 'utf-8',
36
+ )
37
+ const streamSrc = readFileSync(
38
+ resolve(__dirname, '..', 'gateway', 'stream-render.ts'),
39
+ 'utf-8',
40
+ )
41
+ const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc
19
42
  const bridgeSrc = readFileSync(
20
43
  resolve(__dirname, '..', 'bridge', 'bridge.ts'),
21
44
  'utf-8',
@@ -31,12 +54,12 @@ const deriveTurnIdSrc = readFileSync(
31
54
  describe('component 3 — turn-origin reply routing', () => {
32
55
  it('CurrentTurn carries a turnId, and the enqueue handler initialises it', () => {
33
56
  expect(gatewaySrc).toMatch(/turnId: string/)
34
- expect(gatewaySrc).toMatch(/const turnId\s*=\s*\n?\s*deriveTurnId\(/)
35
- expect(gatewaySrc).toMatch(/rememberRecentTurn\(next\)/)
57
+ expect(gatewayAndStreamSrc).toMatch(/const turnId\s*=\s*\n?\s*deriveTurnId\(/)
58
+ expect(gatewayAndStreamSrc).toMatch(/rememberRecentTurn\(next\)/)
36
59
  })
37
60
 
38
61
  it('the inbound meta stamps origin_turn_id derived from chat/thread/messageId', () => {
39
- expect(gatewaySrc).toMatch(/const originTurnId = deriveTurnId\(chat_id, messageThreadId \?\? null, msgId\)/)
62
+ expect(gatewaySrc).toMatch(/const originTurnId = deriveTurnId\((?:p\.)?chat_id, (?:p\.)?messageThreadId \?\? null, (?:p\.)?msgId\)/)
40
63
  expect(gatewaySrc).toMatch(/origin_turn_id: originTurnId/)
41
64
  })
42
65
 
@@ -54,7 +77,7 @@ describe('component 3 — turn-origin reply routing', () => {
54
77
  })
55
78
 
56
79
  it('executeReply resolves the answer thread via the origin turn, not the live currentTurn', () => {
57
- const fn = gatewaySrc.split('async function executeReply')[1]?.split('\nasync function ')[0] ?? ''
80
+ const fn = sendReplyFn
58
81
  expect(fn).toMatch(/TURN_ORIGIN_ROUTING_ENABLED/)
59
82
  expect(fn).toMatch(/findTurnByOriginId\(args\.origin_turn_id/)
60
83
  // The resolution + reply-route telemetry go through resolveAnswerThreadWithLog,
@@ -85,8 +108,7 @@ describe('framework-owned origin recovery (determinism residual, 2026-06-05)', (
85
108
  })
86
109
 
87
110
  it('the reply path recovers origin from the quoted message_id when the model omits the echo', () => {
88
- for (const name of ['executeReply']) {
89
- const fn = gatewaySrc.split(new RegExp(`async function ${name}`))[1]?.split('\nasync function ')[0] ?? ''
111
+ for (const fn of [sendReplyFn]) {
90
112
  // Echo first (authoritative), quoted message_id as the framework fallback.
91
113
  expect(fn).toMatch(/const echoedTurn = findTurnByOriginId\(args\.origin_turn_id/)
92
114
  // Quoted lookup is CHAT-SCOPED (cross-chat message-id collision guard).
@@ -123,7 +145,9 @@ describe('component 4 — per-turn topic framing', () => {
123
145
  expect(gatewaySrc).toMatch(/TOPIC_FRAMING_ENABLED/)
124
146
  expect(gatewaySrc).toMatch(/topic_scope: topicScope/)
125
147
  // Only for topic inbounds — DMs get nothing.
126
- expect(gatewaySrc).toMatch(/TOPIC_FRAMING_ENABLED && messageThreadId != null/)
148
+ // P7 PR-10: the guard reads the injected constant + params inside
149
+ // buildInboundEnvelope (topicFramingEnabled / p.messageThreadId).
150
+ expect(gatewaySrc).toMatch(/(?:TOPIC_FRAMING_ENABLED|p\.topicFramingEnabled) && (?:p\.)?messageThreadId != null/)
127
151
  })
128
152
 
129
153
  it('the bridge instructions frame each channel message as the current topic', () => {
@@ -145,17 +169,17 @@ describe('component 5 — queued-status UX (delete-on-answer)', () => {
145
169
  })
146
170
 
147
171
  it('Hook B promotes the placeholder to "On it" when the buffered turn starts', () => {
148
- expect(gatewaySrc).toMatch(/promoteQueuedStatus\(ev\.chatId, enqThreadIdNum\)/)
172
+ expect(gatewayAndStreamSrc).toMatch(/promoteQueuedStatus\(ev\.chatId, enqThreadIdNum\)/)
149
173
  })
150
174
 
151
175
  it('Hook C reaps the placeholder on the answer (executeReply / stream)', () => {
152
- const reapCalls = gatewaySrc.match(/reapQueuedStatus\(/g) ?? []
176
+ const reapCalls = (gatewaySrc + turnEndSrc).match(/reapQueuedStatus\(/g) ?? []
153
177
  // definition + executeReply + purge cleanup (2 branches)
154
178
  expect(reapCalls.length).toBeGreaterThanOrEqual(4)
155
179
  })
156
180
 
157
181
  it('purgeReactionTracking reaps the placeholder on abnormal turn-end (defense-in-depth)', () => {
158
- const fn = gatewaySrc.split('function purgeReactionTracking')[1]?.split('\nfunction ')[0] ?? ''
182
+ const fn = turnEndSrc.split('function purgeReactionTracking')[1]?.split('\nfunction ')[0] ?? ''
159
183
  expect(fn).toMatch(/reapQueuedStatus\(/)
160
184
  // Reap sits alongside the activeReactionMsgIds cleanup.
161
185
  expect(fn).toMatch(/activeReactionMsgIds\.delete\(key\)/)
@@ -3,6 +3,7 @@ import {
3
3
  normalizeNarrative,
4
4
  prefixSimilarity,
5
5
  isDraftOfReply,
6
+ isReplyTool,
6
7
  DRAFT_SUPPRESS_THRESHOLD,
7
8
  REPLY_TOOLS,
8
9
  } from '../narrative-dedup.js'
@@ -18,6 +19,37 @@ describe('narrative-dedup', () => {
18
19
  expect(REPLY_TOOLS.has('Bash')).toBe(false)
19
20
  })
20
21
 
22
+ describe('isReplyTool (#3231 prefix-aware matching)', () => {
23
+ it('matches the PREFIXED prod wire shape (what real jsonl actually carries)', () => {
24
+ // The whole point of #3231: production jsonl carries mcp__…__telegram__<tool>,
25
+ // so a bare REPLY_TOOLS.has(name) was always false in prod → suppression inert.
26
+ expect(isReplyTool('mcp__switchroom-telegram__stream_reply')).toBe(true)
27
+ expect(isReplyTool('mcp__switchroom-telegram__reply')).toBe(true)
28
+ })
29
+
30
+ it('matches under ANY registration key (clerk-telegram, forks)', () => {
31
+ expect(isReplyTool('mcp__clerk-telegram__stream_reply')).toBe(true)
32
+ expect(isReplyTool('mcp__my-fork-telegram__reply')).toBe(true)
33
+ })
34
+
35
+ it('still matches BARE names (non-MCP sources)', () => {
36
+ expect(isReplyTool('reply')).toBe(true)
37
+ expect(isReplyTool('stream_reply')).toBe(true)
38
+ })
39
+
40
+ it('rejects non-reply tools, prefixed or bare', () => {
41
+ expect(isReplyTool('Bash')).toBe(false)
42
+ expect(isReplyTool('mcp__switchroom-telegram__react')).toBe(false)
43
+ expect(isReplyTool('mcp__switchroom-telegram__edit_message')).toBe(false)
44
+ expect(isReplyTool('mcp__hindsight__retain')).toBe(false)
45
+ })
46
+
47
+ it('is null/undefined safe', () => {
48
+ expect(isReplyTool(null)).toBe(false)
49
+ expect(isReplyTool(undefined)).toBe(false)
50
+ })
51
+ })
52
+
21
53
  describe('normalizeNarrative', () => {
22
54
  it('strips markdown emphasis/heading/quote marks, collapses whitespace, lowercases', () => {
23
55
  expect(normalizeNarrative('**Bold** _italic_ `code`')).toBe('bold italic code')
@@ -101,7 +101,10 @@ describe('anti-double-print (deferred path) — a draft-then-send reply is suppr
101
101
  const answer = 'The build is green — all 412 tests pass.'
102
102
  ctrl.stage(answer) // the model drafting its answer just before reply()
103
103
 
104
- ctrl.resolveOnTool('reply', { text: answer }) // draft-then-send → SUPPRESS
104
+ // #3231: feed the PREFIXED prod wire shape (mcp__…__reply). Before the
105
+ // isReplyTool fix a bare REPLY_TOOLS.has() missed this and the draft was
106
+ // WRONGLY shown — the suppression path was inert on the real wire shape.
107
+ ctrl.resolveOnTool('mcp__switchroom-telegram__reply', { text: answer }) // draft-then-send → SUPPRESS
105
108
  expect(show).not.toHaveBeenCalled()
106
109
  expect(retractShown).not.toHaveBeenCalled() // nothing was shown to retract
107
110
  expect(scheduler.isArmed).toBe(false)
@@ -128,7 +131,8 @@ describe('anti-double-print (TIMER path) — a timer-painted draft is retracted'
128
131
 
129
132
  // The reply finally lands and IS that block → must be retracted, not left
130
133
  // on the card to double-print against the canonical reply.
131
- ctrl.resolveOnTool('stream_reply', { text: answer })
134
+ // #3231: prefixed prod wire shape.
135
+ ctrl.resolveOnTool('mcp__switchroom-telegram__stream_reply', { text: answer })
132
136
  expect(retractShown).toHaveBeenCalledTimes(1)
133
137
  expect(retractShown).toHaveBeenCalledWith(answer)
134
138
  expect(show).toHaveBeenCalledTimes(1) // not re-shown