switchroom 0.19.42 → 0.19.43

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 (72) hide show
  1. package/dist/agent-scheduler/index.js +1 -1
  2. package/dist/auth-broker/index.js +32 -7
  3. package/dist/cli/notion-write-pretool.mjs +1 -1
  4. package/dist/cli/switchroom.js +1187 -669
  5. package/dist/host-control/main.js +205 -28
  6. package/dist/vault/approvals/kernel-server.js +32 -7
  7. package/dist/vault/broker/server.js +32 -7
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +82 -4
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/card-layout.ts +63 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +4 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +1225 -614
  14. package/telegram-plugin/dist/server.js +5 -2
  15. package/telegram-plugin/flood-429-ledger.ts +5 -3
  16. package/telegram-plugin/flood-circuit-breaker.ts +11 -3
  17. package/telegram-plugin/format.ts +223 -13
  18. package/telegram-plugin/gateway/boot-card.ts +7 -2
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  20. package/telegram-plugin/gateway/gateway.ts +49 -51
  21. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  22. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  23. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  24. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  25. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  26. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  27. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  28. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  29. package/telegram-plugin/gateway/stream-render.ts +5 -0
  30. package/telegram-plugin/gateway/update-announce.ts +11 -4
  31. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  32. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  33. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  34. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  35. package/telegram-plugin/outbox.ts +103 -2
  36. package/telegram-plugin/render/rich-render.ts +10 -2
  37. package/telegram-plugin/reply-quote.ts +150 -0
  38. package/telegram-plugin/retry-api-call.ts +254 -11
  39. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  40. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  41. package/telegram-plugin/silent-end.ts +34 -2
  42. package/telegram-plugin/status-no-truncate.ts +31 -16
  43. package/telegram-plugin/stream-controller.ts +44 -9
  44. package/telegram-plugin/stream-reply-handler.ts +5 -3
  45. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  46. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  47. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  48. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  49. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  50. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  51. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  52. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  53. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  54. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  55. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  56. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  57. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  58. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  59. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  60. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  61. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  62. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  63. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  64. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  65. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  66. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  68. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  69. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  70. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  71. package/telegram-plugin/tool-activity-summary.ts +58 -30
  72. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Regression: switchroom #4043 — the plain-text FALLBACK was never re-capped to
3
+ * the plain endpoint's 4096-char wire limit.
4
+ *
5
+ * Every send path chunks for the RICH cap (`RICH_MESSAGE_MAX_CHARS` = 32768,
6
+ * `format.ts`). Three sites then degrade a chunk to the PLAIN `sendMessage`
7
+ * endpoint, which caps at 4096:
8
+ *
9
+ * 1. the reply path's markdown parse-reject fallback
10
+ * (`gateway/outbound-send-path.ts`, `sendReplyChunks` → `sendChunkPlainText`)
11
+ * 2. the outbox sweep's parse-reject fallback
12
+ * (`gateway/outbox-sweep.ts`, `sendChunkRich` → `bot.api.sendMessage`)
13
+ * 3. the captured-prose exhausted-boundary fallback
14
+ * (`gateway/outbound-send-path.ts`, `deliverCapturedProse`)
15
+ *
16
+ * Pre-fix all three handed the FULL rich-sized chunk to the plain endpoint, so
17
+ * Telegram answered `Bad Request: message is too long` and the rescue send —
18
+ * the thing that exists so a long answer is never lost — failed too. In the
19
+ * sweep that is worse than a loss: the record is never journaled and never
20
+ * cleared, so the same doomed send is retried on every tick forever.
21
+ *
22
+ * These assert the USER OUTCOME, not the code path: the fake Telegram enforces
23
+ * the real 4096 limit, so a test that merely walked the fallback without the
24
+ * re-cap would throw and fail. The whole body must arrive, in pieces the plain
25
+ * endpoint accepts.
26
+ */
27
+
28
+ import { describe, it, expect } from 'vitest'
29
+ import { GrammyError } from 'grammy'
30
+ import {
31
+ PLAIN_TEXT_MAX_CHARS,
32
+ RICH_MESSAGE_MAX_CHARS,
33
+ splitPlainTextToCap,
34
+ } from '../format.js'
35
+ import {
36
+ sendReplyChunks,
37
+ deliverCapturedProse,
38
+ computeReplyChunks,
39
+ type ReplyChunkSendDeps,
40
+ type DeliverCapturedProseDeps,
41
+ } from '../gateway/outbound-send-path.js'
42
+ import { createOutboxSend } from '../gateway/outbox-sweep.js'
43
+ import { createRetryApiCall } from '../retry-api-call.js'
44
+ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
45
+
46
+ const CHAT = '100200300'
47
+
48
+ /** A body far past the plain cap but comfortably inside the rich cap, with
49
+ * ordinary paragraph boundaries (so the splitter has somewhere safe to cut). */
50
+ function longBody(chars: number): string {
51
+ const para = 'Sentence about the delivery seam that the user actually reads. '
52
+ let out = ''
53
+ let n = 0
54
+ while (out.length < chars) {
55
+ out += para.repeat(6) + `\n\nparagraph ${n++}\n\n`
56
+ }
57
+ return out.slice(0, chars)
58
+ }
59
+
60
+ /** Telegram's real plain-endpoint rejection for an oversized body. */
61
+ function tooLongError(): GrammyError {
62
+ return new GrammyError(
63
+ 'Call to sendMessage failed!',
64
+ { ok: false, error_code: 400, description: 'Bad Request: message is too long' },
65
+ 'sendMessage',
66
+ {} as never,
67
+ )
68
+ }
69
+
70
+ /** Telegram's real markdown parse-reject (what triggers every plain fallback). */
71
+ function parseRejectError(method: 'sendMessage' | 'sendRichMessage'): GrammyError {
72
+ return new GrammyError(
73
+ `Call to ${method} failed!`,
74
+ {
75
+ ok: false,
76
+ error_code: 400,
77
+ description: "Bad Request: can't parse entities: unexpected end of input",
78
+ },
79
+ method,
80
+ {} as never,
81
+ )
82
+ }
83
+
84
+ describe('splitPlainTextToCap (#4043 — the shared re-cap)', () => {
85
+ it('leaves a body that already fits untouched (single piece, byte-identical)', () => {
86
+ const body = 'short enough'
87
+ expect(splitPlainTextToCap(body)).toEqual([body])
88
+ })
89
+
90
+ it('caps every piece at 4096 and loses nothing', () => {
91
+ const body = longBody(20_000)
92
+ const pieces = splitPlainTextToCap(body)
93
+ expect(pieces.length).toBeGreaterThan(1)
94
+ for (const p of pieces) expect(p.length).toBeLessThanOrEqual(PLAIN_TEXT_MAX_CHARS)
95
+ expect(pieces.join('').replace(/\s+/g, '')).toBe(body.replace(/\s+/g, ''))
96
+ })
97
+
98
+ it('hard-slices an INDIVISIBLE region rather than emitting it oversized', () => {
99
+ // No newline, no space: `splitMarkdownChunks` emits such a region whole.
100
+ const blob = 'x'.repeat(10_000)
101
+ const pieces = splitPlainTextToCap(blob)
102
+ for (const p of pieces) expect(p.length).toBeLessThanOrEqual(PLAIN_TEXT_MAX_CHARS)
103
+ expect(pieces.join('')).toBe(blob)
104
+ })
105
+ })
106
+
107
+ describe('#4043 site 1 — reply path parse-reject fallback (sendReplyChunks)', () => {
108
+ /** Fake wire: the rich send parse-rejects; the plain send enforces 4096. */
109
+ function makeDeps() {
110
+ const plainSends: string[] = []
111
+ let id = 500
112
+ const deps: ReplyChunkSendDeps = {
113
+ sendRich: () => Promise.reject(parseRejectError('sendRichMessage')),
114
+ sendLiteral: () => Promise.reject(parseRejectError('sendMessage')),
115
+ sendLiteralRaw: (_opts, text) => {
116
+ if (text.length > PLAIN_TEXT_MAX_CHARS) return Promise.reject(tooLongError())
117
+ plainSends.push(text)
118
+ return Promise.resolve({ message_id: id++ })
119
+ },
120
+ sendRichRaw: () => Promise.reject(parseRejectError('sendRichMessage')),
121
+ editPreview: () => Promise.resolve({}),
122
+ richMessage: (s) => ({ markdown: s }),
123
+ logOutbound: () => {},
124
+ deleteStalePreview: () => Promise.resolve(),
125
+ stderr: () => {},
126
+ }
127
+ return { deps, plainSends }
128
+ }
129
+
130
+ it('delivers a 20k-char answer as ≤4096-char plain pieces instead of failing', async () => {
131
+ const body = longBody(20_000)
132
+ // The reply path splits for the RICH cap — one chunk, ~20k chars.
133
+ const chunks = computeReplyChunks({
134
+ effectiveText: body,
135
+ literalText: false,
136
+ limit: RICH_MESSAGE_MAX_CHARS,
137
+ chunkMode: 'newline',
138
+ })
139
+ expect(chunks).toHaveLength(1)
140
+
141
+ const { deps, plainSends } = makeDeps()
142
+ const sentIds: number[] = []
143
+ await sendReplyChunks(deps, {
144
+ chatId: CHAT,
145
+ chunks,
146
+ literalText: false,
147
+ suppressText: false,
148
+ threadId: undefined,
149
+ previewMessageId: null,
150
+ sentIds,
151
+ buildSendOpts: () => ({}),
152
+ buildPreviewEditOpts: () => ({}),
153
+ })
154
+
155
+ expect(plainSends.length).toBeGreaterThan(1)
156
+ for (const s of plainSends) expect(s.length).toBeLessThanOrEqual(PLAIN_TEXT_MAX_CHARS)
157
+ // Every delivered message is reported back to the caller (history / dedup).
158
+ expect(sentIds).toHaveLength(plainSends.length)
159
+ // Nothing was dropped on the floor.
160
+ expect(plainSends.join('').replace(/\s+/g, '')).toBe(body.replace(/\s+/g, ''))
161
+ })
162
+
163
+ it('still sends a short parse-rejected chunk as ONE plain message', async () => {
164
+ const { deps, plainSends } = makeDeps()
165
+ const sentIds: number[] = []
166
+ await sendReplyChunks(deps, {
167
+ chatId: CHAT,
168
+ chunks: ['a short answer'],
169
+ literalText: false,
170
+ suppressText: false,
171
+ threadId: undefined,
172
+ previewMessageId: null,
173
+ sentIds,
174
+ buildSendOpts: () => ({}),
175
+ buildPreviewEditOpts: () => ({}),
176
+ })
177
+ expect(plainSends).toEqual(['a short answer'])
178
+ expect(sentIds).toHaveLength(1)
179
+ })
180
+ })
181
+
182
+ describe('#4043 site 2 — outbox sweep parse-reject fallback (createOutboxSend)', () => {
183
+ function fakeBot() {
184
+ const plainSends: Array<{ text: string; opts: Record<string, unknown> }> = []
185
+ let id = 900
186
+ return {
187
+ plainSends,
188
+ api: {
189
+ sendRichMessage: async () => {
190
+ throw parseRejectError('sendRichMessage')
191
+ },
192
+ sendMessage: async (_chatId: string, text: string, opts: Record<string, unknown>) => {
193
+ if (text.length > PLAIN_TEXT_MAX_CHARS) throw tooLongError()
194
+ plainSends.push({ text, opts })
195
+ return { message_id: id++ }
196
+ },
197
+ },
198
+ }
199
+ }
200
+
201
+ const passthroughRetry = <U>(fn: () => Promise<U>): Promise<U> => fn()
202
+
203
+ it('delivers a 20k-char record as ≤4096-char plain pieces instead of retrying forever', async () => {
204
+ const bot = fakeBot()
205
+ const send = createOutboxSend({ getBot: () => bot as never, retry: passthroughRetry })
206
+ const body = longBody(20_000)
207
+
208
+ const res = await send(CHAT, null, body)
209
+
210
+ expect(bot.plainSends.length).toBeGreaterThan(1)
211
+ for (const s of bot.plainSends) {
212
+ expect(s.text.length).toBeLessThanOrEqual(PLAIN_TEXT_MAX_CHARS)
213
+ }
214
+ // The sweep reports one entry per message ACTUALLY landed, with the text
215
+ // that message carries — history / journal bookkeeping tracks the wire.
216
+ expect(res.chunks).toHaveLength(bot.plainSends.length)
217
+ expect(res.chunks.map((c) => c.text)).toEqual(bot.plainSends.map((s) => s.text))
218
+ expect(res.messageId).toBe(res.chunks[res.chunks.length - 1]!.messageId)
219
+ expect(bot.plainSends.map((s) => s.text).join('').replace(/\s+/g, '')).toBe(
220
+ body.replace(/\s+/g, ''),
221
+ )
222
+ })
223
+
224
+ it('the Listen keyboard rides the FINAL plain piece only (no duplicate buttons)', async () => {
225
+ const bot = fakeBot()
226
+ const markup = { inline_keyboard: [[{ text: '🔊 Listen', callback_data: 'voice:1' }]] }
227
+ const send = createOutboxSend({
228
+ getBot: () => bot as never,
229
+ retry: passthroughRetry,
230
+ resolveReplyMarkup: () => markup,
231
+ })
232
+
233
+ await send(CHAT, null, longBody(20_000))
234
+
235
+ const withMarkup = bot.plainSends.filter((s) => s.opts.reply_markup != null)
236
+ expect(withMarkup).toHaveLength(1)
237
+ expect(bot.plainSends[bot.plainSends.length - 1]!.opts.reply_markup).toEqual(markup)
238
+ })
239
+ })
240
+
241
+ describe('reply-path plain fallback — message-scoped trailers ride ONE piece', () => {
242
+ /**
243
+ * MAJOR 1. `buildSendOpts` builds opts for ONE message: `reply_markup` on the
244
+ * LAST chunk, `reply_parameters` on the quoting chunk. `sendChunkPlainText`
245
+ * then re-caps that chunk into N plain pieces — and used to hand the SAME
246
+ * opts object to every one of them. Outcome the user sees: N copies of the
247
+ * inline keyboard (a `single_use` keyboard only clears off the message that
248
+ * was tapped, so the action can be double-fired) and N quote-replies.
249
+ *
250
+ * The sweep path already got this right for the button
251
+ * (`outbox-sweep.ts` `withoutMarkup`); this is the reply path's version.
252
+ */
253
+ const markup = {
254
+ inline_keyboard: [[{ text: 'Approve', callback_data: 'approve:42' }]],
255
+ single_use: true,
256
+ }
257
+ const replyParams = { message_id: 77, quote: { text: 'the question', position: 0 } }
258
+
259
+ function makeDeps() {
260
+ const plainSends: Array<{ text: string; opts: Record<string, unknown> }> = []
261
+ let id = 600
262
+ const deps: ReplyChunkSendDeps = {
263
+ sendRich: () => Promise.reject(parseRejectError('sendRichMessage')),
264
+ sendLiteral: () => Promise.reject(parseRejectError('sendMessage')),
265
+ sendLiteralRaw: (opts, text) => {
266
+ if (text.length > PLAIN_TEXT_MAX_CHARS) return Promise.reject(tooLongError())
267
+ plainSends.push({ text, opts: opts as Record<string, unknown> })
268
+ return Promise.resolve({ message_id: id++ })
269
+ },
270
+ sendRichRaw: () => Promise.reject(parseRejectError('sendRichMessage')),
271
+ editPreview: () => Promise.resolve({}),
272
+ richMessage: (s) => ({ markdown: s }),
273
+ logOutbound: () => {},
274
+ deleteStalePreview: () => Promise.resolve(),
275
+ stderr: () => {},
276
+ }
277
+ return { deps, plainSends }
278
+ }
279
+
280
+ /** Mirrors the live `buildSendOpts` (gateway/outbound-send-path.ts): quote on
281
+ * the first chunk, keyboard on the last, thread on every one. */
282
+ const buildSendOpts = (i: number, isLastChunk: boolean) => ({
283
+ ...(i === 0 ? { reply_parameters: replyParams } : {}),
284
+ message_thread_id: 9,
285
+ ...(isLastChunk ? { reply_markup: markup } : {}),
286
+ })
287
+
288
+ it('an oversized keyboard-bearing reply: ONE keyboard (final piece), ONE quote (first piece)', async () => {
289
+ const body = longBody(20_000)
290
+ const chunks = computeReplyChunks({
291
+ effectiveText: body,
292
+ literalText: false,
293
+ limit: RICH_MESSAGE_MAX_CHARS,
294
+ chunkMode: 'newline',
295
+ })
296
+ // One rich chunk that is BOTH the first (quotes) and the last (keyboard).
297
+ expect(chunks).toHaveLength(1)
298
+
299
+ const { deps, plainSends } = makeDeps()
300
+ const sentIds: number[] = []
301
+ await sendReplyChunks(deps, {
302
+ chatId: CHAT,
303
+ chunks,
304
+ literalText: false,
305
+ suppressText: false,
306
+ threadId: 9,
307
+ previewMessageId: null,
308
+ sentIds,
309
+ buildSendOpts,
310
+ buildPreviewEditOpts: () => ({}),
311
+ })
312
+
313
+ // It really did split — otherwise the assertions below are vacuous.
314
+ expect(plainSends.length).toBeGreaterThan(1)
315
+
316
+ // Exactly ONE live keyboard, and it is on the LAST message the user sees.
317
+ const withMarkup = plainSends.filter((s) => s.opts.reply_markup != null)
318
+ expect(withMarkup).toHaveLength(1)
319
+ expect(plainSends[plainSends.length - 1]!.opts.reply_markup).toEqual(markup)
320
+
321
+ // Exactly ONE quote-reply, and it is on the FIRST message.
322
+ const withQuote = plainSends.filter((s) => s.opts.reply_parameters != null)
323
+ expect(withQuote).toHaveLength(1)
324
+ expect(plainSends[0]!.opts.reply_parameters).toEqual(replyParams)
325
+
326
+ // Per-message policy params still ride EVERY piece.
327
+ for (const s of plainSends) expect(s.opts.message_thread_id).toBe(9)
328
+
329
+ // And the answer itself is intact.
330
+ expect(plainSends.map((s) => s.text).join('').replace(/\s+/g, '')).toBe(
331
+ body.replace(/\s+/g, ''),
332
+ )
333
+ })
334
+
335
+ it('a single-piece fallback still carries both trailers on its one message', async () => {
336
+ const { deps, plainSends } = makeDeps()
337
+ const sentIds: number[] = []
338
+ await sendReplyChunks(deps, {
339
+ chatId: CHAT,
340
+ chunks: ['a short answer'],
341
+ literalText: false,
342
+ suppressText: false,
343
+ threadId: 9,
344
+ previewMessageId: null,
345
+ sentIds,
346
+ buildSendOpts,
347
+ buildPreviewEditOpts: () => ({}),
348
+ })
349
+ expect(plainSends).toHaveLength(1)
350
+ expect(plainSends[0]!.opts.reply_markup).toEqual(markup)
351
+ expect(plainSends[0]!.opts.reply_parameters).toEqual(replyParams)
352
+ })
353
+ })
354
+
355
+ describe('outbox sweep plain fallback — a mid-loop 429 does not re-deliver landed pieces', () => {
356
+ /**
357
+ * MAJOR 2. `createOutboxSend` wraps the WHOLE `sendChunkRich` — its
358
+ * multi-piece plain-fallback loop included — in
359
+ * `retryWithThreadFallback(deps.retry, …)`, and `createRetryApiCall`
360
+ * re-invokes that closure after a flood wait (`retry-api-call.ts`). So a 429
361
+ * on piece 4 of 5 re-ran the closure from the top: the rich send
362
+ * deterministically parse-rejects again and pieces 1-3 were re-sent into the
363
+ * user's chat. A mid-loop 429 is likely precisely in the flood conditions the
364
+ * sweep runs under.
365
+ *
366
+ * Asserted as the USER OUTCOME: every piece appears on the wire exactly once.
367
+ */
368
+ function floodError(retryAfter: number): GrammyError {
369
+ return new GrammyError(
370
+ 'Call to sendMessage failed!',
371
+ {
372
+ ok: false,
373
+ error_code: 429,
374
+ description: 'Too Many Requests: retry after ' + retryAfter,
375
+ parameters: { retry_after: retryAfter },
376
+ },
377
+ 'sendMessage',
378
+ {} as never,
379
+ )
380
+ }
381
+
382
+ /** Fake wire: rich always parse-rejects; the Nth plain send 429s ONCE. */
383
+ function fakeBot(floodOnCall: number) {
384
+ const plainSends: Array<{ text: string; opts: Record<string, unknown> }> = []
385
+ let richCalls = 0
386
+ let plainCalls = 0
387
+ let flooded = false
388
+ let id = 900
389
+ return {
390
+ plainSends,
391
+ richCalls: () => richCalls,
392
+ api: {
393
+ sendRichMessage: async () => {
394
+ richCalls++
395
+ throw parseRejectError('sendRichMessage')
396
+ },
397
+ sendMessage: async (_chatId: string, text: string, opts: Record<string, unknown>) => {
398
+ plainCalls++
399
+ if (text.length > PLAIN_TEXT_MAX_CHARS) throw tooLongError()
400
+ if (!flooded && plainCalls === floodOnCall) {
401
+ flooded = true
402
+ // Telegram rejected it — nothing landed for this piece.
403
+ throw floodError(1)
404
+ }
405
+ plainSends.push({ text, opts })
406
+ return { message_id: id++ }
407
+ },
408
+ },
409
+ }
410
+ }
411
+
412
+ it('resumes at the failed piece instead of re-sending pieces 1-3', async () => {
413
+ const bot = fakeBot(4)
414
+ // The REAL retry policy (which is what re-invokes the closure), with sleep
415
+ // stubbed so the flood wait costs no wall-clock time.
416
+ const retry = createRetryApiCall({ sleep: async () => {} })
417
+ const send = createOutboxSend({ getBot: () => bot as never, retry })
418
+ const body = longBody(20_000)
419
+
420
+ const res = await send(CHAT, null, body)
421
+
422
+ const expected = splitPlainTextToCap(body)
423
+ expect(expected.length).toBeGreaterThanOrEqual(5) // a real mid-loop failure
424
+
425
+ // Every piece delivered EXACTLY once, in order — no duplicate prefix.
426
+ expect(bot.plainSends.map((s) => s.text)).toEqual(expected)
427
+ // Bookkeeping matches the wire (no phantom / double-counted messages).
428
+ expect(res.chunks.map((c) => c.text)).toEqual(expected)
429
+ expect(res.chunks).toHaveLength(expected.length)
430
+ // The deterministic parse-reject is not re-issued on the retry either.
431
+ expect(bot.richCalls()).toBe(1)
432
+ // And nothing was lost.
433
+ expect(bot.plainSends.map((s) => s.text).join('').replace(/\s+/g, '')).toBe(
434
+ body.replace(/\s+/g, ''),
435
+ )
436
+ })
437
+
438
+ it('a 429 on the FINAL keyboard-bearing piece re-sends only that piece', async () => {
439
+ const body = longBody(20_000)
440
+ const expected = splitPlainTextToCap(body)
441
+ // Flood the LAST plain send — the piece that carries the button.
442
+ const bot = fakeBot(expected.length)
443
+ const markup = { inline_keyboard: [[{ text: '🔊 Listen', callback_data: 'voice:1' }]] }
444
+ const retry = createRetryApiCall({ sleep: async () => {} })
445
+ const send = createOutboxSend({
446
+ getBot: () => bot as never,
447
+ retry,
448
+ resolveReplyMarkup: () => markup,
449
+ })
450
+
451
+ await send(CHAT, null, body)
452
+
453
+ // The whole prefix is NOT re-delivered: one message per piece, no more.
454
+ expect(bot.plainSends.map((s) => s.text)).toEqual(expected)
455
+ const withMarkup = bot.plainSends.filter((s) => s.opts.reply_markup != null)
456
+ expect(withMarkup).toHaveLength(1)
457
+ expect(bot.plainSends[bot.plainSends.length - 1]!.opts.reply_markup).toEqual(markup)
458
+ })
459
+ })
460
+
461
+ describe('#4043 site 3 — captured-prose exhausted-boundary fallback', () => {
462
+ it('delivers a 20k-char recovered answer as ≤4096-char plain pieces', async () => {
463
+ const plainSends: string[] = []
464
+ let id = 700
465
+ const api = {
466
+ sendRichMessage: async () => {
467
+ throw parseRejectError('sendRichMessage')
468
+ },
469
+ sendMessage: async (_chatId: string, text: string) => {
470
+ if (text.length > PLAIN_TEXT_MAX_CHARS) throw tooLongError()
471
+ plainSends.push(text)
472
+ return { message_id: id++ }
473
+ },
474
+ }
475
+ const recorded: Array<{ message_ids: number[]; texts: string[] }> = []
476
+ const body = longBody(20_000)
477
+ const deps: DeliverCapturedProseDeps = {
478
+ outboundDedup: new OutboundDedupCache(),
479
+ bot: { api } as unknown as DeliverCapturedProseDeps['bot'],
480
+ robustApiCall: (fn) => fn(),
481
+ redactOutboundText: (t) => t,
482
+ recordOutbound: (rec) => recorded.push({ message_ids: rec.message_ids, texts: rec.texts }),
483
+ HISTORY_ENABLED: true,
484
+ OBLIGATION_LEDGER_ENABLED: true,
485
+ obligationLedger: { close: () => {} },
486
+ clearSilentEndState: () => {},
487
+ // Re-prompt budget already spent → the plain-text fallback arm fires.
488
+ recordUndeliveredTurnEnd: () => ({ exhausted: true }),
489
+ hasOutboundDeliveredSince: () => false,
490
+ }
491
+
492
+ await deliverCapturedProse(deps, {
493
+ chatId: CHAT,
494
+ threadId: undefined,
495
+ statusKeyStr: `${CHAT}:main`,
496
+ registryKey: null,
497
+ originTurnId: 'turn-4043',
498
+ text: body,
499
+ })
500
+
501
+ expect(plainSends.length).toBeGreaterThan(1)
502
+ for (const s of plainSends) expect(s.length).toBeLessThanOrEqual(PLAIN_TEXT_MAX_CHARS)
503
+ // The recovered answer reached the user AND landed in history (no generic
504
+ // apology instead of the answer).
505
+ expect(recorded).toHaveLength(1)
506
+ expect(recorded[0]!.texts).toEqual(plainSends)
507
+ expect(plainSends.join('').replace(/\s+/g, '')).toBe(body.replace(/\s+/g, ''))
508
+ })
509
+ })