switchroom 0.18.30 → 0.18.32

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 (67) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +4 -2
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +708 -255
  5. package/dist/host-control/main.js +5 -3
  6. package/dist/vault/approvals/kernel-server.js +4 -2
  7. package/dist/vault/broker/server.js +4 -2
  8. package/package.json +3 -2
  9. package/profiles/_base/start.sh.hbs +142 -7
  10. package/telegram-plugin/dist/gateway/gateway.js +25870 -25007
  11. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  12. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  13. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  14. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  15. package/telegram-plugin/gateway/gateway.ts +5479 -7069
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  17. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  18. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  19. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  20. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  21. package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
  22. package/telegram-plugin/pending-user-notice.ts +59 -13
  23. package/telegram-plugin/subagent-watcher.ts +111 -28
  24. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  25. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  26. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
  27. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
  28. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  29. package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
  30. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  31. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
  32. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
  33. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  34. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  35. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
  36. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
  37. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  38. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  39. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  40. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  41. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
  42. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  43. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
  44. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  45. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  46. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
  47. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  48. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  49. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  50. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
  51. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  52. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
  53. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  54. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  55. package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
  56. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
  57. package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
  58. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  59. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
  60. package/telegram-plugin/tool-activity-summary.ts +27 -3
  61. package/telegram-plugin/worker-activity-feed.ts +24 -0
  62. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  63. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  64. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  65. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  66. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  67. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -101,14 +101,17 @@ describe('inbound gate holds while approval card is outstanding (#2841)', () =>
101
101
  expect(fn).toMatch(/hasPendingApproval\s*=\s*pendingPermissions\.size\s*>\s*0/)
102
102
  })
103
103
 
104
- it('turnInFlightMachineOnly carries BOTH busy paths (legacy claudeBusyKeys + machine)', () => {
104
+ it('turnInFlightMachineOnly reads the delivery machine (sole gate, #2996 P1)', () => {
105
105
  // #3262: the machine-in-turn signal moved here (read by the /model & /effort
106
- // resolver without the approval hold). Both the legacy claudeBusyKeys path
107
- // and the machine-authoritative probeGateParity path must be present — a
108
- // dropped branch would silently change the busy read.
106
+ // resolver without the approval hold). #2996 P1 deleted the legacy
107
+ // claudeBusyKeys branch and the gate-parity probe — the machine read is
108
+ // the ONLY busy source now; a reintroduced legacy branch would silently
109
+ // fork the busy read again.
109
110
  const fn = slice(GATEWAY, 'function turnInFlightMachineOnly()', 1400)
110
- expect(fn).toMatch(/claudeBusyKeys\.size\s*>\s*0/)
111
- expect(fn).toContain('probeGateParity(')
111
+ expect(fn).toContain('return isMachineInTurn()')
112
+ // Code-shape pins (comments may still narrate the deleted legacy design):
113
+ expect(fn).not.toMatch(/claudeBusyKeys\.size/)
114
+ expect(fn).not.toMatch(/probeGateParity\(/)
112
115
  // NOT gated by the approval hold — that stays in turnInFlightForGate.
113
116
  expect(fn).not.toContain('hasPendingApproval')
114
117
  })
@@ -21,12 +21,15 @@ import { describe, it, expect } from 'vitest'
21
21
  import { readFileSync } from 'node:fs'
22
22
  import { resolve } from 'node:path'
23
23
 
24
+ // #2996 P2: executeReply's body (incl. the file-send + result + history
25
+ // block this suite pins) moved VERBATIM to outbound-send-path.ts
26
+ // (`sendReply`); the slice reads there now. Assertions unchanged.
24
27
  const gatewaySrc = readFileSync(
25
- resolve(__dirname, '..', 'gateway', 'gateway.ts'),
28
+ resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
26
29
  'utf-8',
27
30
  )
28
31
 
29
- // Slice executeReply's file-send + result + history block: from the
32
+ // Slice sendReply's file-send + result + history block: from the
30
33
  // photo-precheck anchor to the recordOutbound call for replies.
31
34
  const sliceStart = gatewaySrc.indexOf('const photoPrecheck = new Map')
32
35
  const sliceEnd = gatewaySrc.indexOf('history recordOutbound (reply) failed')
@@ -43,8 +43,10 @@ describe('#1713 + #1728 — reply tool reaction contract', () => {
43
43
  const fs = require('node:fs') as typeof import('node:fs')
44
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
45
45
  const path = require('node:path') as typeof import('node:path')
46
+ // #2996 P2: executeReply's body moved verbatim to outbound-send-path.ts
47
+ // (`sendReply`); the post-send block anchors live there now.
46
48
  const src = fs.readFileSync(
47
- path.resolve(__dirname, '../gateway/gateway.ts'),
49
+ path.resolve(__dirname, '../gateway/outbound-send-path.ts'),
48
50
  'utf8',
49
51
  )
50
52
  // Find the executeReply post-send block — anchored on the
@@ -75,8 +77,10 @@ describe('#1713 + #1728 — reply tool reaction contract', () => {
75
77
  const fs = require('node:fs') as typeof import('node:fs')
76
78
  // eslint-disable-next-line @typescript-eslint/no-require-imports
77
79
  const path = require('node:path') as typeof import('node:path')
80
+ // #2996 P2: executeReply's body moved verbatim to outbound-send-path.ts
81
+ // (`sendReply`); the post-send block anchors live there now.
78
82
  const src = fs.readFileSync(
79
- path.resolve(__dirname, '../gateway/gateway.ts'),
83
+ path.resolve(__dirname, '../gateway/outbound-send-path.ts'),
80
84
  'utf8',
81
85
  )
82
86
  const anchor = src.indexOf("fresh sendMessage from reply tool is a user-visible")
@@ -145,8 +145,9 @@ describe('auth-flow context rule — Channel B (structural wiring in gateway.ts)
145
145
  })
146
146
 
147
147
  it('reaps awaitingAuthCodeAt in the TTL reaper alongside other pending-state maps', () => {
148
- // The pendingStateReaper interval must sweep expired auth-code-context entries
149
- const reaperIdx = src.indexOf('pendingStateReaper = setInterval')
148
+ // The pendingStateReaper interval must sweep expired auth-code-context entries.
149
+ // Anchor tolerates the #2996 P0c `isGatewayMain ? setInterval(...)` boot gate.
150
+ const reaperIdx = src.indexOf('pendingStateReaper = ')
150
151
  expect(reaperIdx).toBeGreaterThan(0)
151
152
  const reaperBlock = src.slice(reaperIdx, reaperIdx + 800)
152
153
  expect(reaperBlock).toMatch(/awaitingAuthCodeAt/)
@@ -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
+ })