switchroom 0.18.8 → 0.18.9

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 (36) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/switchroom.js +2 -2
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/telegram-plugin/dist/gateway/gateway.js +78648 -77445
  6. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  7. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  8. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  9. package/telegram-plugin/gateway/gateway.ts +527 -2880
  10. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  11. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  12. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  13. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  14. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  15. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  16. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  17. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  18. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  19. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  20. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  21. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  22. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  23. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  24. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  25. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  26. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  27. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  28. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  29. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  30. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  31. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  32. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  33. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  34. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  35. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  36. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
@@ -7,17 +7,41 @@
7
7
  * (b) execute the returned effects against real I/O. This module owns
8
8
  * step (b) for the cutover.
9
9
  *
10
- * Scope of THIS PR bridgeUp only:
11
- * - drainBuffer → executed
12
- * - redeliverPersistedPermVerdicts → executed
10
+ * Effects wired to real executors (all effect kinds now handled):
11
+ * - drainBuffer → redeliverBufferedInbound
12
+ * - redeliverPersistedPermVerdicts → pendingPermissionBuffer.drain → send
13
+ * - deliverToBridge → client.send / ipcServer.sendToAgent
14
+ * - bufferInbound → pendingInboundBuffer.push
15
+ * - persistInbound → inboundSpool.put
16
+ * - deliverPermVerdict → client.send / ipcServer.sendToAgent
17
+ * - persistPermVerdict → pendingPermissionBuffer.push
18
+ * - setTurnStarted / clearTurnStarted → onSetTurnStarted / onClearTurnStarted
19
+ * - noteOutbound → onNoteOutbound
20
+ * - firePoke → onFirePoke
13
21
  * - logTrace → executed
14
22
  *
15
- * Other effects (deliverToBridge, bufferInbound, persistInbound,
16
- * setTurnStarted, clearTurnStarted, noteOutbound, firePoke,
17
- * deliverPermVerdict, persistPermVerdict) still flow through their
18
- * existing imperative paths in `gateway.ts`. The dispatcher logs them
19
- * as `not-yet-cutover` so a future PR can wire them without grep-and-
20
- * pray. NEVER silently no-op: the trace is the gate.
23
+ * Live call sites (post PR3c flip, #2794):
24
+ * - bridgeUp (gateway onClientRegistered): drainBuffer /
25
+ * redeliverPersistedPermVerdicts / logTrace.
26
+ * - handleInbound DEFERRED_INBOUND_EMIT: the inbound-routing effects
27
+ * (deliverToBridge / bufferInbound / persistInbound / setTurnStarted)
28
+ * are AUTHORITATIVE when the cutover is on — the imperative twin in
29
+ * gateway.ts runs only under the kill switch or the documented
30
+ * carve-outs (interrupt-while-in-turn, bridge_dead). The twin is
31
+ * deleted in PR4 after the 48h bake (see the "Hard removal plan"
32
+ * checklist in the RFC: PR3b step 2 / PR4 remain open).
33
+ * - turnEnd lifecycle effects remain shadow-only: the imperative
34
+ * turn-end sites (purgeReactionTracking / releaseTurnBufferGate)
35
+ * already execute the effect-equivalents with gates the machine does
36
+ * not model yet (serialize-until-replied drain); PR3b step 2 tracks
37
+ * that flip.
38
+ *
39
+ * The gateway-internal turn/poke effects (setTurnStarted,
40
+ * clearTurnStarted, noteOutbound, firePoke) map to gateway-scope state
41
+ * (`claudeBusyKeys`, the silence-poke ladder) that this decoupled module
42
+ * cannot reach directly, so they are dispatched through OPTIONAL
43
+ * callbacks on the ctx. When a callback is absent the effect logs an
44
+ * `unwired` trace rather than silently no-opping — the trace is the gate.
21
45
  *
22
46
  * Kill switch: `SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0` disables
23
47
  * dispatcher execution and the gateway falls back to imperative-only.
@@ -26,10 +50,11 @@
26
50
 
27
51
  import type {
28
52
  Effect,
53
+ ChatKey,
29
54
  InboundMessage as MachineInboundMessage,
30
55
  } from './inbound-delivery-machine.js'
31
56
  import type { IpcServer, IpcClient } from './ipc-server.js'
32
- import type { InboundMessage } from './ipc-protocol.js'
57
+ import type { InboundMessage, PermissionEvent } from './ipc-protocol.js'
33
58
  import type { PendingInboundBuffer } from './pending-inbound-buffer.js'
34
59
  import { redeliverBufferedInbound } from './pending-inbound-buffer.js'
35
60
  import type { InboundSpool } from './inbound-spool.js'
@@ -54,6 +79,26 @@ export interface DispatchCtx {
54
79
  * (clerk lost-message incident, 2026-06-03.)
55
80
  */
56
81
  readonly onUserInboundDelivered?: (merged: InboundMessage) => void
82
+ // ── Gateway-internal turn/poke effect callbacks ──────────────────
83
+ // These effects map to gateway-scope state (`claudeBusyKeys`, the
84
+ // silence-poke ladder) that this decoupled module cannot reach. The
85
+ // gateway supplies them when it flips inbound routing through the
86
+ // machine (PR3c). Absent → the effect logs an `unwired` trace.
87
+ readonly onSetTurnStarted?: (key: ChatKey, at: number) => void
88
+ /**
89
+ * Optional: observe the outcome of a `deliverToBridge` effect. The
90
+ * imperative twin branches on `sendToAgent`'s boolean (delivered →
91
+ * steer-ack + busy-mark + delivery-confirm tracking; miss → release
92
+ * reservation + durable-buffer + restart notice). The machine cannot
93
+ * model a send that fails while the bridge is nominally alive, so the
94
+ * PR3c live call site needs the result to run the same post-send
95
+ * branches the twin runs. Called once per deliverToBridge effect,
96
+ * after the send attempt, before `onUserInboundDelivered` enrolment.
97
+ */
98
+ readonly onDeliverResult?: (key: ChatKey, ok: boolean, msg: InboundMessage) => void
99
+ readonly onClearTurnStarted?: (key: ChatKey) => void
100
+ readonly onNoteOutbound?: (key: ChatKey, at: number) => void
101
+ readonly onFirePoke?: (key: ChatKey, level: 'soft' | 'firm' | 'fallback') => void
57
102
  }
58
103
 
59
104
  const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== '0'
@@ -168,20 +213,133 @@ function dispatchOne(effect: Effect, ctx: DispatchCtx): void {
168
213
  return
169
214
  }
170
215
 
171
- // The cases below are KNOWN effect kinds that this PR does NOT
172
- // cut over. The imperative paths still run for them; the
173
- // dispatcher logs the event so future cutover PRs can grep for
174
- // exactly the call sites to migrate.
175
- case 'deliverToBridge':
176
- case 'bufferInbound':
177
- case 'persistInbound':
178
- case 'setTurnStarted':
179
- case 'clearTurnStarted':
180
- case 'noteOutbound':
181
- case 'firePoke':
182
- case 'deliverPermVerdict':
216
+ case 'deliverToBridge': {
217
+ // Route a single inbound to the live bridge. The machine's
218
+ // InboundMessage.payload carries the real ipc-protocol
219
+ // InboundMessage; the machine treats it as opaque.
220
+ const msg = effect.msg.payload as InboundMessage
221
+ let ok = false
222
+ try {
223
+ if (ctx.client) {
224
+ ctx.client.send(msg)
225
+ ok = true
226
+ } else {
227
+ ok = ctx.ipcServer.sendToAgent(ctx.selfAgent, msg)
228
+ }
229
+ } catch (err) {
230
+ log(
231
+ `telegram gateway: dispatch deliverToBridge send threw agent=${ctx.selfAgent} ` +
232
+ `key=${effect.key}: ${(err as Error).message}\n`,
233
+ )
234
+ ok = false
235
+ }
236
+ if (ctx.onDeliverResult) {
237
+ try {
238
+ ctx.onDeliverResult(effect.key, ok, msg)
239
+ } catch {
240
+ /* observer is best-effort; never breaks delivery */
241
+ }
242
+ }
243
+ if (ok && ctx.onUserInboundDelivered) {
244
+ // Enrol in the deliver-until-acked sweep — a socket write is not
245
+ // proof claude consumed it (see onUserInboundDelivered doc).
246
+ // Pass UNCONDITIONALLY, mirroring the drainBuffer path above:
247
+ // the callback self-gates via shouldTrackDelivery (inbound-
248
+ // delivery-confirm.ts), which REJECTS sourced messages and
249
+ // tracks real user inbounds (meta.source undefined) — exactly
250
+ // the messages the sweep protects. Do NOT pre-filter on
251
+ // meta.source here; an inverted guard would skip user inbounds
252
+ // and enrol only messages the inner gate discards.
253
+ try {
254
+ ctx.onUserInboundDelivered(msg)
255
+ } catch {
256
+ /* enrolment is best-effort; never breaks delivery */
257
+ }
258
+ }
259
+ log(`gw-trace dispatch deliverToBridge key=${effect.key} ok=${ok}\n`)
260
+ return
261
+ }
262
+
263
+ case 'bufferInbound': {
264
+ const msg = effect.msg.payload as InboundMessage
265
+ const buffered = ctx.pendingInboundBuffer.push(ctx.selfAgent, msg)
266
+ log(`gw-trace dispatch bufferInbound key=${effect.key} buffered=${buffered}\n`)
267
+ return
268
+ }
269
+
270
+ case 'persistInbound': {
271
+ // Durable spool. `pendingInboundBuffer.push` already spools when a
272
+ // spool is attached to the buffer, so a persistInbound paired with
273
+ // a bufferInbound is idempotent (put() is idempotent by spoolId).
274
+ const msg = effect.msg.payload as InboundMessage
275
+ const put = ctx.inboundSpool ? ctx.inboundSpool.put(ctx.selfAgent, msg) : false
276
+ log(`gw-trace dispatch persistInbound key=${effect.key} put=${put}\n`)
277
+ return
278
+ }
279
+
280
+ case 'deliverPermVerdict': {
281
+ // Branch order differs from the imperative twin
282
+ // (gateway.ts dispatchPermissionVerdict), which always goes via
283
+ // `ipcServer.sendToAgent` — it runs outside any bridgeUp context,
284
+ // so it has no just-registered client handle. Here `ctx.client`
285
+ // is preferred WHEN PRESENT for the same reason as drainBuffer /
286
+ // redeliverPersistedPermVerdicts above: on the bridgeUp path the
287
+ // registry lookup may not yet observe the just-registered client,
288
+ // and a direct send to the connecting socket is the reliable
289
+ // route. PR3c's live call site for machine-driven permVerdict
290
+ // events (non-bridgeUp) must construct the ctx WITHOUT `client`,
291
+ // which makes this branch behave exactly like the twin
292
+ // (sendToAgent). Note the twin additionally buffers on a failed
293
+ // send; the machine models that as a separate persistPermVerdict
294
+ // effect (bridge_dead state), so no fallback push here.
295
+ const ev = effect.verdict.payload as PermissionEvent
296
+ let ok = false
297
+ try {
298
+ if (ctx.client) {
299
+ ctx.client.send(ev as never)
300
+ ok = true
301
+ } else {
302
+ ok = ctx.ipcServer.sendToAgent(ctx.selfAgent, ev as never)
303
+ }
304
+ } catch (err) {
305
+ log(
306
+ `telegram gateway: dispatch deliverPermVerdict send threw agent=${ctx.selfAgent} ` +
307
+ `request=${effect.verdict.requestId}: ${(err as Error).message}\n`,
308
+ )
309
+ ok = false
310
+ }
311
+ log(`gw-trace dispatch deliverPermVerdict request=${effect.verdict.requestId} ok=${ok}\n`)
312
+ return
313
+ }
314
+
183
315
  case 'persistPermVerdict': {
184
- log(`gw-trace dispatch not-yet-cutover effect=${effect.kind}\n`)
316
+ const ev = effect.verdict.payload as PermissionEvent
317
+ const pushed = ctx.pendingPermissionBuffer.push(ctx.selfAgent, ev)
318
+ log(`gw-trace dispatch persistPermVerdict request=${effect.verdict.requestId} pushed=${pushed}\n`)
319
+ return
320
+ }
321
+
322
+ case 'setTurnStarted': {
323
+ if (ctx.onSetTurnStarted) ctx.onSetTurnStarted(effect.key, effect.at)
324
+ else log(`gw-trace dispatch unwired effect=setTurnStarted key=${effect.key}\n`)
325
+ return
326
+ }
327
+
328
+ case 'clearTurnStarted': {
329
+ if (ctx.onClearTurnStarted) ctx.onClearTurnStarted(effect.key)
330
+ else log(`gw-trace dispatch unwired effect=clearTurnStarted key=${effect.key}\n`)
331
+ return
332
+ }
333
+
334
+ case 'noteOutbound': {
335
+ if (ctx.onNoteOutbound) ctx.onNoteOutbound(effect.key, effect.at)
336
+ else log(`gw-trace dispatch unwired effect=noteOutbound key=${effect.key}\n`)
337
+ return
338
+ }
339
+
340
+ case 'firePoke': {
341
+ if (ctx.onFirePoke) ctx.onFirePoke(effect.key, effect.level)
342
+ else log(`gw-trace dispatch unwired effect=firePoke key=${effect.key} level=${effect.level}\n`)
185
343
  return
186
344
  }
187
345
  }
@@ -261,6 +261,14 @@ export function transition(state: State, event: Event): Transition {
261
261
  const alive = state.global.kind !== 'bridge_dead'
262
262
 
263
263
  if (!alive) {
264
+ // ANCHORED STRING — `inbound_bridge_dead_buffer` is load-bearing:
265
+ // gateway.ts's PR3c bridge-dead carve-out keys off this exact stage
266
+ // string to route the inbound to the imperative twin (whose
267
+ // shouldTrackDelivery drop semantics + restart notice are the
268
+ // contract the machine doesn't model). Renaming it silently reroutes
269
+ // that carve-out; a pin test in inbound-delivery-cutover-flip.test.ts
270
+ // breaks on rename. Both the string key-off and this anchor are
271
+ // deleted in PR4 with the twin.
264
272
  return {
265
273
  state,
266
274
  effects: [
@@ -0,0 +1,375 @@
1
+ // Outbound send-path — deterministic text pipeline + chunking core (#2996).
2
+ //
3
+ // Phase 2 of the gateway.ts decomposition (issue #2996, plan §3B). This
4
+ // module owns the pure, side-effect-free heart of the reply/stream/turn-flush
5
+ // outbound pipeline: the normalize → redact → punctuation/bold → voice-scrub
6
+ // text transform, the effective-text spacing decision, the length-limit
7
+ // chunking, and the oversize-chunk re-split. These are exactly the transforms
8
+ // where the recent oversize / redaction / voice-scrub regressions landed, and
9
+ // extracting them here makes them unit-testable in isolation (see
10
+ // outbound-send-path.test.ts golden snapshots).
11
+ //
12
+ // Deliberately NOT moved here (they stay in gateway.ts, delegating to this
13
+ // module): the side-effecting send orchestration — currentTurn pinning,
14
+ // emission-authority / over-ping decisions, activity-card finalize, voice
15
+ // synthesis + sends, typing loops, history recording, the shared
16
+ // `outboundDedup` singleton check/record, and the raw bot.api send loop with
17
+ // its partial-failure contract. Those read gateway module state and are not
18
+ // byte-identically relocatable without an invocable-executeReply harness that
19
+ // this pure-core extraction is itself the prerequisite for.
20
+ //
21
+ // currentTurn coupling (#1067/#1664): this module NEVER reads the currentTurn
22
+ // global. Every function here is pure over its arguments — turn identity is
23
+ // pinned by the caller and never observed here.
24
+
25
+ import {
26
+ repairEscapedWhitespace,
27
+ normalizeParagraphBreaks,
28
+ normalizePunctuation,
29
+ stripExcessBold,
30
+ addParagraphSpacers,
31
+ splitMarkdownChunks,
32
+ hardSliceToCap,
33
+ RICH_MESSAGE_MAX_CHARS,
34
+ } from '../format.js'
35
+ import { scrubVoice } from '../text-voice-scrub.js'
36
+ import { isMessageTooLongError, isHtmlParseRejectError } from '../retry-api-call.js'
37
+
38
+ /** The redactor the caller injects. In gateway this is `redactOutboundText`,
39
+ * which wraps `redact()` and logs (never the secret value) when a mask fires.
40
+ * Injected rather than imported so the redaction structural-wiring test
41
+ * (`gateway-outbound-redact.test.ts`) keeps pinning the helper in gateway.ts,
42
+ * and so this module stays free of the stderr side effect. */
43
+ export type RedactFn = (text: string, site: string) => string
44
+
45
+ export interface NormalizeOutboundResult {
46
+ /** The fully-normalized text. This is the value used downstream as the
47
+ * dedup key, the Telegraph threshold input, and (after effective-text
48
+ * spacing) the chunk source. Callers apply it exactly as the pre-#2996
49
+ * inline pipeline did. */
50
+ text: string
51
+ /** Number of voice-scrub replacements applied (dashes → commas/periods,
52
+ * opener strips). >0 means the voice scrub mutated the text; the caller
53
+ * emits the `voice_scrub_applied` runtime metric on that condition. */
54
+ voiceReplaced: number
55
+ }
56
+
57
+ /**
58
+ * Stage 1 — the deterministic outbound text transform, byte-identical to the
59
+ * inline pipeline at the entry of executeReply (and mirrored on the
60
+ * answer-stream + turn-flush paths):
61
+ *
62
+ * 1. repairEscapedWhitespace — undo LLM JSON-escape bungles
63
+ * 2. normalizeParagraphBreaks — promote lone prose breaks to GFM hard breaks
64
+ * 3. redact (injected) — outbound secret scrub (#2044), BEFORE the
65
+ * punctuation/bold normalizers so a secret with
66
+ * an em-dash or `**` is matched literally
67
+ * 4. stripExcessBold∘normalizePunctuation — fleet-consistent formatting
68
+ * 5. scrubVoice — em/en dash → comma/period (#1683)
69
+ *
70
+ * The order is load-bearing and MUST NOT change (each step's comment in the
71
+ * former inline site documents why). Pure over its arguments.
72
+ */
73
+ export function normalizeOutboundBody(
74
+ rawText: string,
75
+ site: string,
76
+ redact: RedactFn,
77
+ ): NormalizeOutboundResult {
78
+ let text = normalizeParagraphBreaks(repairEscapedWhitespace(rawText))
79
+ text = redact(text, site)
80
+ text = stripExcessBold(normalizePunctuation(text))
81
+ let voiceReplaced = 0
82
+ const scrub = scrubVoice(text)
83
+ if (scrub.replaced > 0) {
84
+ text = scrub.scrubbed
85
+ voiceReplaced = scrub.replaced
86
+ }
87
+ return { text, voiceReplaced }
88
+ }
89
+
90
+ /**
91
+ * Effective-text spacing (#2669 rich-message regression fix). The rich GFM
92
+ * renderer collapses `\n\n` gaps tight, so prose paragraphs render jammed.
93
+ * Inject a visible blank-line spacer on the rich path only; the literal
94
+ * (`format:'text'`) path stays byte-exact. Pure.
95
+ */
96
+ export function computeEffectiveText(text: string, literalText: boolean): string {
97
+ return literalText ? text : addParagraphSpacers(text)
98
+ }
99
+
100
+ /**
101
+ * Length-limit chunking. The literal path uses the newline/length `chunk()`
102
+ * splitter; the rich path uses `splitMarkdownChunks` (markdown-boundary-aware).
103
+ * Pure. `chunk` is passed in so the splitter (moved here as `chunkText`) and
104
+ * this decision stay colocated without a circular gateway import.
105
+ */
106
+ export function computeReplyChunks(args: {
107
+ effectiveText: string
108
+ literalText: boolean
109
+ limit: number
110
+ chunkMode: 'length' | 'newline'
111
+ }): string[] {
112
+ const { effectiveText, literalText, limit, chunkMode } = args
113
+ return literalText
114
+ ? chunkText(effectiveText, limit, chunkMode)
115
+ : splitMarkdownChunks(effectiveText, limit)
116
+ }
117
+
118
+ /**
119
+ * Oversize-chunk re-split (length-error recovery). A single pre-computed chunk
120
+ * can still exceed the wire cap when `splitMarkdownChunks` hit an indivisible
121
+ * region and emitted it whole (a giant fenced block, a no-boundary blob).
122
+ * Re-split at the hard `RICH_MESSAGE_MAX_CHARS` cap; for a truly indivisible
123
+ * block, fall back to a hard character cut so each delivered piece stays under
124
+ * the wire cap. Byte-identical to the inline `sendChunkResplit` piece
125
+ * computation. Pure.
126
+ */
127
+ export function resplitOversizeChunk(piece: string): string[] {
128
+ const subPieces = splitMarkdownChunks(piece, RICH_MESSAGE_MAX_CHARS)
129
+ return subPieces.length > 1 ? subPieces : hardSliceToCap(piece, RICH_MESSAGE_MAX_CHARS)
130
+ }
131
+
132
+ /**
133
+ * Length/newline text splitter (relocated verbatim from gateway.ts). Splits
134
+ * `text` into <= `limit`-char pieces. In `newline` mode it prefers a paragraph
135
+ * break, then a line break, then a space past the halfway point; `length` mode
136
+ * cuts hard at the limit. Pure.
137
+ */
138
+ export function chunkText(text: string, limit: number, mode: 'length' | 'newline'): string[] {
139
+ if (text.length <= limit) return [text]
140
+ const out: string[] = []
141
+ let rest = text
142
+ while (rest.length > limit) {
143
+ let cut = limit
144
+ if (mode === 'newline') {
145
+ const para = rest.lastIndexOf('\n\n', limit)
146
+ const line = rest.lastIndexOf('\n', limit)
147
+ const space = rest.lastIndexOf(' ', limit)
148
+ cut = para > limit / 2 ? para : line > limit / 2 ? line : space > 0 ? space : limit
149
+ }
150
+ out.push(rest.slice(0, cut))
151
+ rest = rest.slice(cut).replace(/^\n+/, '')
152
+ }
153
+ if (rest) out.push(rest)
154
+ return out
155
+ }
156
+
157
+ // ─── Send orchestration (#2996 step 1) ────────────────────────────────────
158
+ //
159
+ // The reply chunk-send loop, relocated VERBATIM from executeReply so it can be
160
+ // driven from a unit test against a fake bot API (see
161
+ // outbound-send-path.test.ts). This is the highest-bug-density mechanic of the
162
+ // send path — the recent oversize / wire-cap / parse-reject / THREAD_NOT_FOUND
163
+ // fallback fixes all landed HERE — and it was previously reachable only through
164
+ // the non-importable gateway monolith (gateway.ts runs boot logic + Bun.listen
165
+ // at import, so `executeReply` cannot be invoked from vitest/bun in-place).
166
+ //
167
+ // The module stays bot-agnostic: every Telegram send is an INJECTED function
168
+ // dep, so the raw `bot.api.*` calls (and their retry wrapping + allow-raw-bot-api
169
+ // markers) remain in gateway.ts, and a test passes fakes. The caller keeps
170
+ // building the per-chunk send/edit option objects (so the option shape stays
171
+ // byte-identical to the inline site and the deps surface stays small — 8), pins
172
+ // the turn, owns dedup/voice/history, and threads the shared `sentIds` array by
173
+ // reference. currentTurn is NEVER read here (#1067/#1664).
174
+
175
+ /** Injected Telegram send surface + logging for {@link sendReplyChunks}. In
176
+ * gateway these are thin adapters over `lockedBot.api.*` (retry-wrapped where
177
+ * the inline site wrapped them); in tests they are fakes recording call shape. */
178
+ export interface ReplyChunkSendDeps {
179
+ /** robustApiCall-wrapped rich send. Adapter: `sendRichMessage(richMessage(s))`.
180
+ * `threadId` (the live value) is passed into the robustApiCall meta so a
181
+ * thread-not-found 400 is converted to THREAD_NOT_FOUND, exactly as inline. */
182
+ sendRich: (opts: Record<string, unknown>, richBody: unknown, threadId: number | undefined) => Promise<{ message_id: number }>
183
+ /** robustApiCall-wrapped literal send. Adapter: `sendMessage(chunk)`. */
184
+ sendLiteral: (opts: Record<string, unknown>, text: string, threadId: number | undefined) => Promise<{ message_id: number }>
185
+ /** UNwrapped literal send (last-resort fallbacks that must NOT re-enter the
186
+ * retry policy that just rejected the payload). Adapter: raw `sendMessage`. */
187
+ sendLiteralRaw: (opts: Record<string, unknown>, text: string) => Promise<{ message_id: number }>
188
+ /** UNwrapped rich send (length-error re-split last resort). Adapter: raw
189
+ * `sendRichMessage(richMessage(piece))`. */
190
+ sendRichRaw: (opts: Record<string, unknown>, richBody: unknown) => Promise<{ message_id: number }>
191
+ /** robustApiCall-wrapped preview edit-in-place. */
192
+ editPreview: (messageId: number, body: unknown, opts: Record<string, unknown>, threadId: number | undefined) => Promise<unknown>
193
+ /** rich-markdown wrapper (`richMessage`). Applied to a chunk/piece string. */
194
+ richMessage: (s: string) => unknown
195
+ /** outbound logger (`logOutbound`). */
196
+ logOutbound: (path: 'reply', chatId: string, messageId: number, chars: number, extra?: string) => void
197
+ /** delete a stale preview message (`deleteStalePreview`). */
198
+ deleteStalePreview: (id: number) => Promise<void>
199
+ /** stderr sink (`process.stderr.write`). */
200
+ stderr: (s: string) => void
201
+ }
202
+
203
+ /** Mutable send state + per-chunk option builders for {@link sendReplyChunks}.
204
+ * The caller owns option shape (byte-identical to the inline site). */
205
+ export interface ReplyChunkSendState {
206
+ chatId: string
207
+ chunks: string[]
208
+ literalText: boolean
209
+ /** voice-only mode with a full synthesis skips the text body entirely. */
210
+ suppressText: boolean
211
+ /** current thread id; re-split/fallbacks may drop it (THREAD_NOT_FOUND). */
212
+ threadId: number | undefined
213
+ /** a stale draft-stream preview to edit-in-place on the first chunk, or null. */
214
+ previewMessageId: number | null
215
+ /** shared results array — appended in place (voice/file sends push too). */
216
+ sentIds: number[]
217
+ /** build the send-options object for chunk `i` (last-chunk flag + live thread). */
218
+ buildSendOpts: (i: number, isLastChunk: boolean, threadId: number | undefined) => Record<string, unknown>
219
+ /** build the preview edit-in-place options for the first chunk. */
220
+ buildPreviewEditOpts: (isLastChunk: boolean) => Record<string, unknown>
221
+ }
222
+
223
+ export interface ReplyChunkSendResult {
224
+ /** thread id after any THREAD_NOT_FOUND fallback (used by later file sends). */
225
+ threadId: number | undefined
226
+ /** preview id after consumption (null once edited/deleted). */
227
+ previewMessageId: number | null
228
+ }
229
+
230
+ /**
231
+ * Send the pre-computed reply chunks. Relocated verbatim from executeReply's
232
+ * chunk loop. Appends message ids to `state.sentIds` in order. On an
233
+ * unrecoverable send error it throws the raw error — the caller wraps it into
234
+ * the `reply failed after N of M chunk(s) sent` partial-failure contract and
235
+ * runs the typing-loop `finally`, exactly as before.
236
+ */
237
+ export async function sendReplyChunks(
238
+ deps: ReplyChunkSendDeps,
239
+ state: ReplyChunkSendState,
240
+ ): Promise<ReplyChunkSendResult> {
241
+ const { chatId, chunks, literalText, suppressText, sentIds } = state
242
+ let threadId = state.threadId
243
+ let previewMessageId = state.previewMessageId
244
+
245
+ for (let i = 0; i < chunks.length; i++) {
246
+ // PR-C2: voice-only mode with a successful synthesis suppresses the
247
+ // text body — the spoken voice note IS the reply. Bail before the
248
+ // first chunk send (sentIds stays empty for text); the voice send
249
+ // below lands the answer. Any other mode (voice+text, or voice-only
250
+ // that fell back) sends the text chunks as normal.
251
+ if (suppressText) break
252
+ const isLastChunk = i === chunks.length - 1
253
+ const sendOpts = state.buildSendOpts(i, isLastChunk, threadId)
254
+
255
+ if (i === 0 && previewMessageId != null) {
256
+ const editOpts = state.buildPreviewEditOpts(isLastChunk)
257
+ try {
258
+ await deps.editPreview(previewMessageId!, literalText ? chunks[i] : deps.richMessage(chunks[i]), editOpts, threadId)
259
+ sentIds.push(previewMessageId!)
260
+ previewMessageId = null
261
+ continue
262
+ } catch (err) {
263
+ const msg = err instanceof Error ? err.message : String(err)
264
+ if (/not modified/i.test(msg)) {
265
+ sentIds.push(previewMessageId!)
266
+ previewMessageId = null
267
+ continue
268
+ }
269
+ deps.stderr(`telegram gateway: preview edit-in-place failed (${msg}), sending fresh\n`)
270
+ await deps.deleteStalePreview(previewMessageId!)
271
+ previewMessageId = null
272
+ }
273
+ }
274
+
275
+ // Last-resort: resend this chunk as plain text (no rich wrapper, so
276
+ // the markdown parser never runs). Keeps thread / reply / markup
277
+ // params; only the formatting is sacrificed. Used when Telegram
278
+ // rejects our markdown — better an unformatted answer than a
279
+ // vanished one. The raw markdown source is itself readable prose, so
280
+ // we send it verbatim rather than strip anything.
281
+ const sendChunkPlainText = async (opts: Record<string, unknown>): Promise<void> => {
282
+ const plain =
283
+ chunks[i].length > 0
284
+ ? chunks[i]
285
+ : '⚠️ (a fragment could not be rendered for Telegram)'
286
+ const sent = await deps.sendLiteralRaw(opts, plain)
287
+ sentIds.push(sent.message_id)
288
+ deps.logOutbound('reply', chatId, sent.message_id, plain.length, `chunk=${i + 1}/${chunks.length} plaintext-fallback`)
289
+ deps.stderr(
290
+ `telegram gateway: markdown parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text\n`,
291
+ )
292
+ }
293
+
294
+ // Literal `format:'text'` sends bypass the rich parser entirely
295
+ // (plain sendMessage, no markdown). The default path ships rich
296
+ // markdown via sendRichMessage. Both resolve to a Message with a
297
+ // message_id, which is all the caller reads.
298
+ //
299
+ // `wrapped` selects the retry-wrapped adapter (first attempt) vs the
300
+ // UNwrapped adapter (THREAD_NOT_FOUND retry). The inline site wrapped only
301
+ // the first attempt in robustApiCall; the retry called the raw send
302
+ // deliberately, so re-attempting after a dropped thread never re-enters the
303
+ // retry policy. Preserving that split keeps behavior byte-identical.
304
+ const sendChunk = (opts: Record<string, unknown>, wrapped: boolean): Promise<{ message_id: number }> => {
305
+ if (literalText) {
306
+ return wrapped ? deps.sendLiteral(opts, chunks[i], threadId) : deps.sendLiteralRaw(opts, chunks[i])
307
+ }
308
+ // sendRichMessage does NOT accept link_preview_options (rich messages
309
+ // control previews via entity detection) — drop it for the rich path.
310
+ const richOpts = { ...opts }
311
+ delete (richOpts as { link_preview_options?: unknown }).link_preview_options
312
+ const richBody = deps.richMessage(chunks[i])
313
+ return wrapped ? deps.sendRich(richOpts, richBody, threadId) : deps.sendRichRaw(richOpts, richBody)
314
+ }
315
+
316
+ // Length-error recovery: a single pre-computed chunk can still exceed the
317
+ // wire cap when splitMarkdownChunks hit an indivisible region and emitted
318
+ // it whole (a giant fenced block, a no-boundary blob). Telegram answers
319
+ // with RICH_MESSAGE_TEXT_TOO_LONG / MESSAGE_TOO_LONG. Re-split this chunk
320
+ // at a harder boundary and send each piece, rather than misclassifying it
321
+ // as a parse-reject (which would resend the same oversized payload as
322
+ // plain text) or surfacing the raw 400.
323
+ const sendChunkResplit = async (opts: Record<string, unknown>): Promise<void> => {
324
+ // Re-split at the same cap; for a truly indivisible block this still
325
+ // yields one oversized piece, but a hard character-cut on the rendered
326
+ // markdown at least keeps each delivered piece under the wire cap.
327
+ const pieces = resplitOversizeChunk(chunks[i])
328
+ for (let p = 0; p < pieces.length; p++) {
329
+ let sent: { message_id: number }
330
+ if (literalText) {
331
+ sent = await deps.sendLiteralRaw(opts, pieces[p])
332
+ } else {
333
+ const ro = { ...opts }
334
+ delete (ro as { link_preview_options?: unknown }).link_preview_options
335
+ sent = await deps.sendRichRaw(ro, deps.richMessage(pieces[p]))
336
+ }
337
+ sentIds.push(sent.message_id)
338
+ deps.logOutbound('reply', chatId, sent.message_id, pieces[p].length, `chunk=${i + 1}/${chunks.length} resplit=${p + 1}/${pieces.length}`)
339
+ }
340
+ deps.stderr(
341
+ `telegram gateway: rich body too long — re-split chunk ${i + 1}/${chunks.length} into ${pieces.length} piece(s)\n`,
342
+ )
343
+ }
344
+
345
+ try {
346
+ const sent = await sendChunk(sendOpts, true)
347
+ sentIds.push(sent.message_id)
348
+ deps.logOutbound('reply', chatId, sent.message_id, chunks[i].length, `chunk=${i + 1}/${chunks.length}`)
349
+ } catch (err) {
350
+ if (err instanceof Error && err.message === 'THREAD_NOT_FOUND') {
351
+ threadId = undefined
352
+ const retryOpts = { ...sendOpts }
353
+ delete (retryOpts as Record<string, unknown>).message_thread_id
354
+ try {
355
+ const sent = await sendChunk(retryOpts, false)
356
+ sentIds.push(sent.message_id)
357
+ } catch (retryErr) {
358
+ // Thread dropped, AND another failure: length → re-split,
359
+ // parse-reject → plain text, else propagate.
360
+ if (isMessageTooLongError(retryErr)) await sendChunkResplit(retryOpts)
361
+ else if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
362
+ else throw retryErr
363
+ }
364
+ } else if (isMessageTooLongError(err)) {
365
+ await sendChunkResplit(sendOpts)
366
+ } else if (isHtmlParseRejectError(err)) {
367
+ await sendChunkPlainText(sendOpts)
368
+ } else {
369
+ throw err
370
+ }
371
+ }
372
+ }
373
+
374
+ return { threadId, previewMessageId }
375
+ }