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.
Files changed (90) hide show
  1. package/dist/agent-scheduler/index.js +23 -3
  2. package/dist/auth-broker/index.js +54 -9
  3. package/dist/cli/notion-write-pretool.mjs +23 -3
  4. package/dist/cli/switchroom.js +1956 -900
  5. package/dist/host-control/main.js +227 -30
  6. package/dist/vault/approvals/kernel-server.js +54 -9
  7. package/dist/vault/broker/server.js +54 -9
  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 +1318 -693
  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/approval-callback-consume-record.test.ts +132 -0
  19. package/telegram-plugin/gateway/boot-card.ts +7 -2
  20. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  21. package/telegram-plugin/gateway/gateway.ts +56 -58
  22. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  23. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  24. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  25. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  26. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  27. package/telegram-plugin/gateway/model-command.ts +21 -109
  28. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  29. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  30. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  31. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  32. package/telegram-plugin/gateway/stream-render.ts +62 -0
  33. package/telegram-plugin/gateway/update-announce.ts +11 -4
  34. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  35. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  36. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  37. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  38. package/telegram-plugin/outbox.ts +103 -2
  39. package/telegram-plugin/render/rich-render.ts +10 -2
  40. package/telegram-plugin/reply-quote.ts +150 -0
  41. package/telegram-plugin/retry-api-call.ts +254 -11
  42. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  43. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  44. package/telegram-plugin/silent-end.ts +34 -2
  45. package/telegram-plugin/status-no-truncate.ts +31 -16
  46. package/telegram-plugin/stream-controller.ts +44 -9
  47. package/telegram-plugin/stream-reply-handler.ts +5 -3
  48. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  49. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  50. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  51. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  52. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  53. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  54. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  55. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  56. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  57. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  58. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  59. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  60. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  61. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  62. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  63. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  64. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  65. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  66. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  67. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  68. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  69. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  70. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  71. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  72. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  73. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  74. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  75. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  76. package/telegram-plugin/tool-activity-summary.ts +58 -30
  77. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  78. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  79. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  80. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  81. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  82. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  83. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  84. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  85. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  88. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  90. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Wire-level outcome test for `quote_text` (surgical quote-reply).
3
+ *
4
+ * WHY AT THE FETCH LAYER
5
+ * ----------------------
6
+ * The bug this pins was a PAYLOAD-SHAPE bug: `reply_parameters.quote` was built
7
+ * as `{ text, position: 0 }` when the Bot API declares `quote` a **String**
8
+ * (`quote_position` is a separate sibling Integer). Telegram answered every
9
+ * quoted reply with `400 Bad Request: field "quote" must be of type String` —
10
+ * 378 of 384 total `sendRichMessage` failures across the live fleet
11
+ * (agent `overlord`, 2026-07-29 → 07-30). A unit test over the options builder
12
+ * would have asserted the same wrong shape happily; only a test that reads what
13
+ * actually goes over the wire can catch it, and only that keeps a future
14
+ * wrapper (a re-render, an opts transform, a new send adapter) from re-breaking
15
+ * it invisibly.
16
+ *
17
+ * So this drives the REAL send path — `sendReply` (the body of the gateway's
18
+ * `executeReply`, which is a thin wrapper over it) — against a REAL grammy
19
+ * `Bot` whose HTTP client is an injected `fetch`. Every assertion is made on
20
+ * the JSON body grammy serialized for `POST /botTOKEN/sendRichMessage`.
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest'
24
+ import { Bot, type Context } from 'grammy'
25
+ import { sendReply, type SendReplyGatewayDeps } from '../gateway/outbound-send-path.js'
26
+ import { handleStreamReply, type StreamReplyDeps } from '../stream-reply-handler.js'
27
+ import type { StreamBotApi } from '../stream-controller.js'
28
+ import type { DraftStreamHandle } from '../draft-stream.js'
29
+ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
30
+ import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
31
+ import { VoiceOnDemandCache } from '../voice-ondemand.js'
32
+ import { PreSynthQueue } from '../voice-presynth.js'
33
+ import type { Access } from '../gateway/gateway.js'
34
+
35
+ const CHAT = '5550001'
36
+
37
+ type WireCall = { method: string; payload: Record<string, unknown> }
38
+
39
+ /** Scripted Telegram error for one wire method. Fires `times` times (default 1),
40
+ * then that method succeeds. `times` matters for the body-error cases: a real
41
+ * unparseable body fails EVERY time it is re-sent to the same method, so a
42
+ * fire-once script would let a wrong fallback branch pass by accident. */
43
+ type ScriptedError = { method: string; description: string; times?: number }
44
+
45
+ /** A REAL grammy Bot whose HTTP client is an injected fetch — every call is
46
+ * captured as the JSON body grammy actually serialized for the Bot API. */
47
+ function makeWireBot(opts: { errors?: ScriptedError[] } = {}) {
48
+ const calls: WireCall[] = []
49
+ const errors = [...(opts.errors ?? [])]
50
+ let nextMessageId = 9000
51
+
52
+ const fetchImpl = (async (input: unknown, init?: { body?: unknown }) => {
53
+ const url = String(input)
54
+ const method = url.slice(url.lastIndexOf('/') + 1)
55
+ const body = typeof init?.body === 'string' ? init.body : '{}'
56
+ const payload = JSON.parse(body) as Record<string, unknown>
57
+ calls.push({ method, payload })
58
+ const scripted = errors.findIndex((e) => e.method === method)
59
+ if (scripted >= 0) {
60
+ const err = errors[scripted]
61
+ const remaining = (err.times ?? 1) - 1
62
+ if (remaining > 0) errors[scripted] = { ...err, times: remaining }
63
+ else errors.splice(scripted, 1)
64
+ return new Response(
65
+ JSON.stringify({ ok: false, error_code: 400, description: err.description }),
66
+ { status: 400, headers: { 'content-type': 'application/json' } },
67
+ )
68
+ }
69
+ return new Response(
70
+ JSON.stringify({
71
+ ok: true,
72
+ result: {
73
+ message_id: nextMessageId++,
74
+ date: Math.floor(Date.now() / 1000),
75
+ chat: { id: Number(CHAT), type: 'private' },
76
+ },
77
+ }),
78
+ { status: 200, headers: { 'content-type': 'application/json' } },
79
+ )
80
+ }) as unknown as typeof fetch
81
+
82
+ const bot = new Bot<Context>('123456:TEST-TOKEN', {
83
+ client: { fetch: fetchImpl },
84
+ botInfo: {
85
+ id: 123456,
86
+ is_bot: true,
87
+ first_name: 'test',
88
+ username: 'test_bot',
89
+ can_join_groups: false,
90
+ can_read_all_group_messages: false,
91
+ supports_inline_queries: false,
92
+ can_connect_to_business_account: false,
93
+ has_main_web_app: false,
94
+ },
95
+ })
96
+
97
+ const sends = () => calls.filter((c) => c.method.startsWith('send'))
98
+ const replyParams = (c: WireCall) => c.payload.reply_parameters as Record<string, unknown> | undefined
99
+ return { bot, calls, sends, replyParams }
100
+ }
101
+
102
+ function makeHarness(opts: { errors?: ScriptedError[] } = {}) {
103
+ const wire = makeWireBot(opts)
104
+ const bot = wire.bot
105
+
106
+ const access: Access = {
107
+ dmPolicy: 'allowlist',
108
+ allowFrom: [CHAT],
109
+ groups: {},
110
+ pending: {},
111
+ parseMode: 'html',
112
+ historyEnabled: false,
113
+ }
114
+
115
+ const noop = () => {}
116
+ const deps: SendReplyGatewayDeps = {
117
+ outboundDedup: new OutboundDedupCache(),
118
+ flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
119
+ firstTextReplyLogged: new Set<string>(),
120
+ suppressPtyPreview: new Set<string>(),
121
+ activeDraftStreams: new Map(),
122
+ lastPtyPreviewByChat: new Map(),
123
+ voiceOnDemandCache: new VoiceOnDemandCache(),
124
+ voicePreSynthQueue: new PreSynthQueue({ runJob: async () => {} }),
125
+ pendingProgress: { clearPending: noop, noteOutbound: noop },
126
+ signalTracker: { noteOutbound: noop, noteSignal: noop },
127
+ silencePoke: { noteOutbound: noop },
128
+ getCurrentTurn: () => null,
129
+ getLastActiveTurnChatId: () => undefined,
130
+ HISTORY_ENABLED: false,
131
+ TURN_ORIGIN_ROUTING_ENABLED: false,
132
+ AUTOCLASSIFY_MIDTURN_SHADOW: false,
133
+ MAX_ATTACHMENT_BYTES: 50 * 1024 * 1024,
134
+ MAX_CHUNK_LIMIT: 4000,
135
+ PHOTO_EXTS: new Set(['.png', '.jpg']),
136
+ lockedBot: bot,
137
+ robustApiCall: <T>(fn: () => Promise<T>) => fn(),
138
+ swallowingApiCall: async (fn: () => Promise<unknown>) => {
139
+ try {
140
+ return await fn()
141
+ } catch {
142
+ return undefined
143
+ }
144
+ },
145
+ loadAccess: () => access,
146
+ redactOutboundText: (t: string) => t,
147
+ assertAllowedChat: noop,
148
+ assertSendable: noop,
149
+ statusKey: (chatId: string, threadId?: number | null) => `${chatId}:${threadId ?? ''}`,
150
+ streamKey: (chatId: string, threadId?: number | null) => `${chatId}:${threadId ?? ''}`,
151
+ resolveReplyOwnerTurn: () => ({
152
+ turn: null,
153
+ tier: 'none',
154
+ candidates: {
155
+ liveTurnId: null,
156
+ originTurnId: null,
157
+ quotedTurnId: null,
158
+ latestEndedTurnId: null,
159
+ },
160
+ }),
161
+ findTurnByOriginId: () => null,
162
+ findTurnByQuotedMessageId: () => null,
163
+ resolveAnswerThreadWithLog: () => undefined,
164
+ resolveThreadId: () => undefined,
165
+ getLatestInboundMessageId: () => null,
166
+ getLastSubagentHandbackAt: () => null,
167
+ recordOutbound: noop,
168
+ emissionAuthorityFor: () =>
169
+ ({
170
+ claimOrDowngradePing: () => ({ allowed: true, ping: true }),
171
+ }) as never,
172
+ clearActivitySummary: noop,
173
+ startTypingLoop: noop,
174
+ stopTypingLoop: noop,
175
+ logOutbound: noop,
176
+ closeObligationOnSubstantiveReply: noop,
177
+ finalizeStatusReaction: noop,
178
+ releaseTurnBufferGate: noop,
179
+ reapQueuedStatus: noop,
180
+ noteAgentOutputAt: noop,
181
+ rememberAgentButtonMeta: noop,
182
+ resolveVoiceOutPlan: () => null,
183
+ synthesizeVoiceOut: async () => null,
184
+ publishToTelegraph: async () => null,
185
+ clearSilentEndState: noop,
186
+ emitRuntimeMetric: noop,
187
+ shadowEmit: noop,
188
+ progressDriver: null,
189
+ }
190
+
191
+ return { ...wire, deps }
192
+ }
193
+
194
+ /** Drive the REAL `stream_reply` handler against the same wire-capturing bot. */
195
+ function makeStreamHarness(opts: { errors?: ScriptedError[] } = {}) {
196
+ const wire = makeWireBot(opts)
197
+ const noop = () => {}
198
+ const deps: StreamReplyDeps = {
199
+ bot: wire.bot as unknown as { api: StreamBotApi },
200
+ retry: <T>(fn: () => Promise<T>) => fn(),
201
+ repairEscapedWhitespace: (t: string) => t,
202
+ assertAllowedChat: noop,
203
+ resolveThreadId: () => undefined,
204
+ disableLinkPreview: true,
205
+ defaultFormat: 'html',
206
+ logStreamingEvent: noop,
207
+ historyEnabled: false,
208
+ recordOutbound: noop,
209
+ writeError: noop,
210
+ throttleMs: 0,
211
+ }
212
+ const state = {
213
+ activeDraftStreams: new Map<string, DraftStreamHandle>(),
214
+ suppressPtyPreview: new Set<string>(),
215
+ lastPtyPreviewByChat: new Map<string, string>(),
216
+ }
217
+ return { ...wire, deps, state }
218
+ }
219
+
220
+ describe('quote_text lands on the wire as ReplyParameters.quote (a String)', () => {
221
+ it('sends reply_parameters.quote as a string and resolves delivered', async () => {
222
+ const h = makeHarness()
223
+ const res = await sendReply(h.deps, {
224
+ args: {
225
+ chat_id: CHAT,
226
+ text: 'Yes — that line is the one that breaks.',
227
+ reply_to: 4242,
228
+ quote_text: 'the position is a separate field',
229
+ },
230
+ turn: null,
231
+ })
232
+
233
+ const sends = h.sends()
234
+ expect(sends.length).toBeGreaterThan(0)
235
+ const rp = h.replyParams(sends[0])
236
+ expect(rp).toBeDefined()
237
+ expect(rp!.message_id).toBe(4242)
238
+ // The bug: `quote` was an OBJECT (`{ text, position }`). Telegram requires
239
+ // a String, and rejected every such send with 400.
240
+ expect(typeof rp!.quote).toBe('string')
241
+ expect(rp!.quote).toBe('the position is a separate field')
242
+ // No fabricated position — Telegram locates the quote itself, and neither
243
+ // send site has a real position source.
244
+ expect(rp).not.toHaveProperty('quote_position')
245
+ expect(rp).not.toHaveProperty('quote_parse_mode')
246
+ // Delivered: the send resolved and the tool result is not a failure notice.
247
+ const out = res.content[0]?.text ?? ''
248
+ expect(out).not.toMatch(/fail/i)
249
+ expect(out).toMatch(/sent/i)
250
+ })
251
+
252
+ it('is NOT html-escaped — the quote must stay an exact substring of the original', async () => {
253
+ const h = makeHarness()
254
+ await sendReply(h.deps, {
255
+ args: {
256
+ chat_id: CHAT,
257
+ text: 'Checked.',
258
+ reply_to: 77,
259
+ quote_text: 'tom & jerry <b>not a tag</b>',
260
+ },
261
+ turn: null,
262
+ })
263
+ const rp = h.replyParams(h.sends()[0])!
264
+ expect(rp.quote).toBe('tom & jerry <b>not a tag</b>')
265
+ })
266
+
267
+ it('a quote Telegram cannot find degrades to an unquoted reply instead of losing the answer', async () => {
268
+ const h = makeHarness({
269
+ errors: [
270
+ { method: 'sendRichMessage', description: 'Bad Request: QUOTE_TEXT_INVALID' },
271
+ ],
272
+ })
273
+ const res = await sendReply(h.deps, {
274
+ args: {
275
+ chat_id: CHAT,
276
+ text: 'The answer still has to land.',
277
+ reply_to: 4242,
278
+ quote_text: 'a paraphrase that is not in the original',
279
+ },
280
+ turn: null,
281
+ })
282
+
283
+ const sends = h.sends()
284
+ expect(sends.length).toBe(2)
285
+ // First attempt carried the quote and was rejected …
286
+ expect(h.replyParams(sends[0])!.quote).toBe('a paraphrase that is not in the original')
287
+ // … the retry keeps the reply target and drops only the highlight.
288
+ const retry = h.replyParams(sends[1])!
289
+ expect(retry.message_id).toBe(4242)
290
+ expect(retry).not.toHaveProperty('quote')
291
+ expect(res.content[0]?.text ?? '').toMatch(/sent/i)
292
+ })
293
+
294
+ // A `<blockquote>` parse failure is a BODY error whose description happens to
295
+ // contain the substring "quote". If the quote classifier claims it, the ladder
296
+ // takes the quote branch, re-sends the SAME unparseable body without the
297
+ // quote, hits the same 400 with no secondary handling, and throws — so the
298
+ // pre-existing plain-text rescue never runs and the composed answer is lost.
299
+ // This is an outcome test on purpose: asserting the classifier returns false
300
+ // in isolation would not have caught it, because the loss happens in the
301
+ // ladder, not in the predicate.
302
+ for (const description of [
303
+ 'Bad Request: can\'t parse entities: Can\'t find end tag corresponding to start tag "blockquote"',
304
+ 'Bad Request: can\'t parse entities: Unsupported start tag "blockquote"',
305
+ ]) {
306
+ it(`a blockquote parse error on a QUOTED reply still reaches the plain-text rescue (${description.slice(-24)})`, async () => {
307
+ const h = makeHarness({
308
+ // Fails every time it is attempted — a body Telegram cannot parse does
309
+ // not start parsing on the second try.
310
+ errors: [{ method: 'sendRichMessage', description, times: 5 }],
311
+ })
312
+
313
+ const res = await sendReply(h.deps, {
314
+ args: {
315
+ chat_id: CHAT,
316
+ text: 'The answer must survive a blockquote parse error.',
317
+ reply_to: 4242,
318
+ quote_text: 'a real substring of the original',
319
+ },
320
+ turn: null,
321
+ })
322
+
323
+ const sends = h.sends()
324
+ // The rich attempt failed; the rescue must have gone out over a DIFFERENT
325
+ // method (plain text, no rich wrapper ⇒ the entity parser never runs).
326
+ const rescue = sends.filter((c) => c.method !== 'sendRichMessage')
327
+ expect(rescue.length).toBeGreaterThan(0)
328
+ // The answer actually landed, with its body intact.
329
+ expect(String(rescue[0].payload.text ?? '')).toContain(
330
+ 'The answer must survive a blockquote parse error.',
331
+ )
332
+ expect(res.content[0]?.text ?? '').toMatch(/sent/i)
333
+ // And the quote branch was never taken: no second rich send stripped of
334
+ // the quote (that path is what swallowed the answer).
335
+ const richSends = sends.filter((c) => c.method === 'sendRichMessage')
336
+ expect(richSends.length).toBe(1)
337
+ })
338
+ }
339
+
340
+ it('omits quote entirely when quote_text is absent (plain reply unchanged)', async () => {
341
+ const h = makeHarness()
342
+ await sendReply(h.deps, {
343
+ args: { chat_id: CHAT, text: 'plain reply', reply_to: 12 },
344
+ turn: null,
345
+ })
346
+ const rp = h.replyParams(h.sends()[0])!
347
+ expect(rp).toEqual({ message_id: 12 })
348
+ })
349
+
350
+ it('stream_reply carries the same String shape to the wire', async () => {
351
+ const h = makeStreamHarness()
352
+ await handleStreamReply(
353
+ {
354
+ chat_id: CHAT,
355
+ text: 'streamed answer',
356
+ done: true,
357
+ reply_to: '881',
358
+ quote_text: 'exactly this fragment',
359
+ },
360
+ h.state,
361
+ h.deps,
362
+ )
363
+ const sends = h.sends()
364
+ expect(sends.length).toBeGreaterThan(0)
365
+ const rp = h.replyParams(sends[0])!
366
+ expect(rp.message_id).toBe(881)
367
+ expect(typeof rp.quote).toBe('string')
368
+ expect(rp.quote).toBe('exactly this fragment')
369
+ expect(rp).not.toHaveProperty('quote_position')
370
+ })
371
+
372
+ it('stream_reply drops an unfindable quote and still delivers the message', async () => {
373
+ const h = makeStreamHarness({
374
+ errors: [
375
+ {
376
+ method: 'sendRichMessage',
377
+ description: 'Bad Request: message quote not found in the original message',
378
+ },
379
+ ],
380
+ })
381
+ const res = await handleStreamReply(
382
+ {
383
+ chat_id: CHAT,
384
+ text: 'streamed answer',
385
+ done: true,
386
+ reply_to: '881',
387
+ quote_text: 'not present in the original',
388
+ },
389
+ h.state,
390
+ h.deps,
391
+ )
392
+ const sends = h.sends()
393
+ expect(sends.length).toBe(2)
394
+ expect(h.replyParams(sends[0])!.quote).toBe('not present in the original')
395
+ expect(h.replyParams(sends[1])!).toEqual({ message_id: 881 })
396
+ expect(res.messageId).not.toBeNull()
397
+ })
398
+
399
+ it('truncates an over-long quote to the Bot API 1024-char cap (a prefix is still an exact substring)', async () => {
400
+ const h = makeHarness()
401
+ const long = 'x'.repeat(1500)
402
+ await sendReply(h.deps, {
403
+ args: { chat_id: CHAT, text: 'ok', reply_to: 5, quote_text: long },
404
+ turn: null,
405
+ })
406
+ const rp = h.replyParams(h.sends()[0])!
407
+ expect((rp.quote as string).length).toBe(1024)
408
+ expect(long.startsWith(rp.quote as string)).toBe(true)
409
+ })
410
+
411
+ it('never cuts an astral character in half at the 1024 boundary', async () => {
412
+ const h = makeHarness()
413
+ // '𝄞' is one astral char = TWO UTF-16 units. 1023 filler units put its high
414
+ // surrogate at index 1023 and its low surrogate at 1024, so a naive
415
+ // slice(0, 1024) would emit a LONE HIGH SURROGATE — not valid UTF-8 on the
416
+ // wire, and no longer a substring of anything.
417
+ const long = 'x'.repeat(1023) + '𝄞' + 'y'.repeat(200)
418
+ await sendReply(h.deps, {
419
+ args: { chat_id: CHAT, text: 'ok', reply_to: 5, quote_text: long },
420
+ turn: null,
421
+ })
422
+ const quote = h.replyParams(h.sends()[0])!.quote as string
423
+ // Cut back to 1023 rather than splitting the pair …
424
+ expect(quote.length).toBe(1023)
425
+ // … so no unpaired surrogate survives …
426
+ for (const ch of quote) expect(ch.codePointAt(0)! >= 0xd800 && ch.codePointAt(0)! <= 0xdfff).toBe(false)
427
+ // … and it round-trips through UTF-8 unchanged (a lone surrogate would be
428
+ // replaced by U+FFFD here).
429
+ expect(Buffer.from(quote, 'utf8').toString('utf8')).toBe(quote)
430
+ // … and it is still an exact prefix of the original.
431
+ expect(long.startsWith(quote)).toBe(true)
432
+ })
433
+ })