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.
- package/dist/agent-scheduler/index.js +1 -1
- package/dist/auth-broker/index.js +32 -7
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +1187 -669
- package/dist/host-control/main.js +205 -28
- package/dist/vault/approvals/kernel-server.js +32 -7
- package/dist/vault/broker/server.js +32 -7
- 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 +1225 -614
- 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/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +49 -51
- 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/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 +5 -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/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- 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
|
@@ -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
|
+
})
|