switchroom 0.20.22 → 0.21.1

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,8 +25,9 @@
25
25
  * chat exists in the DB.
26
26
  */
27
27
 
28
- import { chmodSync, existsSync, mkdirSync } from 'fs'
28
+ import { chmodSync, existsSync } from 'fs'
29
29
  import { join } from 'path'
30
+ import { adoptSqliteOwnership, mkdirStateSync } from '../src/util/state-owner.js'
30
31
  import { redact } from './secret-detect/redact.js'
31
32
 
32
33
  /**
@@ -81,7 +82,22 @@ function loadDatabaseClass(): SqliteDatabaseConstructor {
81
82
  }
82
83
  }
83
84
 
84
- export type MessageRole = 'user' | 'assistant'
85
+ /**
86
+ * `system` is the CARD lane (#4571): a bot→user message the gateway posted as
87
+ * a UI surface rather than as an answer — the mid-turn activity card, the
88
+ * status pin, approval / boot / issues / worker-feed cards, restart notices,
89
+ * `progress_update` lines. Those consume real Telegram message ids, so the
90
+ * operator can (and does) quote-reply to one; before this lane existed the
91
+ * row simply did not exist and the reply resolved to nothing.
92
+ *
93
+ * They are deliberately NOT `assistant`: every delivery-accounting predicate
94
+ * in this file keys on `role = 'assistant'` meaning "the user was actually
95
+ * answered" (`getRecentOutboundCount`, `hasOutboundDeliveredSince`,
96
+ * `hasOutboundWithText`), and calling a card an answer would silently suppress
97
+ * the silence/over-ping/represent safety nets. A distinct value keeps every
98
+ * existing predicate byte-identical while making the id resolvable.
99
+ */
100
+ export type MessageRole = 'user' | 'assistant' | 'system'
85
101
 
86
102
  export interface RecordedMessage {
87
103
  chat_id: string
@@ -127,6 +143,14 @@ export interface RecordedMessage {
127
143
  forwarded_from_id: string | null
128
144
  forwarded_date: string | null
129
145
  forwarded_message_id: number | null
146
+ /**
147
+ * Discriminator for a `system` (card) row: the send's `verb` tag as it was
148
+ * passed to the gateway's retry wrapper, normalised (`activity-summary.send`
149
+ * → `activity-summary`). Null for `user` / `assistant` rows and for an
150
+ * untagged send. Surfaced to the agent as `reply_to_kind` when a native
151
+ * reply points at one of these messages.
152
+ */
153
+ kind: string | null
130
154
  }
131
155
 
132
156
  export interface QueryOptions {
@@ -134,6 +158,14 @@ export interface QueryOptions {
134
158
  thread_id?: number | null
135
159
  limit?: number
136
160
  before_message_id?: number
161
+ /**
162
+ * Include `system` (card) rows in the result. DEFAULT FALSE, deliberately:
163
+ * cards are ephemeral UI, and `get_recent_messages` renders straight into
164
+ * the operator-visible model context — a wall of activity-card rows there
165
+ * would be a regression. Card rows stay RESOLVABLE by id (see
166
+ * {@link lookupMessageRoleAndText}) without being LISTED.
167
+ */
168
+ include_system?: boolean
137
169
  }
138
170
 
139
171
  const DEFAULT_LIMIT = 10
@@ -185,7 +217,7 @@ function isValidMessageId(id: unknown): id is number {
185
217
  export function initHistory(stateDir: string, retentionDays = 30): void {
186
218
  if (db != null) return
187
219
  const Database = loadDatabaseClass()
188
- mkdirSync(stateDir, { recursive: true, mode: 0o700 })
220
+ mkdirStateSync(stateDir, { recursive: true, mode: 0o700 })
189
221
  const path = join(stateDir, 'history.db')
190
222
  dbPath = path
191
223
  db = new Database(path, { create: true })
@@ -212,6 +244,7 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
212
244
  group_id INTEGER,
213
245
  reply_to_message_id INTEGER,
214
246
  reply_to_text TEXT,
247
+ kind TEXT,
215
248
  PRIMARY KEY (chat_id, thread_id, message_id)
216
249
  )
217
250
  `)
@@ -232,6 +265,10 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
232
265
  "forwarded_from_id TEXT",
233
266
  "forwarded_date TEXT",
234
267
  "forwarded_message_id INTEGER",
268
+ // #4571 — card/system-surface discriminator. Nullable with no default, so
269
+ // the ALTER is instant on a multi-thousand-row live DB and every existing
270
+ // row keeps its exact contents (SQLite backfills NULL without a rewrite).
271
+ "kind TEXT",
235
272
  ]) {
236
273
  try {
237
274
  db.exec(`ALTER TABLE messages ADD COLUMN ${column}`)
@@ -300,6 +337,12 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
300
337
  const f = path + suffix
301
338
  if (existsSync(f)) { try { chmodSync(f, 0o644) } catch { /* ignore */ } }
302
339
  }
340
+ // Ownership rides the same hook as the mode, for the same reason. SQLite
341
+ // creates `-wal`/`-shm` itself, in C, so no `node:fs` write helper can ever
342
+ // see them — this is the only place we get to fix their owner. A root
343
+ // gateway would otherwise leave root:root DB files in an agent-owned state
344
+ // dir and EACCES every non-root reader. No-op off the root path.
345
+ adoptSqliteOwnership(path)
303
346
 
304
347
  if (retentionDays > 0) {
305
348
  const cutoff = Math.floor(Date.now() / 1000) - retentionDays * 86400
@@ -422,6 +465,9 @@ export function checkpointWal(): boolean {
422
465
  const f = dbPath + suffix
423
466
  if (existsSync(f)) { try { chmodSync(f, 0o644) } catch { /* ignore */ } }
424
467
  }
468
+ // …and ownership with it: a TRUNCATE checkpoint DELETES and RE-CREATES
469
+ // the sidecars, so the owner we set at init is gone by here.
470
+ adoptSqliteOwnership(dbPath)
425
471
  }
426
472
  return true
427
473
  } catch {
@@ -609,12 +655,29 @@ export function recordOutbound(args: RecordOutboundArgs): void {
609
655
  (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id)
610
656
  VALUES (?, ?, ?, 'assistant', NULL, NULL, ?, ?, ?, ?)
611
657
  `)
658
+ // #4571 — a real reply ALWAYS wins over a provisional card row.
659
+ //
660
+ // The system-lane observer (gateway `robustApiCall`) records every sent
661
+ // message the moment the API resolves, which is BEFORE the caller reaches
662
+ // its own `recordOutbound`. `INSERT OR REPLACE` alone would not reliably
663
+ // overwrite that row: the unique key folds `thread_id`, and the observer
664
+ // derives the thread from the Telegram response (which sets
665
+ // `message_thread_id` on reply chains in plain supergroups) while this
666
+ // writer uses the gateway's own `threadId` — a mismatch would leave BOTH
667
+ // rows and let the card row shadow the answer for id lookups. Deleting on
668
+ // (chat_id, message_id) — unique within a chat regardless of thread, the
669
+ // same assumption `recordEdit` / `recordReaction` already make — makes the
670
+ // promotion exact and is a no-op when no observer row exists.
671
+ const dropSystem = requireDb().prepare(
672
+ `DELETE FROM messages WHERE chat_id = ? AND message_id = ? AND role = 'system'`,
673
+ )
612
674
  // bun:sqlite has a transaction() helper. Cheap insurance against partial
613
675
  // writes if the process dies mid-loop. The transaction signature is
614
676
  // typed as variadic-unknown for genericity; cast the typed callback
615
677
  // through the wider shape.
616
678
  const tx = requireDb().transaction(((rows: Array<{ id: number; text: string; attachKind: string | null }>) => {
617
679
  for (const r of rows) {
680
+ dropSystem.run(args.chat_id, r.id)
618
681
  stmt.run(args.chat_id, args.thread_id ?? null, r.id, ts, r.text, r.attachKind, groupId)
619
682
  }
620
683
  }) as (...args: unknown[]) => unknown)
@@ -634,6 +697,97 @@ export function recordOutbound(args: RecordOutboundArgs): void {
634
697
  }
635
698
  }
636
699
 
700
+ export interface RecordSystemOutboundArgs {
701
+ chat_id: string
702
+ thread_id: number | null | undefined
703
+ message_id: number
704
+ /** Normalised send verb (`activity-summary`, `approval-card`, …) or null. */
705
+ kind: string | null | undefined
706
+ /** Rendered text as Telegram echoed it back. May be empty. */
707
+ text: string
708
+ /** Unix SECONDS. Defaults to now. */
709
+ ts?: number
710
+ }
711
+
712
+ /**
713
+ * Record a CARD / system-surface outbound (#4571).
714
+ *
715
+ * Conditional insert on (chat_id, message_id): if ANY row already exists for
716
+ * that id — a real `assistant` reply recorded by `recordOutbound`, an inbound,
717
+ * or a previously-recorded card — this is a no-op and returns false. That is
718
+ * what makes the call safe to fire from a blanket send observer that cannot
719
+ * distinguish a fresh send from an edit of a message it already knows: an edit
720
+ * returns the SAME `message_id`, hits the `NOT EXISTS` guard, and falls through
721
+ * to {@link updateSystemOutboundText} instead of duplicating a row per edit.
722
+ *
723
+ * Never throws — a card row is a nice-to-have, and a failure here must not
724
+ * break the send path it is observing. Failures are logged via `warnHistory`.
725
+ *
726
+ * Returns true iff a new row was inserted.
727
+ */
728
+ export function recordSystemOutbound(args: RecordSystemOutboundArgs): boolean {
729
+ if (!isValidMessageId(args.message_id)) return false
730
+ // History disabled / not yet initialised: stay silent. This is called from a
731
+ // blanket send observer, so a warn here would be one stderr line per API call.
732
+ if (db == null) return false
733
+ try {
734
+ const res = requireDb()
735
+ .prepare(`
736
+ INSERT INTO messages
737
+ (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, kind)
738
+ SELECT ?, ?, ?, 'system', NULL, NULL, ?, ?, NULL, NULL, ?
739
+ WHERE NOT EXISTS (
740
+ SELECT 1 FROM messages WHERE chat_id = ? AND message_id = ?
741
+ )
742
+ `)
743
+ .run(
744
+ args.chat_id,
745
+ args.thread_id ?? null,
746
+ args.message_id,
747
+ args.ts ?? Math.floor(Date.now() / 1000),
748
+ // Same outbound redaction chokepoint as recordOutbound: a card body is
749
+ // rendered from live tool activity and can quote a secret.
750
+ redact(args.text),
751
+ args.kind ?? null,
752
+ args.chat_id,
753
+ args.message_id,
754
+ ) as { changes?: number }
755
+ return (res?.changes ?? 0) > 0
756
+ } catch (err) {
757
+ warnHistory(
758
+ `recordSystemOutbound: INSERT failed (chat=${args.chat_id} id=${args.message_id}): ` +
759
+ `${err instanceof Error ? err.message : String(err)} — a posted card will not be ` +
760
+ `resolvable if the operator quote-replies to it`,
761
+ )
762
+ return false
763
+ }
764
+ }
765
+
766
+ /**
767
+ * Refresh a card row's stored text in place (#4571). Only ever touches a
768
+ * `system` row, so an edit of a real reply can never be rewritten through this
769
+ * path (`recordEdit` owns that).
770
+ *
771
+ * `ts` is deliberately NOT bumped: it is the card's POST time, which is what
772
+ * retention prunes on. Never throws. Returns true iff a row was updated.
773
+ */
774
+ export function updateSystemOutboundText(args: {
775
+ chat_id: string
776
+ message_id: number
777
+ text: string
778
+ }): boolean {
779
+ try {
780
+ const res = requireDb()
781
+ .prepare(
782
+ `UPDATE messages SET text = ? WHERE chat_id = ? AND message_id = ? AND role = 'system'`,
783
+ )
784
+ .run(redact(args.text), args.chat_id, args.message_id) as { changes?: number }
785
+ return (res?.changes ?? 0) > 0
786
+ } catch {
787
+ return false
788
+ }
789
+ }
790
+
637
791
  interface RecordEditArgs {
638
792
  chat_id: string
639
793
  message_id: number
@@ -781,16 +935,27 @@ export function getLatestInboundMessageId(
781
935
  export function lookupMessageRoleAndText(
782
936
  chatId: string,
783
937
  messageId: number,
784
- ): { role: 'user' | 'assistant'; text: string } | null {
785
- const row = requireDb()
786
- .prepare(
787
- `SELECT role, text FROM messages WHERE chat_id = ? AND message_id = ? LIMIT 1`,
788
- )
789
- .get(chatId, messageId) as
790
- | { role: 'user' | 'assistant'; text: string | null }
938
+ opts?: {
939
+ /**
940
+ * Also resolve `system` (card) rows. DEFAULT FALSE so the two
941
+ * authorship-sensitive callers the reaction trigger's
942
+ * `role === 'assistant'` bot-authored predicate and the
943
+ * `reaction_dispatch` preview — keep their exact pre-#4571 behaviour
944
+ * (a card was invisible to them, and still is). The reply-antecedent
945
+ * resolver opts IN: that is the whole point of the card lane.
946
+ */
947
+ includeSystem?: boolean
948
+ },
949
+ ): { role: MessageRole; text: string; kind: string | null } | null {
950
+ const sql =
951
+ `SELECT role, text, kind FROM messages WHERE chat_id = ? AND message_id = ?` +
952
+ (opts?.includeSystem === true ? '' : ` AND role <> 'system'`) +
953
+ ` LIMIT 1`
954
+ const row = requireDb().prepare(sql).get(chatId, messageId) as
955
+ | { role: MessageRole; text: string | null; kind: string | null }
791
956
  | undefined
792
957
  if (!row) return null
793
- return { role: row.role, text: row.text ?? '' }
958
+ return { role: row.role, text: row.text ?? '', kind: row.kind ?? null }
794
959
  }
795
960
 
796
961
  export function getRecentOutboundCount(
@@ -1007,6 +1172,8 @@ export function query(opts: QueryOptions): RecordedMessage[] {
1007
1172
  const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT))
1008
1173
  const params: unknown[] = [opts.chat_id]
1009
1174
  let sql = 'SELECT * FROM messages WHERE chat_id = ?'
1175
+ // #4571 — card rows are resolvable, not listable. See QueryOptions.include_system.
1176
+ if (opts.include_system !== true) sql += " AND role <> 'system'"
1010
1177
  if (opts.thread_id !== undefined) {
1011
1178
  if (opts.thread_id === null) {
1012
1179
  sql += ' AND thread_id IS NULL'
@@ -36,8 +36,9 @@
36
36
  * otherwise; the gateway then resumes or reports accordingly.
37
37
  */
38
38
 
39
- import { chmodSync, mkdirSync } from 'fs'
39
+ import { chmodSync } from 'fs'
40
40
  import { join } from 'path'
41
+ import { adoptSqliteOwnership, mkdirStateSync } from '../../src/util/state-owner.js'
41
42
 
42
43
  // ---------------------------------------------------------------------------
43
44
  // bun:sqlite lazy-loader (same pattern as history.ts)
@@ -302,7 +303,7 @@ function applySchema(db: SqliteDatabase): void {
302
303
  export function openTurnsDb(agentDir: string): SqliteDatabase {
303
304
  const Database = loadDatabaseClass()
304
305
  const dir = join(agentDir, 'telegram')
305
- mkdirSync(dir, { recursive: true, mode: 0o700 })
306
+ mkdirStateSync(dir, { recursive: true, mode: 0o700 })
306
307
  const path = join(dir, 'registry.db')
307
308
  const db = new Database(path, { create: true })
308
309
  applySchema(db)
@@ -318,6 +319,11 @@ export function openTurnsDb(agentDir: string): SqliteDatabase {
318
319
  } catch {
319
320
  /* ignore — chmod not supported on some FUSE mounts */
320
321
  }
322
+ // Same reasoning as the chmod above, for the owner rather than the mode:
323
+ // a root-running gateway must not leave root:root DB files in an
324
+ // agent-owned state dir. `-wal`/`-shm` are created by SQLite in C, so this
325
+ // hook is the only place we can reach them. No-op off the root path.
326
+ adoptSqliteOwnership(path)
321
327
  return db
322
328
  }
323
329
 
@@ -0,0 +1,191 @@
1
+ /**
2
+ * sent-text-capture.ts — the card-body FALLBACK: stamp a send's REQUEST body
3
+ * onto the `Message` Telegram returned for it (#4571 / #4576 follow-up).
4
+ *
5
+ * Read this first: it is NOT the primary source of the stored card body.
6
+ * -----------------------------------------------------------------------
7
+ * `system-message-observer.ts` takes the body off the RESPONSE — `rich_message`
8
+ * (Telegram's own rendered block tree) first, then `text` / `caption`. That is
9
+ * the more faithful source, and it covers every send verb the gateway writes a
10
+ * history row for. This module supplies the LAST tier of that precedence
11
+ * chain, for responses that carry no renderable body at all: a rich send whose
12
+ * blocks flatten to nothing (a media-only card), or a future verb whose
13
+ * response omits the body.
14
+ *
15
+ * Deliberately last, because the body it captures is NOT the body as written.
16
+ * The dominant card path is `sendRichMessage(chat, richMessage(body))`, and
17
+ * `richMessage()` applies `guardAccidentalFormatting` in the CALLER
18
+ * (`rich-send.ts`), long before any transformer seam. So what this module sees
19
+ * on that path is already wire-escaped: `sent_text_capture.ts` arrives as
20
+ * `sent\_text\_capture.ts`, `$12.40` as `\$12.40`. Escaped-but-present beats
21
+ * empty, which is why the tier exists at all — but it must never win over a
22
+ * response that resolved those escapes.
23
+ *
24
+ * The bug this exists to backstop
25
+ * -------------------------------
26
+ * #4576 gave every gateway card a `role='system'` history row so a quote-reply
27
+ * to it resolves. The row carried the right `message_id`, chat, thread and
28
+ * `kind` — and an EMPTY `text`, on 100% of the rows, on every agent in the
29
+ * fleet. `resolveReplyToFromBuffer` only sets `reply_to_text` when the stored
30
+ * body is non-empty (`inbound-router.ts`), so the agent learned WHICH card was
31
+ * tapped and still could not see WHAT IT SAID. That was the entire point.
32
+ *
33
+ * Root cause: the observer read ONE body field off the response
34
+ * (`msg.text ?? msg.caption`), and every card goes out through Bot API 10.1
35
+ * `sendRichMessage`, whose response is a `Message.RichMessageMessage` — the
36
+ * body lives under `rich_message: { blocks }`, and `text` / `caption` are
37
+ * simply absent (`@grammyjs/types` `message.d.ts:94`, `:180`; Bot API
38
+ * `RichMessage.blocks` = "Content of the message"). So the extractor fell
39
+ * through to `''` every single time. Reading `rich_message` is the fix; this
40
+ * module is the belt to that pair of braces.
41
+ *
42
+ * The mechanism
43
+ * -------------
44
+ * Capture the body from the REQUEST at the one seam no outbound call can
45
+ * bypass: a grammY API transformer (`bot.api.config.use`) — the same seam
46
+ * `installRichMarkdownGuard` already uses, and the reason that guard is
47
+ * universal where `richMessage()` is not.
48
+ *
49
+ * The transformer sees the outbound payload AND the resolved response in one
50
+ * call, so it can pair them with zero bookkeeping: no id→text map, no eviction,
51
+ * no cross-call race. It stamps the body onto the returned `Message` under a
52
+ * non-enumerable, `Symbol.for`-keyed property, which:
53
+ * - survives grammY's `callApi` unwrapping — the transformer chain resolves
54
+ * with the raw `{ok, result}` envelope and `callApi` returns `data.result`
55
+ * BY REFERENCE (grammy 1.44.0 `out/core/client.js:95-99`), so the object
56
+ * the caller receives is the object we stamped;
57
+ * - is invisible to `JSON.stringify`, `Object.keys`, spreads and structural
58
+ * equality, so nothing that reads a `Message` today can observe it.
59
+ *
60
+ * Non-negotiable: this must never break the send it observes. Every step is
61
+ * defensive and the transformer's only unconditional act is `return prev(...)`.
62
+ */
63
+
64
+ import type { Bot } from 'grammy'
65
+
66
+ /**
67
+ * The stamp key. `Symbol.for` (not a module-local `Symbol()`) deliberately:
68
+ * the plugin is consumed both from source and from a bundle, and a duplicated
69
+ * module instance would otherwise mint a second, non-matching symbol and
70
+ * silently reopen the exact hole this file closes.
71
+ */
72
+ export const SENT_TEXT = Symbol.for('switchroom.telegram.sentText')
73
+
74
+ /** Depth cap for the rich-block walk — a hostile/odd payload cannot recurse us. */
75
+ const MAX_BLOCK_DEPTH = 8
76
+
77
+ /**
78
+ * Best-effort readable text out of an OUTBOUND `InputRichMessage.blocks` array.
79
+ *
80
+ * Nothing in this repo builds `{ blocks }` today (every rich send goes through
81
+ * `richMessage()` → `{ markdown }`), so this is purely the guard against a
82
+ * future adopter silently re-emptying the card lane. Bounded, allocation-shy,
83
+ * never throws.
84
+ */
85
+ function flattenInputRichBlocks(blocks: unknown, depth: number): string {
86
+ if (!Array.isArray(blocks) || depth > MAX_BLOCK_DEPTH) return ''
87
+ const parts: string[] = []
88
+ for (const block of blocks) {
89
+ if (block == null || typeof block !== 'object') continue
90
+ const b = block as Record<string, unknown>
91
+ for (const key of ['markdown', 'html', 'text', 'caption'] as const) {
92
+ const v = b[key]
93
+ if (typeof v === 'string' && v.length > 0) parts.push(v)
94
+ }
95
+ const nested = flattenInputRichBlocks(b.blocks, depth + 1)
96
+ if (nested.length > 0) parts.push(nested)
97
+ }
98
+ return parts.join('\n')
99
+ }
100
+
101
+ /**
102
+ * The body a Telegram API request is about to POST, or null when the payload
103
+ * carries no user-visible text (pins, deletes, reactions, `getUpdates`, …).
104
+ *
105
+ * Shape verified against grammy 1.44.0 `out/core/api.js` and the payload notes
106
+ * in `installRichMarkdownGuard`: `sendRichMessage` / rich `editMessageText`
107
+ * put the body at `payload.rich_message.markdown`, plain sends at
108
+ * `payload.text`, media sends at `payload.caption`. Pure.
109
+ */
110
+ export function outboundPayloadText(payload: unknown): string | null {
111
+ if (payload == null || typeof payload !== 'object') return null
112
+ const p = payload as Record<string, unknown>
113
+ const rich = p.rich_message
114
+ if (rich != null && typeof rich === 'object') {
115
+ const r = rich as Record<string, unknown>
116
+ if (typeof r.markdown === 'string') return r.markdown
117
+ if (typeof r.html === 'string') return r.html
118
+ const flat = flattenInputRichBlocks(r.blocks, 0)
119
+ if (flat.length > 0) return flat
120
+ }
121
+ if (typeof p.text === 'string') return p.text
122
+ if (typeof p.caption === 'string') return p.caption
123
+ return null
124
+ }
125
+
126
+ /**
127
+ * Stamp `text` onto a resolved API response envelope's `Message` result.
128
+ *
129
+ * Takes the raw `{ok, result}` envelope (what a transformer sees), not the
130
+ * unwrapped message, and no-ops on anything that isn't a single Message —
131
+ * `true` (pins / dropped edits / `editMessageText` on an inline message),
132
+ * arrays, `{ok:false}` rejections. Never throws.
133
+ */
134
+ export function attachSentText(envelope: unknown, text: string): void {
135
+ try {
136
+ if (envelope == null || typeof envelope !== 'object') return
137
+ const env = envelope as { ok?: unknown; result?: unknown }
138
+ if (env.ok !== true) return
139
+ const result = env.result
140
+ if (result == null || typeof result !== 'object' || Array.isArray(result)) return
141
+ Object.defineProperty(result, SENT_TEXT, {
142
+ value: text,
143
+ enumerable: false,
144
+ configurable: true,
145
+ writable: true,
146
+ })
147
+ } catch {
148
+ /* stamping must never break the send */
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Read the body stamped by {@link installSentTextCapture} off a `Message`, or
154
+ * null when the message did not transit a capture-installed bot (a test double,
155
+ * a second Bot instance, a hand-built fixture). Pure.
156
+ */
157
+ export function readSentText(message: unknown): string | null {
158
+ if (message == null || typeof message !== 'object') return null
159
+ const v = (message as Record<symbol, unknown>)[SENT_TEXT]
160
+ return typeof v === 'string' ? v : null
161
+ }
162
+
163
+ /**
164
+ * Install the capture transformer on the production Bot.
165
+ *
166
+ * Install it AFTER `installRichMarkdownGuard` so it composes OUTSIDE the guard
167
+ * (grammY's last-installed transformer runs first) and therefore captures the
168
+ * payload before the guard's backslash escapes are applied.
169
+ *
170
+ * Be precise about what that does and does not buy. It only avoids the
171
+ * TRANSFORMER's escaping pass, which matters for the call sites that build a
172
+ * raw `{ markdown }` and go straight to `sendRichMessage` / `editMessageText`
173
+ * (banners, approval and folder-picker edits — see `richMessage()`'s docblock
174
+ * for the list). It does NOT recover a pre-escape body for the dominant path,
175
+ * because `richMessage()` escapes in the CALLER, upstream of every transformer.
176
+ * There is no seam that can. That is precisely why this capture is the LAST
177
+ * tier of the observer's precedence chain rather than the first.
178
+ */
179
+ export function installSentTextCapture(bot: Bot): void {
180
+ bot.api.config.use(async (prev, method, payload, signal) => {
181
+ let text: string | null = null
182
+ try {
183
+ text = outboundPayloadText(payload)
184
+ } catch {
185
+ text = null
186
+ }
187
+ const res = await prev(method, payload, signal)
188
+ if (text != null) attachSentText(res, text)
189
+ return res
190
+ })
191
+ }
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
1
+ import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest'
2
2
  import { mkdtempSync, rmSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
@@ -29,6 +29,17 @@ function mirrorWith(sender: (m: OutboundToBuzzMessage) => boolean, opts?: { defa
29
29
 
30
30
  const BUZZ_COORDS = { channelId: 'chan-A', eventId: 'evt-1', threadRoot: 'root-1' }
31
31
 
32
+ // Cross-FILE isolation. `getBuzzMirror()` is a module-level singleton and the
33
+ // last describe here boots one (`maybeBootBuzzMirror`) with no trailing reset —
34
+ // every `beforeEach` in this file resets on the way IN, nothing resets on the
35
+ // way OUT. `bun test` runs every file in ONE process with no guaranteed file
36
+ // order, so a booted mirror leaks into whichever suite runs next: sendReply's
37
+ // Buzz hook (outbound-send-path.ts:2658) then fires in suites that never wired
38
+ // its deps and dies on `findLatestTurnForChat is not a function` (observed:
39
+ // 43 failures in send-reply-golden.test.ts, purely from readdir order).
40
+ // Unconditional teardown here is the only ordering-proof fix.
41
+ afterAll(() => __resetBuzzMirrorForTests())
42
+
32
43
  describe('BuzzMirror.mirrorReplyDelivered — routing + S1 owner guard', () => {
33
44
  beforeEach(() => __resetBuzzMirrorForTests())
34
45