switchroom 0.19.41 → 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.
- package/dist/agent-scheduler/index.js +23 -3
- package/dist/auth-broker/index.js +54 -9
- package/dist/cli/notion-write-pretool.mjs +23 -3
- package/dist/cli/switchroom.js +1956 -900
- package/dist/host-control/main.js +227 -30
- package/dist/vault/approvals/kernel-server.js +54 -9
- package/dist/vault/broker/server.js +54 -9
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +82 -4
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1318 -693
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +56 -58
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +62 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- package/vendor/hindsight-memory/CHANGELOG.md +28 -0
- package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
- package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +18 -3
- package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
- package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
- package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
|
@@ -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
|
+
})
|