switchroom 0.21.3 → 0.21.5

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.
@@ -25,6 +25,10 @@
25
25
  * partial quote) over the full parent `.text`.
26
26
  * - `buildInboundEnvelope` emits `reply_to_role` when known and `reply_to_text`
27
27
  * from the recovered escaped form.
28
+ * - `buildReplyForwardContext` reads a RICH parent's body off
29
+ * `reply_to_message.rich_message` LIVE (#4598) — the only path that can
30
+ * resolve a card the buffer never recorded. The last describe block pins
31
+ * that, including a killer case a buffer-only implementation must fail.
28
32
  *
29
33
  * The persisted-row-non-NULL half of the 1a contract (which needs a real
30
34
  * bun:sqlite history.db) lives in reply-to-buffer-history.test.ts (bun).
@@ -98,20 +102,32 @@ describe('resolveReplyToFromBuffer — reply-to buffer fallback (1a)', () => {
98
102
  })
99
103
 
100
104
  it('does NOT overwrite a non-empty LIVE reply text (reply to a person, or a partial quote)', () => {
101
- const lookup = () => {
102
- throw new Error('lookup must not be called when live text is present')
103
- }
104
105
  const out = resolveReplyToFromBuffer({
105
106
  replyToMessageId: 9,
106
107
  replyToText: 'live raw text',
107
108
  replyToTextEscaped: 'live escaped text',
108
109
  historyEnabled: true,
109
110
  replyToTextMax: REPLY_TO_TEXT_MAX,
110
- lookup,
111
+ lookup: () => ({ role: 'user', text: 'STALE BUFFER TEXT' }),
111
112
  })
112
113
  expect(out.replyToText).toBe('live raw text')
113
114
  expect(out.replyToTextEscaped).toBe('live escaped text')
115
+ // …but the lookup still supplies the ROLE, which has no live-update source.
116
+ expect(out.replyToRole).toBe('user')
117
+ })
118
+
119
+ it('a missing row does not clobber a live reply text', () => {
120
+ const out = resolveReplyToFromBuffer({
121
+ replyToMessageId: 9,
122
+ replyToText: 'live raw text',
123
+ replyToTextEscaped: 'live escaped text',
124
+ historyEnabled: true,
125
+ replyToTextMax: REPLY_TO_TEXT_MAX,
126
+ lookup: () => null,
127
+ })
128
+ expect(out.replyToText).toBe('live raw text')
114
129
  expect(out.replyToRole).toBeUndefined()
130
+ expect(out.replyToKind).toBeUndefined()
115
131
  })
116
132
 
117
133
  it('history-disabled guard: never calls lookup, degrades to id-only, does not throw', () => {
@@ -208,6 +224,195 @@ describe('buildReplyForwardContext — native partial-quote preference (2a)', ()
208
224
  })
209
225
  })
210
226
 
227
+ /**
228
+ * Rich-message parents (#4598).
229
+ *
230
+ * Every card the gateway posts ships via Bot API 10.1 `sendRichMessage`, so a
231
+ * native reply to one delivers a `reply_to_message` with `rich_message.blocks`
232
+ * populated and `text` / `caption` ABSENT. Measured on the wire against a real
233
+ * bot: keys `[message_id, from, chat, date, rich_message]`.
234
+ *
235
+ * Before #4598 the body of a card antecedent was 100% buffer-sourced, so a
236
+ * card that never made it into `history.db` — posted while the gateway was
237
+ * down, or through a send path that bypassed the recording chokepoint — was
238
+ * permanently unresolvable no matter how the recording side was fixed. These
239
+ * pin the LIVE read.
240
+ */
241
+ describe('buildReplyForwardContext — rich_message parent (#4598)', () => {
242
+ /** The shape Telegram actually delivers for a reply to a card. */
243
+ function richParent(message_id: number) {
244
+ return {
245
+ message_id,
246
+ rich_message: {
247
+ blocks: [
248
+ { type: 'paragraph', text: [{ type: 'bold', text: 'Usage' }, ' this week'] },
249
+ { type: 'paragraph', text: 'Opus 41% then Sonnet 12%' },
250
+ ],
251
+ },
252
+ }
253
+ }
254
+
255
+ const RENDERED = 'Usage this week\nOpus 41% then Sonnet 12%'
256
+
257
+ it('reads the parent body off rich_message when text and caption are absent', () => {
258
+ const ctx = makeCtx({ reply_to_message: richParent(9938) })
259
+ const out = buildReplyForwardContext({
260
+ ctx,
261
+ coalescedForwardOrigins: undefined,
262
+ replyToTextMax: REPLY_TO_TEXT_MAX,
263
+ })
264
+ expect(out.replyToMessageId).toBe(9938)
265
+ expect(out.replyToText).toBe(RENDERED)
266
+ expect(out.replyToTextEscaped).toBe(RENDERED)
267
+ })
268
+
269
+ it('truncates a long rich body to the cap, like every other antecedent', () => {
270
+ const ctx = makeCtx({
271
+ reply_to_message: {
272
+ message_id: 1,
273
+ rich_message: { blocks: [{ type: 'paragraph', text: 'x'.repeat(400) }] },
274
+ },
275
+ })
276
+ const out = buildReplyForwardContext({
277
+ ctx,
278
+ coalescedForwardOrigins: undefined,
279
+ replyToTextMax: REPLY_TO_TEXT_MAX,
280
+ })
281
+ expect(out.replyToText).toHaveLength(REPLY_TO_TEXT_MAX)
282
+ expect(out.replyToText?.endsWith('…')).toBe(true)
283
+ })
284
+
285
+ it('yields undefined (not empty string) for an unrenderable block tree, so the buffer still runs', () => {
286
+ // A thinking-only / media-stripped card renders to nothing. It must fall
287
+ // THROUGH to the buffer rather than pinning the antecedent to ''.
288
+ const ctx = makeCtx({
289
+ reply_to_message: { message_id: 5, rich_message: { blocks: [{ type: 'thinking' }] } },
290
+ })
291
+ const live = buildReplyForwardContext({
292
+ ctx,
293
+ coalescedForwardOrigins: undefined,
294
+ replyToTextMax: REPLY_TO_TEXT_MAX,
295
+ })
296
+ expect(live.replyToText).toBeUndefined()
297
+ expect(live.replyToTextEscaped).toBeUndefined()
298
+
299
+ const out = resolveReplyToFromBuffer({
300
+ replyToMessageId: live.replyToMessageId,
301
+ replyToText: live.replyToText,
302
+ replyToTextEscaped: live.replyToTextEscaped,
303
+ historyEnabled: true,
304
+ replyToTextMax: REPLY_TO_TEXT_MAX,
305
+ lookup: () => ({ role: 'system', text: 'stored card body', kind: 'activity-summary' }),
306
+ })
307
+ expect(out.replyToText).toBe('stored card body')
308
+ expect(out.replyToKind).toBe('activity-summary')
309
+ })
310
+
311
+ it('prefers a native partial quote over the rich parent body', () => {
312
+ const ctx = makeCtx({
313
+ reply_to_message: richParent(7),
314
+ quote: { text: 'Opus 41%', position: 5, is_manual: true },
315
+ })
316
+ const out = buildReplyForwardContext({
317
+ ctx,
318
+ coalescedForwardOrigins: undefined,
319
+ replyToTextMax: REPLY_TO_TEXT_MAX,
320
+ })
321
+ expect(out.replyToText).toBe('Opus 41%')
322
+ })
323
+
324
+ it('THE BUFFER-ONLY KILLER: the live rich body wins over a DIFFERENT stored row', () => {
325
+ // A buffer-only implementation resolves this reply from the stored row and
326
+ // returns the STALE text — which is the point: this is the one test in the
327
+ // file that a recording-side-only fix cannot pass. The lookup DOES run
328
+ // (role/kind have no live source, see the next test) but it must not be
329
+ // allowed to win the text, so the stub deliberately returns a different
330
+ // body from the one on the wire.
331
+ const ctx = makeCtx({ reply_to_message: richParent(9925) })
332
+ const live = buildReplyForwardContext({
333
+ ctx,
334
+ coalescedForwardOrigins: undefined,
335
+ replyToTextMax: REPLY_TO_TEXT_MAX,
336
+ })
337
+
338
+ let lookupCalls = 0
339
+ const out = resolveReplyToFromBuffer({
340
+ replyToMessageId: live.replyToMessageId,
341
+ replyToText: live.replyToText,
342
+ replyToTextEscaped: live.replyToTextEscaped,
343
+ historyEnabled: true,
344
+ replyToTextMax: REPLY_TO_TEXT_MAX,
345
+ lookup: () => {
346
+ lookupCalls++
347
+ return { role: 'system', text: 'STALE BUFFER TEXT', kind: 'usage-card' }
348
+ },
349
+ })
350
+
351
+ expect(out.replyToText).toBe(RENDERED)
352
+ expect(out.replyToTextEscaped).toBe(RENDERED)
353
+ expect(out.replyToText).not.toContain('STALE BUFFER TEXT')
354
+ // The row was consulted — for role/kind only, never for the body.
355
+ expect(lookupCalls).toBe(1)
356
+ })
357
+
358
+ it('a live-resolved rich body still carries reply_to_role AND reply_to_kind through the envelope', () => {
359
+ // The regression the `liveTextEmpty` gate introduced: an operator FULL-
360
+ // replies (no partial quote) to a live activity card whose row IS in
361
+ // history.db. The body now resolves live off `rich_message` — and if that
362
+ // short-circuits the lookup, the envelope loses `reply_to_role="system"`
363
+ // and `reply_to_kind="activity-summary"` entirely, killing the #4571 kind
364
+ // lane for exactly the case it was built for.
365
+ const ctx = makeCtx({ reply_to_message: richParent(9925) })
366
+ const live = buildReplyForwardContext({
367
+ ctx,
368
+ coalescedForwardOrigins: undefined,
369
+ replyToTextMax: REPLY_TO_TEXT_MAX,
370
+ })
371
+ const resolved = resolveReplyToFromBuffer({
372
+ replyToMessageId: live.replyToMessageId,
373
+ replyToText: live.replyToText,
374
+ replyToTextEscaped: live.replyToTextEscaped,
375
+ historyEnabled: true,
376
+ replyToTextMax: REPLY_TO_TEXT_MAX,
377
+ // The row IS recorded — same card, stored body.
378
+ lookup: () => ({ role: 'system', text: RENDERED, kind: 'activity-summary' }),
379
+ })
380
+ expect(resolved.replyToRole).toBe('system')
381
+ expect(resolved.replyToKind).toBe('activity-summary')
382
+
383
+ const msg = buildInboundEnvelope(
384
+ makeEnvelopeParams({
385
+ ctx,
386
+ replyToMessageId: live.replyToMessageId,
387
+ replyToTextEscaped: resolved.replyToTextEscaped,
388
+ replyToRole: resolved.replyToRole,
389
+ replyToKind: resolved.replyToKind,
390
+ }),
391
+ )
392
+ expect(msg.meta?.reply_to_text).toBe(RENDERED)
393
+ expect(msg.meta?.reply_to_role).toBe('system')
394
+ expect(msg.meta?.reply_to_kind).toBe('activity-summary')
395
+ })
396
+
397
+ it('carries the live rich body through to the inbound envelope', () => {
398
+ const ctx = makeCtx({ reply_to_message: richParent(9925) })
399
+ const live = buildReplyForwardContext({
400
+ ctx,
401
+ coalescedForwardOrigins: undefined,
402
+ replyToTextMax: REPLY_TO_TEXT_MAX,
403
+ })
404
+ const msg = buildInboundEnvelope(
405
+ makeEnvelopeParams({
406
+ ctx,
407
+ replyToMessageId: live.replyToMessageId,
408
+ replyToTextEscaped: live.replyToTextEscaped,
409
+ }),
410
+ )
411
+ expect(msg.meta?.reply_to_message_id).toBe('9925')
412
+ expect(msg.meta?.reply_to_text).toBe(RENDERED)
413
+ })
414
+ })
415
+
211
416
  function makeEnvelopeParams(overrides: Partial<EnvelopeBuildParams>): EnvelopeBuildParams {
212
417
  return {
213
418
  ctx: makeCtx({}),
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * system-message-observer — the card-lane recorder's pure behaviour (#4571).
3
3
  *
4
- * The observer hangs off gateway.ts's single `robustApiCall` chokepoint and
4
+ * The observer hangs off the grammy API transformer seam, installed at bot
5
+ * construction (#4599 moved it there from gateway.ts's `robustApiCall`, which
6
+ * the `ctx.replyWithRichMessage` slash-command path bypassed entirely), and
5
7
  * turns every card the gateway posts (activity summary, status pin, approval /
6
8
  * boot / issues cards, progress lines) into ONE resolvable history row. The
7
9
  * two properties that make it safe to run on the hot send path are asserted
@@ -12,12 +14,20 @@
12
14
  * 2. an id that turns out to belong to a real reply / inbound is demoted to
13
15
  * `foreign` and never written to again.
14
16
  *
17
+ * Both install the observer in their OWN harness bot, so a third block at the
18
+ * bottom of this file pins the single PRODUCTION install line in
19
+ * `initGatewayBot()` — see its docblock.
20
+ *
15
21
  * The end-to-end proof (card posted → id resolvable → a reply pointing at it
16
22
  * is understood) needs a real bun:sqlite history.db and lives in
17
23
  * card-history-lane.test.ts (bun).
18
24
  */
19
25
 
20
26
  import { describe, it, expect } from 'vitest'
27
+ import { readFileSync } from 'node:fs'
28
+ import { fileURLToPath } from 'node:url'
29
+ import { dirname, resolve } from 'node:path'
30
+ import ts from 'typescript'
21
31
  import {
22
32
  makeSystemMessageObserver,
23
33
  normalizeSendVerb,
@@ -223,3 +233,76 @@ describe('makeSystemMessageObserver', () => {
223
233
  expect(store.rows.get(1)?.text).toBe('card 1 edited')
224
234
  })
225
235
  })
236
+
237
+ /**
238
+ * Boot-wiring pin (#4599).
239
+ *
240
+ * Every behaviour test above installs the observer in its OWN harness, so
241
+ * deleting the single production install line in `initGatewayBot()` leaves all
242
+ * of them green while ALL card recording silently vanishes — worse than
243
+ * pre-#4571, because the old `robustApiCall` hook is gone and the empty-body
244
+ * alarm now lives INSIDE the observer, so nothing would fire either. Grammy's
245
+ * installed transformers are anonymous fns with nothing to grip at runtime, so
246
+ * this is a source-level AST assertion on the boot path — the same approach
247
+ * `format-guard-pins.test.ts` uses for `installRichMarkdownGuard`.
248
+ */
249
+ const __dirname = dirname(fileURLToPath(import.meta.url))
250
+ const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
251
+ const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
252
+ const gatewaySource = ts.createSourceFile(
253
+ GATEWAY_PATH,
254
+ GATEWAY_SRC,
255
+ ts.ScriptTarget.Latest,
256
+ true,
257
+ ts.ScriptKind.TS,
258
+ )
259
+
260
+ function findFunction(name: string): ts.FunctionDeclaration | undefined {
261
+ for (const s of gatewaySource.statements) {
262
+ if (ts.isFunctionDeclaration(s) && s.name?.text === name) return s
263
+ }
264
+ return undefined
265
+ }
266
+
267
+ function countCallsTo(root: ts.Node, name: string): number {
268
+ let count = 0
269
+ const visit = (node: ts.Node): void => {
270
+ if (
271
+ ts.isCallExpression(node) &&
272
+ ts.isIdentifier(node.expression) &&
273
+ node.expression.text === name
274
+ ) {
275
+ count++
276
+ // Installed on the constructed bot instance, with the real observer.
277
+ expect(node.arguments[0]?.getText(gatewaySource)).toBe('bot')
278
+ expect(node.arguments[1]?.getText(gatewaySource)).toBe('observeSentMessage')
279
+ }
280
+ ts.forEachChild(node, visit)
281
+ }
282
+ visit(root)
283
+ return count
284
+ }
285
+
286
+ describe('boot wiring: installSystemMessageObserver is installed on the production Bot', () => {
287
+ it('imports installSystemMessageObserver from ../shared/bot-runtime.js', () => {
288
+ // The import must exist for the boot call to resolve; a refactor that drops
289
+ // the import would break the seam.
290
+ expect(GATEWAY_SRC).toMatch(
291
+ /import\s*\{[^}]*\binstallSystemMessageObserver\b[^}]*\}\s*from\s*'\.\.\/shared\/bot-runtime\.js'/,
292
+ )
293
+ })
294
+
295
+ it('calls installSystemMessageObserver(bot, observeSentMessage) exactly once inside initGatewayBot()', () => {
296
+ const fn = findFunction('initGatewayBot')
297
+ expect(fn?.body).toBeDefined()
298
+ expect(countCallsTo(fn!.body!, 'installSystemMessageObserver')).toBe(1)
299
+ })
300
+
301
+ it('builds the production observer from the real history writers', () => {
302
+ // The install is conditional on `observeSentMessage`; pin what fills it, or
303
+ // the line above could survive against a permanently-undefined observer.
304
+ expect(GATEWAY_SRC).toMatch(
305
+ /const observeSentMessage = isGatewayMain && HISTORY_ENABLED\s*\n?\s*\?\s*makeSystemMessageObserver\(/,
306
+ )
307
+ })
308
+ })