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
@@ -0,0 +1,571 @@
1
+ /**
2
+ * Golden-transcript harness for the extracted send-orchestration façade
3
+ * (#2996 P2, plan Amendments 9/10).
4
+ *
5
+ * ── Oracle standard (Amendment 10 — read this before "improving" it) ──────
6
+ * The coordinator-accepted oracle for P2 is the EXTRACTED-MODULE golden
7
+ * harness (the merged `outbound-send-chunks.test.ts` precedent, scaled to the
8
+ * full façade): `sendReply` / `deliverCapturedProse` are driven against a
9
+ * fake bot API recording `(method, chat_id, text, opts, reply_markup)` plus
10
+ * the REAL `OutboundDedupCache`. Pinning the literal IN-PLACE `executeReply`
11
+ * first was not possible: it is unexported and its `lockedBot` handle is
12
+ * assigned only inside the `isGatewayMain`-gated boot, so the in-place body
13
+ * cannot be invoked from any test runner even though gateway.ts now imports
14
+ * cleanly (P0e). Behavior preservation therefore rests on (a) the VERBATIM
15
+ * relocation (8 enumerated pin-vs-live spellings, see the module's section
16
+ * comment), (b) the pre-existing structural wiring tests re-pointed at the
17
+ * module, and (c) this behavioral harness.
18
+ *
19
+ * ── What is pinned here ───────────────────────────────────────────────────
20
+ * - multi-chunk send order + keyboard-on-last-chunk
21
+ * - oversize re-split (MESSAGE_TOO_LONG → hard-cap pieces, never dropped)
22
+ * - code fences survive the normalize pipeline to the wire
23
+ * - outbound secret redaction (REAL `redact()`) before the wire + dedup key
24
+ * - partial-failure contract ("reply failed after N of M chunk(s) sent")
25
+ * - voice: voice+text ordering; voice-only text suppression
26
+ * - photo route vs document route file sends
27
+ * - deliverCapturedProse silent-end recovery send + its dedup record
28
+ * - CROSS-SURFACE stream-then-reply dedup through the ONE shared
29
+ * OutboundDedupCache instance (Amendment 1) — with a negative control
30
+ * proving a second instance would reinstate the duplicate-reply class
31
+ * - structural: gateway constructs EXACTLY ONE OutboundDedupCache and the
32
+ * extracted module constructs NONE (the re-`new` hard-fail rule)
33
+ */
34
+ import { describe, it, expect } from 'vitest'
35
+ import { GrammyError } from 'grammy'
36
+ import { readFileSync, mkdtempSync, writeFileSync } from 'node:fs'
37
+ import { tmpdir } from 'node:os'
38
+ import { join } from 'node:path'
39
+ import {
40
+ sendReply,
41
+ deliverCapturedProse,
42
+ type SendReplyGatewayDeps,
43
+ type DeliverCapturedProseDeps,
44
+ type SendReplyRequest,
45
+ type VoiceOutPlan,
46
+ } from '../gateway/outbound-send-path.js'
47
+ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
48
+ import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
49
+ import { redact } from '../secret-detect/redact.js'
50
+ import type { CurrentTurn, Access } from '../gateway/gateway.js'
51
+
52
+ // ── fake bot API ──────────────────────────────────────────────────────────
53
+
54
+ interface RecordedCall {
55
+ method: string
56
+ chat_id: string
57
+ /** text for literal sends; the rich `{ markdown }` body's text for rich. */
58
+ text: string | null
59
+ opts: Record<string, unknown>
60
+ reply_markup: unknown
61
+ message_id?: number
62
+ }
63
+
64
+ function grammy400(description: string, method = 'sendRichMessage'): GrammyError {
65
+ return new GrammyError(
66
+ `Call to '${method}' failed! (400: ${description})`,
67
+ { ok: false, error_code: 400, description },
68
+ method,
69
+ {},
70
+ )
71
+ }
72
+
73
+ function makeFakeBot(failNext?: Partial<Record<string, unknown[]>>) {
74
+ const calls: RecordedCall[] = []
75
+ let nextId = 1000
76
+ const queues = failNext ?? {}
77
+ const maybeFail = (method: string): void => {
78
+ const q = queues[method]
79
+ if (q && q.length > 0) {
80
+ const e = q.shift()
81
+ if (e != null) throw e
82
+ }
83
+ }
84
+ const record = (
85
+ method: string,
86
+ chat_id: string,
87
+ text: string | null,
88
+ opts: Record<string, unknown>,
89
+ ): { message_id: number; message_thread_id?: number } => {
90
+ maybeFail(method)
91
+ const id = ++nextId
92
+ calls.push({ method, chat_id, text, opts, reply_markup: opts.reply_markup ?? null, message_id: id })
93
+ const tid = opts.message_thread_id
94
+ return { message_id: id, ...(tid != null ? { message_thread_id: Number(tid) } : {}) }
95
+ }
96
+ const api = {
97
+ sendRichMessage: async (chat: string, body: { markdown: string }, opts: Record<string, unknown> = {}) =>
98
+ record('sendRichMessage', chat, body.markdown, opts),
99
+ sendMessage: async (chat: string, text: string, opts: Record<string, unknown> = {}) =>
100
+ record('sendMessage', chat, text, opts),
101
+ editMessageText: async (chat: string, mid: number, body: unknown, opts: Record<string, unknown> = {}) => {
102
+ maybeFail('editMessageText')
103
+ calls.push({
104
+ method: 'editMessageText',
105
+ chat_id: chat,
106
+ text: typeof body === 'string' ? body : (body as { markdown: string }).markdown,
107
+ opts,
108
+ reply_markup: opts.reply_markup ?? null,
109
+ message_id: mid,
110
+ })
111
+ return {}
112
+ },
113
+ deleteMessage: async (chat: string, mid: number) => {
114
+ calls.push({ method: 'deleteMessage', chat_id: chat, text: null, opts: {}, reply_markup: null, message_id: mid })
115
+ return true
116
+ },
117
+ sendVoice: async (chat: string, _file: unknown, opts: Record<string, unknown> = {}) =>
118
+ record('sendVoice', chat, null, opts),
119
+ sendPhoto: async (chat: string, _file: unknown, opts: Record<string, unknown> = {}) =>
120
+ record('sendPhoto', chat, null, opts),
121
+ sendDocument: async (chat: string, _file: unknown, opts: Record<string, unknown> = {}) =>
122
+ record('sendDocument', chat, null, opts),
123
+ sendMediaGroup: async (chat: string, media: unknown[], opts: Record<string, unknown> = {}) => {
124
+ maybeFail('sendMediaGroup')
125
+ const out: Array<{ message_id: number }> = []
126
+ for (let i = 0; i < media.length; i++) {
127
+ const id = ++nextId
128
+ calls.push({ method: 'sendMediaGroup', chat_id: chat, text: null, opts, reply_markup: null, message_id: id })
129
+ out.push({ message_id: id })
130
+ }
131
+ return out
132
+ },
133
+ }
134
+ return { api, calls }
135
+ }
136
+
137
+ // ── harness deps ──────────────────────────────────────────────────────────
138
+
139
+ const CHAT = '1001'
140
+
141
+ function makeAccess(over: Partial<Record<string, unknown>> = {}): Access {
142
+ return {
143
+ allowFrom: [CHAT],
144
+ groups: {},
145
+ disableLinkPreview: true,
146
+ ...over,
147
+ } as unknown as Access
148
+ }
149
+
150
+ interface Harness {
151
+ deps: SendReplyGatewayDeps
152
+ calls: RecordedCall[]
153
+ dedup: OutboundDedupCache
154
+ effects: string[]
155
+ }
156
+
157
+ function makeHarness(opts?: {
158
+ access?: Access
159
+ dedup?: OutboundDedupCache
160
+ turnProvider?: () => CurrentTurn | null
161
+ failNext?: Partial<Record<string, unknown[]>>
162
+ voicePlan?: VoiceOutPlan | null
163
+ synth?: (plan: { ttsText: string }) => Promise<Uint8Array | null>
164
+ }): Harness {
165
+ const { api, calls } = makeFakeBot(opts?.failNext)
166
+ const dedup = opts?.dedup ?? new OutboundDedupCache()
167
+ const effects: string[] = []
168
+ const access = opts?.access ?? makeAccess()
169
+ const getCurrentTurn = opts?.turnProvider ?? (() => null)
170
+ const key = (c: string, t?: number | null) => `${c}:${t ?? 'main'}`
171
+ const deps: SendReplyGatewayDeps = {
172
+ outboundDedup: dedup,
173
+ flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
174
+ firstTextReplyLogged: new Set<string>(),
175
+ suppressPtyPreview: new Set<string>(),
176
+ activeDraftStreams: new Map(),
177
+ lastPtyPreviewByChat: new Map(),
178
+ voiceOnDemandCache: { put: () => {} } as unknown as SendReplyGatewayDeps['voiceOnDemandCache'],
179
+ voicePreSynthQueue: { enqueue: () => {} } as unknown as SendReplyGatewayDeps['voicePreSynthQueue'],
180
+ pendingProgress: {
181
+ clearPending: (k, r) => effects.push(`pendingProgress.clearPending:${k}:${r}`),
182
+ noteOutbound: (k) => effects.push(`pendingProgress.noteOutbound:${k}`),
183
+ },
184
+ signalTracker: {
185
+ noteOutbound: (k) => effects.push(`signalTracker.noteOutbound:${k}`),
186
+ noteSignal: (k) => effects.push(`signalTracker.noteSignal:${k}`),
187
+ },
188
+ silencePoke: { noteOutbound: (k) => effects.push(`silencePoke.noteOutbound:${k}`) },
189
+ getCurrentTurn,
190
+ getLastActiveTurnChatId: () => undefined,
191
+ HISTORY_ENABLED: false,
192
+ TURN_ORIGIN_ROUTING_ENABLED: true,
193
+ AUTOCLASSIFY_MIDTURN_SHADOW: false,
194
+ MAX_ATTACHMENT_BYTES: 50 * 1024 * 1024,
195
+ MAX_CHUNK_LIMIT: 4096,
196
+ PHOTO_EXTS: new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']),
197
+ lockedBot: { api } as unknown as SendReplyGatewayDeps['lockedBot'],
198
+ robustApiCall: (fn) => fn(),
199
+ swallowingApiCall: async (fn) => {
200
+ try {
201
+ return await fn()
202
+ } catch {
203
+ return undefined
204
+ }
205
+ },
206
+ loadAccess: () => access,
207
+ redactOutboundText: (text) => redact(text),
208
+ assertAllowedChat: () => {},
209
+ assertSendable: () => {},
210
+ statusKey: key,
211
+ streamKey: key,
212
+ resolveReplyOwnerTurn: () => null,
213
+ findTurnByOriginId: () => null,
214
+ findTurnByQuotedMessageId: () => null,
215
+ resolveAnswerThreadWithLog: (_c, explicit) => explicit,
216
+ resolveThreadId: (_c, explicit) => (explicit != null ? Number(explicit) : undefined),
217
+ getLatestInboundMessageId: () => null,
218
+ recordOutbound: () => effects.push('recordOutbound'),
219
+ emissionAuthorityFor: () =>
220
+ ({
221
+ claimOrDowngradePing: (
222
+ _input: unknown,
223
+ _state: unknown,
224
+ _apply: unknown,
225
+ disabled: () => void,
226
+ ) => disabled(),
227
+ markSubstantiveFinalDelivered: (fn: () => void) => fn(),
228
+ finalizeCard: (fn: () => void) => fn(),
229
+ }) as unknown as ReturnType<SendReplyGatewayDeps['emissionAuthorityFor']>,
230
+ clearActivitySummary: () => effects.push('clearActivitySummary'),
231
+ startTypingLoop: (c, t) => effects.push(`startTypingLoop:${key(c, t)}`),
232
+ stopTypingLoop: (c, t) => effects.push(`stopTypingLoop:${key(c, t)}`),
233
+ logOutbound: () => {},
234
+ closeObligationOnSubstantiveReply: () => effects.push('closeObligation'),
235
+ finalizeStatusReaction: (c, t, o) => effects.push(`finalizeStatusReaction:${key(c, t)}:${o}`),
236
+ releaseTurnBufferGate: (k) => effects.push(`releaseTurnBufferGate:${k}`),
237
+ reapQueuedStatus: () => effects.push('reapQueuedStatus'),
238
+ noteAgentOutputAt: () => {},
239
+ rememberAgentButtonMeta: () => effects.push('rememberAgentButtonMeta'),
240
+ resolveVoiceOutPlan: () => opts?.voicePlan ?? null,
241
+ synthesizeVoiceOut: opts?.synth ?? (async () => null),
242
+ publishToTelegraph: async () => null,
243
+ clearSilentEndState: (k) => effects.push(`clearSilentEndState:${k}`),
244
+ emitRuntimeMetric: () => {},
245
+ shadowEmit: () => {},
246
+ progressDriver: null,
247
+ }
248
+ return { deps, calls, dedup, effects }
249
+ }
250
+
251
+ function req(text: string, extra: Record<string, unknown> = {}, turn: CurrentTurn | null = null): SendReplyRequest {
252
+ return { args: { chat_id: CHAT, text, ...extra }, turn }
253
+ }
254
+
255
+ // ── the cases ─────────────────────────────────────────────────────────────
256
+
257
+ describe('sendReply golden harness — text pipeline to the wire', () => {
258
+ it('single-chunk happy path: one rich send, normalized text on the wire', async () => {
259
+ const h = makeHarness()
260
+ const res = await sendReply(h.deps, req('Hello there. This is the answer.'))
261
+ const sends = h.calls.filter((c) => c.method === 'sendRichMessage')
262
+ expect(sends).toHaveLength(1)
263
+ expect(sends[0]!.chat_id).toBe(CHAT)
264
+ expect(sends[0]!.text).toContain('Hello there. This is the answer.')
265
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
266
+ // typing loop started + stopped around the send
267
+ expect(h.effects).toContain(`startTypingLoop:${CHAT}:main`)
268
+ expect(h.effects).toContain(`stopTypingLoop:${CHAT}:main`)
269
+ })
270
+
271
+ it('multi-chunk: ordered sends; keyboard attaches to the LAST chunk only', async () => {
272
+ const para = (n: number) => `Paragraph ${n} ${'x'.repeat(60)}.`
273
+ const text = [para(1), para(2), para(3)].join('\n\n')
274
+ const h = makeHarness({ access: makeAccess({ textChunkLimit: 80 }) })
275
+ const keyboard = [[{ text: 'Open', url: 'https://example.com' }]]
276
+ const res = await sendReply(h.deps, req(text, { inline_keyboard: keyboard }))
277
+ const sends = h.calls.filter((c) => c.method === 'sendRichMessage')
278
+ expect(sends.length).toBeGreaterThanOrEqual(2)
279
+ // order: chunk i is sent before chunk i+1 (message ids ascend with order)
280
+ for (let i = 1; i < sends.length; i++) {
281
+ expect(sends[i]!.message_id!).toBeGreaterThan(sends[i - 1]!.message_id!)
282
+ }
283
+ // keyboard only on the last chunk
284
+ for (let i = 0; i < sends.length - 1; i++) expect(sends[i]!.reply_markup).toBeNull()
285
+ expect(sends[sends.length - 1]!.reply_markup).toMatchObject({
286
+ inline_keyboard: [[{ text: 'Open', url: 'https://example.com' }]],
287
+ })
288
+ expect(res.content[0]!.text).toMatch(new RegExp(`^sent ${sends.length} parts`))
289
+ })
290
+
291
+ it('code fences survive the normalize pipeline to the wire intact', async () => {
292
+ const fence = '```ts\nconst x = a - b // em-dash-free zone\n```'
293
+ const h = makeHarness()
294
+ await sendReply(h.deps, req(`Here is the code:\n\n${fence}`))
295
+ const sends = h.calls.filter((c) => c.method === 'sendRichMessage')
296
+ expect(sends).toHaveLength(1)
297
+ expect(sends[0]!.text).toContain('```ts\nconst x = a - b')
298
+ })
299
+
300
+ it('secrets are redacted (REAL redact()) before the wire', async () => {
301
+ // Runtime-concatenated so push-protection never sees a token-shaped literal.
302
+ const token = 'sk-ant-' + 'api03-' + 'A'.repeat(93) + 'AA'
303
+ const h = makeHarness()
304
+ await sendReply(h.deps, req(`Your key is ${token} - keep it safe.`))
305
+ const sends = h.calls.filter((c) => c.method === 'sendRichMessage')
306
+ expect(sends).toHaveLength(1)
307
+ expect(sends[0]!.text).not.toContain(token)
308
+ // Mask shape is `[REDACTED:<detector>]` (underscores may be markdown-escaped
309
+ // downstream of the scrub — the token bytes are what must never survive).
310
+ expect(sends[0]!.text).toMatch(/\[REDACTED/)
311
+ })
312
+
313
+ it('oversize chunk: MESSAGE_TOO_LONG re-splits to hard-cap pieces instead of dropping', async () => {
314
+ const h = makeHarness({
315
+ failNext: { sendRichMessage: [grammy400('MESSAGE_TOO_LONG: message is too long')] },
316
+ })
317
+ const res = await sendReply(h.deps, req('word '.repeat(40).trim()))
318
+ // first rich attempt failed; the re-split path delivered >= 1 piece
319
+ const sends = h.calls.filter((c) => c.method === 'sendRichMessage')
320
+ expect(sends.length).toBeGreaterThanOrEqual(1)
321
+ expect(res.content[0]!.text).toMatch(/^sent/)
322
+ })
323
+
324
+ it('partial failure: unrecoverable error surfaces the N-of-M contract', async () => {
325
+ const para = (n: number) => `Paragraph ${n} ${'y'.repeat(60)}.`
326
+ const text = [para(1), para(2), para(3)].join('\n\n')
327
+ const h = makeHarness({
328
+ access: makeAccess({ textChunkLimit: 80 }),
329
+ failNext: { sendRichMessage: [null, new Error('boom: network down')] },
330
+ })
331
+ await expect(sendReply(h.deps, req(text))).rejects.toThrow(
332
+ /reply failed after 1 of \d+ chunk\(s\) sent: boom: network down/,
333
+ )
334
+ // the typing loop is still stopped by the finally
335
+ expect(h.effects.filter((e) => e.startsWith('stopTypingLoop'))).toHaveLength(1)
336
+ })
337
+ })
338
+
339
+ describe('sendReply golden harness — voice', () => {
340
+ const plan: VoiceOutPlan = {
341
+ engine: 'kokoro',
342
+ speed: 1.1,
343
+ replyMode: 'voice+text',
344
+ ttsChunks: ['Spoken answer.'],
345
+ }
346
+
347
+ it('voice+text: text chunks first, then the voice note', async () => {
348
+ const h = makeHarness({
349
+ voicePlan: plan,
350
+ synth: async () => new Uint8Array([1, 2, 3]),
351
+ })
352
+ await sendReply(h.deps, req('The spoken answer, also as text.'))
353
+ const methods = h.calls.map((c) => c.method)
354
+ expect(methods).toContain('sendRichMessage')
355
+ expect(methods).toContain('sendVoice')
356
+ expect(methods.indexOf('sendVoice')).toBeGreaterThan(methods.indexOf('sendRichMessage'))
357
+ })
358
+
359
+ it('voice-only with full synthesis: text body suppressed, voice note IS the reply', async () => {
360
+ const h = makeHarness({
361
+ voicePlan: { ...plan, replyMode: 'voice-only' },
362
+ synth: async () => new Uint8Array([1, 2, 3]),
363
+ })
364
+ const res = await sendReply(h.deps, req('Only spoken.'))
365
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
366
+ expect(h.calls.filter((c) => c.method === 'sendVoice')).toHaveLength(1)
367
+ expect(res.content[0]!.text).toMatch(/^sent/)
368
+ })
369
+
370
+ it('voice-only with FAILED synthesis: text path still delivers (never drop the answer)', async () => {
371
+ const h = makeHarness({
372
+ voicePlan: { ...plan, replyMode: 'voice-only' },
373
+ synth: async () => null,
374
+ })
375
+ await sendReply(h.deps, req('Fell back to text.'))
376
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
377
+ expect(h.calls.filter((c) => c.method === 'sendVoice')).toHaveLength(0)
378
+ })
379
+ })
380
+
381
+ describe('sendReply golden harness — file sends', () => {
382
+ const dir = mkdtempSync(join(tmpdir(), 'p2-golden-'))
383
+
384
+ it('a photo-extension file routes via sendPhoto; a document via sendDocument', async () => {
385
+ const png = join(dir, 'shot.png')
386
+ const txt = join(dir, 'notes.txt')
387
+ // Garbage PNG bytes: the photo PRE-check probe fails open (keeps the
388
+ // photo route), which is exactly the #3033 contract we want pinned.
389
+ writeFileSync(png, Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]))
390
+ writeFileSync(txt, 'hello')
391
+ const h = makeHarness()
392
+ await sendReply(h.deps, req('files attached', { files: [png] }))
393
+ expect(h.calls.filter((c) => c.method === 'sendPhoto')).toHaveLength(1)
394
+
395
+ const h2 = makeHarness()
396
+ await sendReply(h2.deps, req('doc attached', { files: [txt] }))
397
+ expect(h2.calls.filter((c) => c.method === 'sendDocument')).toHaveLength(1)
398
+ })
399
+
400
+ it('2-10 photos batch into ONE sendMediaGroup album', async () => {
401
+ const a = join(dir, 'a.png')
402
+ const b = join(dir, 'b.png')
403
+ writeFileSync(a, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
404
+ writeFileSync(b, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
405
+ const h = makeHarness()
406
+ await sendReply(h.deps, req('album', { files: [a, b] }))
407
+ expect(h.calls.filter((c) => c.method === 'sendMediaGroup')).toHaveLength(2) // one record per album item
408
+ expect(h.calls.filter((c) => c.method === 'sendPhoto')).toHaveLength(0)
409
+ })
410
+ })
411
+
412
+ describe('cross-surface dedup — the ONE OutboundDedupCache instance (Amendment 1/9)', () => {
413
+ it('a stream-surface record suppresses the same-content reply (zero wire calls)', async () => {
414
+ const dedup = new OutboundDedupCache()
415
+ const text = 'The final answer, already streamed.'
416
+ // Simulate the handleSessionEvent answer-stream record site (P4 surface):
417
+ // it records into the SAME cache instance the reply path checks.
418
+ dedup.record(CHAT, undefined, text, Date.now(), null)
419
+ const h = makeHarness({ dedup })
420
+ const res = await sendReply(h.deps, req(text))
421
+ expect(res.content[0]!.text).toBe('sent (deduped — same content sent via earlier path)')
422
+ expect(h.calls).toHaveLength(0)
423
+ })
424
+
425
+ it('NEGATIVE CONTROL: a second cache instance reinstates the duplicate send', async () => {
426
+ const streamSideCache = new OutboundDedupCache()
427
+ const text = 'The final answer, already streamed.'
428
+ streamSideCache.record(CHAT, undefined, text, Date.now(), null)
429
+ // The reply path holding a DIFFERENT instance (the forbidden re-new)
430
+ // does NOT see the record — the duplicate ships. This is why the
431
+ // injected singleton contract is load-bearing.
432
+ const h = makeHarness({ dedup: new OutboundDedupCache() })
433
+ await sendReply(h.deps, req(text))
434
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
435
+ })
436
+
437
+ it('a delivered reply records into the shared cache (later flush/stream is suppressed)', async () => {
438
+ const dedup = new OutboundDedupCache()
439
+ const h = makeHarness({ dedup })
440
+ await sendReply(h.deps, req('Recorded for dedup across surfaces.'))
441
+ const hit = dedup.check(CHAT, undefined, 'Recorded for dedup across surfaces.', Date.now(), null)
442
+ expect(hit).not.toBeNull()
443
+ })
444
+ })
445
+
446
+ describe('deliverCapturedProse — silent-end recovery (routed into the send module)', () => {
447
+ function makeProseDeps(dedup: OutboundDedupCache) {
448
+ const { api, calls } = makeFakeBot()
449
+ const effects: string[] = []
450
+ const deps: DeliverCapturedProseDeps = {
451
+ outboundDedup: dedup,
452
+ bot: { api } as unknown as DeliverCapturedProseDeps['bot'],
453
+ robustApiCall: (fn) => fn(),
454
+ redactOutboundText: (text) => redact(text),
455
+ recordOutbound: () => effects.push('recordOutbound'),
456
+ HISTORY_ENABLED: false,
457
+ OBLIGATION_LEDGER_ENABLED: true,
458
+ obligationLedger: { close: (id) => effects.push(`obligation.close:${id}`) },
459
+ clearSilentEndState: (k) => effects.push(`clearSilentEndState:${k}`),
460
+ recordUndeliveredTurnEnd: () => {
461
+ effects.push('recordUndeliveredTurnEnd')
462
+ return { exhausted: false }
463
+ },
464
+ hasOutboundDeliveredSince: () => false,
465
+ }
466
+ return { deps, calls, effects }
467
+ }
468
+
469
+ it('recovers the captured answer: rich send + dedup record + obligation close + state clear', async () => {
470
+ const dedup = new OutboundDedupCache()
471
+ const { deps, calls, effects } = makeProseDeps(dedup)
472
+ await deliverCapturedProse(deps, {
473
+ chatId: CHAT,
474
+ threadId: undefined,
475
+ statusKeyStr: `${CHAT}:main`,
476
+ registryKey: null,
477
+ originTurnId: 'turn-42',
478
+ text: 'The recovered terminal answer.',
479
+ })
480
+ const sends = calls.filter((c) => c.method === 'sendRichMessage')
481
+ expect(sends).toHaveLength(1)
482
+ expect(sends[0]!.text).toContain('The recovered terminal answer.')
483
+ expect(effects).toContain('obligation.close:turn-42')
484
+ expect(effects).toContain(`clearSilentEndState:${CHAT}:main`)
485
+ // recorded into the SHARED cache
486
+ expect(dedup.check(CHAT, undefined, 'The recovered terminal answer.', Date.now(), null)).not.toBeNull()
487
+ })
488
+
489
+ it('CROSS-PATH: a captured-prose delivery suppresses the model\'s late reply retry', async () => {
490
+ const dedup = new OutboundDedupCache()
491
+ const { deps } = makeProseDeps(dedup)
492
+ const text = 'Answer delivered via silent-end recovery.'
493
+ await deliverCapturedProse(deps, {
494
+ chatId: CHAT,
495
+ threadId: undefined,
496
+ statusKeyStr: `${CHAT}:main`,
497
+ registryKey: null,
498
+ originTurnId: 'turn-43',
499
+ text,
500
+ })
501
+ // The model's late `reply` with the same content hits the SAME instance.
502
+ const h = makeHarness({ dedup })
503
+ const res = await sendReply(h.deps, req(text))
504
+ expect(res.content[0]!.text).toBe('sent (deduped — same content sent via earlier path)')
505
+ expect(h.calls).toHaveLength(0)
506
+ })
507
+
508
+ it('already-deduped content settles bookkeeping WITHOUT a wire send', async () => {
509
+ const dedup = new OutboundDedupCache()
510
+ const text = 'Already went out earlier via the stream.'
511
+ dedup.record(CHAT, undefined, text, Date.now(), null)
512
+ const { deps, calls, effects } = makeProseDeps(dedup)
513
+ await deliverCapturedProse(deps, {
514
+ chatId: CHAT,
515
+ threadId: undefined,
516
+ statusKeyStr: `${CHAT}:main`,
517
+ registryKey: null,
518
+ originTurnId: 'turn-44',
519
+ text,
520
+ })
521
+ expect(calls).toHaveLength(0)
522
+ expect(effects).toContain('obligation.close:turn-44')
523
+ })
524
+ })
525
+
526
+ describe('structural wiring — the singleton + the gateway entry point (#2996 P2)', () => {
527
+ const gatewaySrc = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
528
+ const moduleSrc = readFileSync(new URL('../gateway/outbound-send-path.ts', import.meta.url), 'utf8')
529
+
530
+ it('gateway constructs EXACTLY ONE OutboundDedupCache; the module constructs NONE', () => {
531
+ // Amendment 1/9 hard-fail rule: a re-`new` in an extracted module
532
+ // reinstates the duplicate-reply class. The one instance lives in
533
+ // gateway.ts and is INJECTED everywhere else.
534
+ expect(gatewaySrc.match(/new OutboundDedupCache\(/g) ?? []).toHaveLength(1)
535
+ expect(moduleSrc.match(/new OutboundDedupCache\(/g) ?? []).toHaveLength(0)
536
+ })
537
+
538
+ it('executeReply is a thin wrapper: pins the turn and delegates to sendReply', () => {
539
+ const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
540
+ const body = after.split('\nasync function ')[0] ?? after
541
+ expect(body).toContain('return sendReply(gatewaySendReplyDeps(), { args, turn: currentTurn })')
542
+ })
543
+
544
+ it('the injected deps carry the singleton + BOTH turn accessors (Amendment 9)', () => {
545
+ const after = gatewaySrc.split('function gatewaySendReplyDeps(')[1] ?? ''
546
+ const body = after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
547
+ expect(body).toMatch(/\n\s{4}outboundDedup,/) // the ONE instance, shorthand
548
+ expect(body).toContain('getCurrentTurn: () => currentTurn')
549
+ expect(body).toContain('getLastActiveTurnChatId: () => lastActiveTurnChatId')
550
+ // The dead deps stay dead (Amendment 9): no ledger / send gate injected.
551
+ expect(body).not.toMatch(/backstopDeliveryLedger/)
552
+ expect(body).not.toMatch(/\bsendGate\b/)
553
+ })
554
+
555
+ it('deliverCapturedProse is a thin wrapper delegating to the module with the singleton', () => {
556
+ const after = gatewaySrc.split('async function deliverCapturedProse(')[1] ?? ''
557
+ const body = after.split('\nasync function ')[0]?.split('\n/** Live gateway deps')[0] ?? after
558
+ expect(body).toContain('return deliverCapturedProseCore(gatewayCapturedProseDeps(), args)')
559
+ const depsAfter = gatewaySrc.split('function gatewayCapturedProseDeps(')[1] ?? ''
560
+ const depsBody = depsAfter.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? depsAfter
561
+ expect(depsBody).toMatch(/\n\s{4}outboundDedup,/)
562
+ })
563
+
564
+ it('the module keeps the pin-vs-live contract: entry pin from req, live reads via getCurrentTurn()', () => {
565
+ const win = moduleSrc.split('export async function sendReply(')[1]?.split('\nexport ')[0] ?? ''
566
+ expect(win).toContain('const turn = req.turn')
567
+ // The load-bearing live re-reads survived the move (#1664 class):
568
+ expect(win).toContain('const finalizeTurn = getCurrentTurn()')
569
+ expect(win.match(/getCurrentTurn\(\)/g)!.length).toBeGreaterThanOrEqual(5)
570
+ })
571
+ })
@@ -24,6 +24,15 @@ import { readFileSync } from 'node:fs'
24
24
  import { resolve } from 'node:path'
25
25
 
26
26
  const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
27
+ // #2996 P4-A: the model-driven reset sites (tool_label append, answer-stream
28
+ // onMetric) live inside handleSessionEvent, which moved VERBATIM to
29
+ // stream-render.ts. There the live-turn guard reads the injected accessor
30
+ // (`getCurrentTurn() === turn`) rather than the `currentTurn` module global.
31
+ const streamSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'stream-render.ts'), 'utf-8')
32
+ // #2996 P4-B: drainActivitySummary / feedHeartbeatTick moved VERBATIM into
33
+ // narrative-lane.ts (factory scope, bodies indented +2; gateway keeps thin
34
+ // same-name wrappers) — the heartbeat-safety windows read the module source.
35
+ const laneSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'narrative-lane.ts'), 'utf-8')
27
36
 
28
37
  function between(src: string, startMarker: string, endMarker: string): string {
29
38
  const after = src.split(startMarker)[1] ?? ''
@@ -32,13 +41,13 @@ function between(src: string, startMarker: string, endMarker: string): string {
32
41
 
33
42
  describe('silence-poke production-liveness — heartbeat safety', () => {
34
43
  it('drainActivitySummary must NOT reset the silence clock (the framework heartbeat drains here)', () => {
35
- const body = between(gatewaySrc, 'async function drainActivitySummary', '\nfunction feedHeartbeatTick')
44
+ const body = between(laneSrc, 'async function drainActivitySummary', '\n function feedHeartbeatTick')
36
45
  expect(body.length).toBeGreaterThan(100) // sanity: the slice found the function body
37
46
  expect(body).not.toMatch(/noteProduction/)
38
47
  })
39
48
 
40
49
  it('feedHeartbeatTick itself must NOT reset the silence clock (model-independent re-render)', () => {
41
- const body = between(gatewaySrc, 'function feedHeartbeatTick(): void {', '\n}')
50
+ const body = between(laneSrc, 'function feedHeartbeatTick(): void {', '\n }')
42
51
  expect(body.length).toBeGreaterThan(50)
43
52
  expect(body).not.toMatch(/noteProduction/)
44
53
  })
@@ -47,18 +56,18 @@ describe('silence-poke production-liveness — heartbeat safety', () => {
47
56
  // appendActivityLabel returns a fresh render only when the model emits a NEW
48
57
  // labelled step — the genuine liveness signal the heartbeat can never forge.
49
58
  const block = between(
50
- gatewaySrc,
59
+ streamSrc,
51
60
  'const rendered = appendActivityLabel(turn.mirrorLines, ev.label)',
52
61
  '\n return',
53
62
  )
54
63
  expect(block).toMatch(/silencePoke\.noteProduction/)
55
- expect(block).toMatch(/currentTurn === turn/)
64
+ expect(block).toMatch(/getCurrentTurn\(\) === turn/)
56
65
  })
57
66
 
58
67
  it('the answer-stream draft onMetric reset is model-driven and gated on the live turn', () => {
59
- const block = between(gatewaySrc, 'onMetric: (metricEv) => {', '\n },')
68
+ const block = between(streamSrc, 'onMetric: (metricEv) => {', '\n },')
60
69
  expect(block).toMatch(/silencePoke\.noteProduction/)
61
- expect(block).toMatch(/currentTurn === turn/)
70
+ expect(block).toMatch(/getCurrentTurn\(\) === turn/)
62
71
  })
63
72
 
64
73
  it('production-liveness is behind the default-ON SWITCHROOM_SILENCE_LIVENESS_PRODUCTION kill switch', () => {
@@ -74,12 +83,17 @@ describe('silence-poke production-liveness — heartbeat safety', () => {
74
83
  // Structural guard so a refactor can't silently re-silence the re-ping
75
84
  // (the gateway IIFE can't be instantiated in-process — same pattern as the
76
85
  // heartbeat-safety assertions above).
77
- const block = between(gatewaySrc, 'onFrameworkFallback: async (ctx) => {', '\nfunction trackRedeliveredInbound')
86
+ // #2996 P8 PR-C3: the fallback body moved verbatim to liveness-wiring.ts;
87
+ // the send itself is the injected `sendSilenceText(chat, thread, text,
88
+ // silent)` closure (gateway keeps the bot-api touch), so the gate now
89
+ // rides the closure's `silent` argument at the callsite.
90
+ const livenessSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'liveness-wiring.ts'), 'utf-8')
91
+ const block = between(livenessSrc, 'onFrameworkFallback: async (ctx) => {', '\n }\n}')
78
92
  expect(block.length).toBeGreaterThan(100) // sanity: slice found the handler body
79
93
  // The signal is derived once, hoisted above the update-status branch so it's
80
94
  // in scope at the send site.
81
95
  expect(block).toMatch(/const blockedOnApproval = activeStatusReactions/)
82
96
  // The send gates on it rather than hard-coding silent.
83
- expect(block).toMatch(/disable_notification: blockedOnApproval \? false : true/)
97
+ expect(block).toMatch(/sendSilenceText\(ctx\.chatId, ctx\.threadId \?\? null, text, blockedOnApproval \? false : true\)/)
84
98
  })
85
99
  })