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.
@@ -0,0 +1,514 @@
1
+ /**
2
+ * Card history lane — REAL history.db end-to-end (#4571).
3
+ *
4
+ * The bug: the gateway posts activity cards, status pins, approval / boot /
5
+ * issues cards and progress lines as real Telegram messages that consume real
6
+ * message ids, but ONLY the `reply` family (which calls `recordOutbound`) ever
7
+ * wrote a row. Measured on a live agent's buffer: 116 rows across a 266-id
8
+ * span. The operator quote-replies to the activity card — the most recent
9
+ * message on their screen for most of a turn — Telegram delivers
10
+ * `reply_to_message_id` pointing at it, the buffer has nothing, and the agent
11
+ * answers "I can't see the message you replied to".
12
+ *
13
+ * This drives the WHOLE chain against a real bun:sqlite DB, because a stored
14
+ * row nobody can look up is not a fix:
15
+ *
16
+ * robustApiCall resolves → observer → recordSystemOutbound
17
+ * → lookupMessageRoleAndText({ includeSystem: true })
18
+ * → resolveReplyToFromBuffer → buildInboundEnvelope
19
+ * → meta.reply_to_role='system' + meta.reply_to_kind + reply_to_text
20
+ *
21
+ * plus the three regressions the lane must not cause: card rows must not be
22
+ * LISTED by `query()` (get_recent_messages), a real reply must always beat the
23
+ * provisional card row for the same id, and the schema migration must be
24
+ * idempotent against a live pre-#4571 DB without losing a row.
25
+ *
26
+ * Runs under `bun test` (bun:sqlite is a Bun built-in vitest can't resolve);
27
+ * vitest-excluded in vitest.config.ts, covered by the bun `tests/` target in
28
+ * telegram-plugin/scripts/bun-test-ci.sh.
29
+ */
30
+
31
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
32
+ import { Database } from 'bun:sqlite'
33
+ import { mkdtempSync, rmSync, existsSync } from 'fs'
34
+ import { tmpdir } from 'os'
35
+ import { join } from 'path'
36
+ import {
37
+ initHistory,
38
+ recordInbound,
39
+ recordOutbound,
40
+ recordSystemOutbound,
41
+ updateSystemOutboundText,
42
+ lookupMessageRoleAndText,
43
+ query,
44
+ _resetForTests,
45
+ } from '../history.js'
46
+ import {
47
+ resolveReplyToFromBuffer,
48
+ buildInboundEnvelope,
49
+ type EnvelopeBuildParams,
50
+ } from '../gateway/inbound-router.js'
51
+ import { makeSystemMessageObserver } from '../gateway/system-message-observer.js'
52
+ import { Bot } from 'grammy'
53
+ import { installRichMarkdownGuard } from '../shared/bot-runtime.js'
54
+ import { installSentTextCapture } from '../shared/sent-text-capture.js'
55
+ import { richMessage } from '../rich-send.js'
56
+
57
+ const CHAT = '5550001'
58
+ const REPLY_TO_TEXT_MAX = 200
59
+
60
+ /** The gateway's own wiring, verbatim: history writers behind the observer. */
61
+ function makeObserver(now?: () => number) {
62
+ return makeSystemMessageObserver({
63
+ insert: recordSystemOutbound,
64
+ updateText: updateSystemOutboundText,
65
+ ...(now != null ? { now } : {}),
66
+ })
67
+ }
68
+
69
+ /**
70
+ * A Telegram Message response, as `robustApiCall` resolves with.
71
+ *
72
+ * NOTE (#4576): this PLAIN shape is what `sendMessage` returns. No CARD send
73
+ * uses it — cards go out via `sendRichMessage`, whose response carries no
74
+ * `text` at all. Tests that assert the card BODY must use `sentRichCard()`
75
+ * below; a hand-built `{ text }` fixture cannot fail on the empty-body bug.
76
+ */
77
+ function sentMessage(messageId: number, text: string) {
78
+ return { message_id: messageId, chat: { id: Number(CHAT) }, text }
79
+ }
80
+
81
+ /**
82
+ * The result of a REAL `sendRichMessage` through the production transformer
83
+ * stack (fmt guard + `installSentTextCapture`), transport stubbed to answer
84
+ * with the true `Message.RichMessageMessage` shape: `rich_message` blocks, and
85
+ * NO `text` / `caption`.
86
+ *
87
+ * This is the fixture the #4576 defect demanded: every `role='system'` row on
88
+ * every agent in the fleet had `length(text)=0`, because the lane's tests fed
89
+ * the observer a response shape production never produces.
90
+ */
91
+ async function sentRichCard(messageId: number, body: string): Promise<unknown> {
92
+ const fakeFetch = (async () =>
93
+ ({
94
+ ok: true,
95
+ status: 200,
96
+ json: async () => ({
97
+ ok: true,
98
+ result: {
99
+ message_id: messageId,
100
+ date: 0,
101
+ chat: { id: Number(CHAT), type: 'private' },
102
+ rich_message: { blocks: [{ type: 'paragraph', text: { text: body } }] },
103
+ },
104
+ }),
105
+ }) as unknown as Response) as unknown as typeof fetch
106
+
107
+ const bot = new Bot('123456:TEST_TOKEN', {
108
+ botInfo: {
109
+ id: 123456, is_bot: true, first_name: 'Test', username: 'test_bot',
110
+ can_join_groups: false, can_read_all_group_messages: false,
111
+ supports_inline_queries: false, can_connect_to_business: false,
112
+ has_main_web_app: false,
113
+ },
114
+ client: { fetch: fakeFetch },
115
+ })
116
+ installRichMarkdownGuard(bot)
117
+ installSentTextCapture(bot)
118
+ return bot.api.sendRichMessage(Number(CHAT), richMessage(body))
119
+ }
120
+
121
+ /** The reply-antecedent lookup as gateway.ts binds it (#4571: includeSystem). */
122
+ function boundLookup(messageId: number) {
123
+ return lookupMessageRoleAndText(CHAT, messageId, { includeSystem: true })
124
+ }
125
+
126
+ function makeEnvelopeParams(overrides: Partial<EnvelopeBuildParams>): EnvelopeBuildParams {
127
+ return {
128
+ ctx: { message: { date: 1_700_000_000 } } as unknown as EnvelopeBuildParams['ctx'],
129
+ chat_id: CHAT,
130
+ messageThreadId: undefined,
131
+ msgId: 20268,
132
+ effectiveText: 'stop what you are doing and check the other repo',
133
+ imagePath: undefined,
134
+ attachment: undefined,
135
+ attachmentCount: 0,
136
+ extraMeta: {},
137
+ from: { id: 111, username: 'alice' },
138
+ access: { groups: {} },
139
+ isSteering: false,
140
+ isQueuedPrefix: false,
141
+ isQueuedMidTurn: false,
142
+ priorTurnInProgress: false,
143
+ secondsSinceTurnStart: undefined,
144
+ priorAssistantPreview: undefined,
145
+ replyToMessageId: undefined,
146
+ replyToTextEscaped: undefined,
147
+ replyToRole: undefined,
148
+ forwardOriginMeta: {},
149
+ topicFramingEnabled: false,
150
+ personDirectory: { byTelegramKey: {} },
151
+ isDmChatId: () => true,
152
+ ...overrides,
153
+ } as EnvelopeBuildParams
154
+ }
155
+
156
+ let stateDir: string
157
+
158
+ beforeEach(() => {
159
+ stateDir = mkdtempSync(join(tmpdir(), 'card-history-lane-'))
160
+ initHistory(stateDir, 30)
161
+ })
162
+
163
+ afterEach(() => {
164
+ _resetForTests()
165
+ if (existsSync(stateDir)) rmSync(stateDir, { recursive: true, force: true })
166
+ })
167
+
168
+ describe('a quote-reply to the live activity card is UNDERSTOOD, end to end', () => {
169
+ it('card posted → id resolvable → the reply carries role=system, the card kind, and its body', () => {
170
+ const observe = makeObserver()
171
+ const CARD_ID = 20267
172
+
173
+ // 1. The gateway opens the activity card. No `recordOutbound` anywhere —
174
+ // this is the exact path that used to leave NO row at all.
175
+ observe(sentMessage(CARD_ID, '⚙️ Working — reading history.ts, 3 tools'), {
176
+ chat_id: CHAT,
177
+ verb: 'activity-summary.send',
178
+ })
179
+
180
+ // 2. The operator quote-replies to it. Telegram gives the id but NOT the
181
+ // text (the bot authored it), so the live reply text is empty.
182
+ const resolved = resolveReplyToFromBuffer({
183
+ replyToMessageId: CARD_ID,
184
+ replyToText: undefined,
185
+ replyToTextEscaped: undefined,
186
+ historyEnabled: true,
187
+ replyToTextMax: REPLY_TO_TEXT_MAX,
188
+ lookup: boundLookup,
189
+ })
190
+
191
+ // 3. The antecedent resolves — this is the assertion the bug fails.
192
+ expect(resolved.replyToRole).toBe('system')
193
+ expect(resolved.replyToKind).toBe('activity-summary')
194
+ expect(resolved.replyToText).toBe('⚙️ Working — reading history.ts, 3 tools')
195
+
196
+ // 4. …and reaches the agent on the inbound envelope, so it can read the
197
+ // card body instead of reporting amnesia.
198
+ const msg = buildInboundEnvelope(
199
+ makeEnvelopeParams({
200
+ replyToMessageId: CARD_ID,
201
+ replyToTextEscaped: resolved.replyToTextEscaped,
202
+ replyToRole: resolved.replyToRole,
203
+ replyToKind: resolved.replyToKind,
204
+ }),
205
+ )
206
+ expect(msg.meta?.reply_to_message_id).toBe(String(CARD_ID))
207
+ expect(msg.meta?.reply_to_role).toBe('system')
208
+ expect(msg.meta?.reply_to_kind).toBe('activity-summary')
209
+ expect(msg.meta?.reply_to_text).toBe('⚙️ Working — reading history.ts, 3 tools')
210
+ })
211
+
212
+ it('WITHOUT the observer the same reply resolves to nothing (the bug, pinned)', () => {
213
+ // No observe() call — i.e. pre-#4571 behaviour for a card send.
214
+ const resolved = resolveReplyToFromBuffer({
215
+ replyToMessageId: 20267,
216
+ replyToText: undefined,
217
+ replyToTextEscaped: undefined,
218
+ historyEnabled: true,
219
+ replyToTextMax: REPLY_TO_TEXT_MAX,
220
+ lookup: boundLookup,
221
+ })
222
+ expect(resolved.replyToRole).toBeUndefined()
223
+ expect(resolved.replyToText).toBeUndefined()
224
+ })
225
+
226
+ it('surfaces the LATEST card body, not the text it was opened with', () => {
227
+ let t = 1_000
228
+ const observe = makeObserver(() => t)
229
+ observe(sentMessage(20267, '⚙️ Working — starting'), {
230
+ chat_id: CHAT,
231
+ verb: 'activity-summary.send',
232
+ })
233
+ t += 60_000
234
+ observe(sentMessage(20267, '⚙️ Working — 11 tools, editing gateway.ts'), {
235
+ chat_id: CHAT,
236
+ verb: 'activity-summary.edit',
237
+ })
238
+
239
+ const resolved = resolveReplyToFromBuffer({
240
+ replyToMessageId: 20267,
241
+ replyToText: undefined,
242
+ replyToTextEscaped: undefined,
243
+ historyEnabled: true,
244
+ replyToTextMax: REPLY_TO_TEXT_MAX,
245
+ lookup: boundLookup,
246
+ })
247
+ expect(resolved.replyToText).toBe('⚙️ Working — 11 tools, editing gateway.ts')
248
+ })
249
+ })
250
+
251
+ describe('#4576: the stored card body is NOT empty — real sendRichMessage response', () => {
252
+ it('a rich card write leaves a non-empty row, and the quote-reply carries the body', async () => {
253
+ const observe = makeObserver()
254
+ const CARD_ID = 20395
255
+ const BODY = '⚙️ Working — reading history.ts, 3 tools'
256
+
257
+ // The exact production path: sendRichMessage → transformer stack → observer.
258
+ // Its response has NO `text` field, which is what made every fleet row empty.
259
+ const result = await sentRichCard(CARD_ID, BODY)
260
+ expect((result as { text?: unknown }).text).toBeUndefined()
261
+ observe(result, { chat_id: CHAT, verb: 'activity-summary.send' })
262
+
263
+ const row = lookupMessageRoleAndText(CHAT, CARD_ID, { includeSystem: true })
264
+ expect(row?.role).toBe('system')
265
+ expect(row?.kind).toBe('activity-summary')
266
+ // The assertion the shipped bug fails: a stored body, not ''.
267
+ expect(row?.text.length).toBeGreaterThan(0)
268
+ expect(row?.text).toBe(BODY)
269
+
270
+ const resolved = resolveReplyToFromBuffer({
271
+ replyToMessageId: CARD_ID,
272
+ replyToText: undefined,
273
+ replyToTextEscaped: undefined,
274
+ historyEnabled: true,
275
+ replyToTextMax: REPLY_TO_TEXT_MAX,
276
+ lookup: boundLookup,
277
+ })
278
+ expect(resolved.replyToText).toBe(BODY)
279
+
280
+ const msg = buildInboundEnvelope(
281
+ makeEnvelopeParams({
282
+ replyToMessageId: CARD_ID,
283
+ replyToTextEscaped: resolved.replyToTextEscaped,
284
+ replyToRole: resolved.replyToRole,
285
+ replyToKind: resolved.replyToKind,
286
+ }),
287
+ )
288
+ expect(msg.meta?.reply_to_text).toBe(BODY)
289
+ })
290
+
291
+ it('holds for every card verb observed on the live fleet, not just the activity card', async () => {
292
+ const observe = makeObserver()
293
+ // The `kind` values measured on live agents' history.db after v0.21.0.
294
+ const verbs: [string, number, string][] = [
295
+ ['boot-card', 5082, '🟢 **overlord** is up — sonnet, 3 skills'],
296
+ ['worker-feed', 20404, '🛠 Worker — scoping the card-persistence defect'],
297
+ ['issues-card', 20405, '**3 open issues** — #4571, #4576, #4580'],
298
+ ['rollout-status-post', 20402, 'Rolling v0.21.0 — 4/12 agents done'],
299
+ ['quota-watch.fleet-roll', 20407, 'Quota 61% — resets 09:00'],
300
+ ['permission_request', 20406, '**Approve** `docker restart switchroom-clerk`?'],
301
+ ]
302
+ for (const [verb, id, body] of verbs) {
303
+ observe(await sentRichCard(id, body), { chat_id: CHAT, verb })
304
+ }
305
+ for (const [verb, id, body] of verbs) {
306
+ const row = lookupMessageRoleAndText(CHAT, id, { includeSystem: true })
307
+ expect(`${verb}:${row?.text.length ?? 0}`).not.toBe(`${verb}:0`)
308
+ expect(row?.text).toBe(body)
309
+ }
310
+ // No card row anywhere in the buffer is bodiless — the fleet-wide invariant
311
+ // the shipped release violated on 100% of its rows.
312
+ const cards = query({ chat_id: CHAT, limit: 100, include_system: true }).filter(
313
+ (r) => r.role === 'system',
314
+ )
315
+ expect(cards).toHaveLength(verbs.length)
316
+ expect(cards.filter((r) => r.text.length === 0)).toEqual([])
317
+ })
318
+ })
319
+
320
+ describe('the card lane does not pollute normal history reads', () => {
321
+ it('query() (get_recent_messages) lists the conversation, never the cards', () => {
322
+ const observe = makeObserver()
323
+ recordInbound({
324
+ chat_id: CHAT,
325
+ thread_id: null,
326
+ message_id: 20200,
327
+ user: 'alice',
328
+ user_id: '111',
329
+ ts: 1_000,
330
+ text: 'what is the status of the migration?',
331
+ })
332
+ for (const id of [20209, 20210, 20212, 20213]) {
333
+ observe(sentMessage(id, `card ${id}`), { chat_id: CHAT, verb: 'activity-summary.send' })
334
+ }
335
+ recordOutbound({
336
+ chat_id: CHAT,
337
+ thread_id: null,
338
+ message_ids: [20214],
339
+ texts: ['Migration is applied on all three boxes.'],
340
+ ts: 2_000,
341
+ })
342
+
343
+ const listed = query({ chat_id: CHAT, limit: 50 })
344
+ expect(listed.map((r) => r.message_id)).toEqual([20200, 20214])
345
+ expect(listed.some((r) => r.role === 'system')).toBe(false)
346
+
347
+ // Opt-in still sees them — resolvable, just not listed.
348
+ const withCards = query({ chat_id: CHAT, limit: 50, include_system: true })
349
+ expect(withCards.map((r) => r.message_id).sort((a, b) => a - b)).toEqual([
350
+ 20200, 20209, 20210, 20212, 20213, 20214,
351
+ ])
352
+ })
353
+
354
+ it('an authorship-sensitive lookup (reaction trigger) still cannot see a card', () => {
355
+ makeObserver()(sentMessage(20267, 'card'), { chat_id: CHAT, verb: 'activity-summary.send' })
356
+ // Default (no includeSystem) — the pre-#4571 contract, unchanged.
357
+ expect(lookupMessageRoleAndText(CHAT, 20267)).toBeNull()
358
+ expect(lookupMessageRoleAndText(CHAT, 20267, { includeSystem: true })?.role).toBe('system')
359
+ })
360
+ })
361
+
362
+ describe('a real reply always beats the provisional card row', () => {
363
+ it('recordOutbound promotes the id to assistant and leaves exactly one row', () => {
364
+ const observe = makeObserver()
365
+ const ID = 20261
366
+ // The observer fires first — it runs when the API call resolves, strictly
367
+ // before the caller reaches its own recordOutbound.
368
+ observe(sentMessage(ID, 'Migration is applied on all three boxes.'), {
369
+ chat_id: CHAT,
370
+ verb: 'reply',
371
+ })
372
+ recordOutbound({
373
+ chat_id: CHAT,
374
+ thread_id: null,
375
+ message_ids: [ID],
376
+ texts: ['Migration is applied on all three boxes.'],
377
+ ts: 2_000,
378
+ })
379
+
380
+ const rows = query({ chat_id: CHAT, limit: 50, include_system: true })
381
+ expect(rows.filter((r) => r.message_id === ID)).toHaveLength(1)
382
+ expect(rows[0]?.role).toBe('assistant')
383
+ // And a reply pointing at it reads as an ANSWER, not a card.
384
+ const resolved = resolveReplyToFromBuffer({
385
+ replyToMessageId: ID,
386
+ replyToText: undefined,
387
+ replyToTextEscaped: undefined,
388
+ historyEnabled: true,
389
+ replyToTextMax: REPLY_TO_TEXT_MAX,
390
+ lookup: boundLookup,
391
+ })
392
+ expect(resolved.replyToRole).toBe('assistant')
393
+ expect(resolved.replyToKind).toBeUndefined()
394
+ })
395
+
396
+ it('promotes even when the observer recorded a DIFFERENT thread for the id', () => {
397
+ // Telegram stamps message_thread_id on reply chains in plain supergroups,
398
+ // so the observer's thread can differ from the gateway's. The unique key
399
+ // folds thread_id, so without the explicit delete BOTH rows would survive
400
+ // and the card could shadow the answer.
401
+ recordSystemOutbound({
402
+ chat_id: CHAT,
403
+ thread_id: 77,
404
+ message_id: 20263,
405
+ kind: 'activity-summary',
406
+ text: 'card',
407
+ })
408
+ recordOutbound({
409
+ chat_id: CHAT,
410
+ thread_id: null,
411
+ message_ids: [20263],
412
+ texts: ['the real answer'],
413
+ ts: 2_000,
414
+ })
415
+ const rows = query({ chat_id: CHAT, limit: 50, include_system: true })
416
+ expect(rows).toHaveLength(1)
417
+ expect(rows[0]?.role).toBe('assistant')
418
+ })
419
+
420
+ it('a card never overwrites an inbound that already owns the id', () => {
421
+ recordInbound({
422
+ chat_id: CHAT,
423
+ thread_id: null,
424
+ message_id: 20300,
425
+ user: 'alice',
426
+ user_id: '111',
427
+ ts: 1_000,
428
+ text: 'the operator said this',
429
+ })
430
+ expect(
431
+ recordSystemOutbound({
432
+ chat_id: CHAT,
433
+ thread_id: null,
434
+ message_id: 20300,
435
+ kind: 'activity-summary',
436
+ text: 'card',
437
+ }),
438
+ ).toBe(false)
439
+ expect(updateSystemOutboundText({ chat_id: CHAT, message_id: 20300, text: 'card' })).toBe(false)
440
+ const row = lookupMessageRoleAndText(CHAT, 20300, { includeSystem: true })
441
+ expect(row?.role).toBe('user')
442
+ expect(row?.text).toBe('the operator said this')
443
+ })
444
+ })
445
+
446
+ describe('migration onto a live pre-#4571 history.db', () => {
447
+ it('adds `kind` without losing a row, and is a no-op when already migrated', () => {
448
+ _resetForTests()
449
+ const legacyDir = mkdtempSync(join(tmpdir(), 'card-history-legacy-'))
450
+ try {
451
+ // A pre-#4571 schema: no `kind` column, no logical-key index.
452
+ const raw = new Database(join(legacyDir, 'history.db'))
453
+ raw.exec(`
454
+ CREATE TABLE messages (
455
+ chat_id TEXT NOT NULL, thread_id INTEGER, message_id INTEGER NOT NULL,
456
+ role TEXT NOT NULL, user TEXT, user_id TEXT, ts INTEGER NOT NULL,
457
+ text TEXT NOT NULL, attachment_kind TEXT, group_id INTEGER,
458
+ PRIMARY KEY (chat_id, thread_id, message_id)
459
+ )
460
+ `)
461
+ const ins = raw.prepare(
462
+ `INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text)
463
+ VALUES (?, NULL, ?, ?, ?, ?, ?, ?)`,
464
+ )
465
+ const nowSec = Math.floor(Date.now() / 1000)
466
+ for (let i = 0; i < 200; i++) {
467
+ ins.run(CHAT, 19000 + i, i % 2 === 0 ? 'user' : 'assistant', 'alice', '111', nowSec, `row ${i}`)
468
+ }
469
+ raw.close()
470
+
471
+ const countRows = () => {
472
+ const d = new Database(join(legacyDir, 'history.db'), { readonly: true })
473
+ try {
474
+ return (d.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c
475
+ } finally {
476
+ d.close()
477
+ }
478
+ }
479
+ expect(countRows()).toBe(200)
480
+
481
+ // First migration.
482
+ initHistory(legacyDir, 30)
483
+ expect(countRows()).toBe(200)
484
+ // The new column is usable, and every legacy row kept its exact contents.
485
+ expect(lookupMessageRoleAndText(CHAT, 19000)).toEqual({
486
+ role: 'user',
487
+ text: 'row 0',
488
+ kind: null,
489
+ })
490
+ recordSystemOutbound({
491
+ chat_id: CHAT,
492
+ thread_id: null,
493
+ message_id: 19500,
494
+ kind: 'boot-card',
495
+ text: 'booted',
496
+ })
497
+ _resetForTests()
498
+
499
+ // Re-run against the ALREADY-migrated file: idempotent, non-destructive.
500
+ initHistory(legacyDir, 30)
501
+ expect(countRows()).toBe(201)
502
+ expect(lookupMessageRoleAndText(CHAT, 19500, { includeSystem: true })).toEqual({
503
+ role: 'system',
504
+ text: 'booted',
505
+ kind: 'boot-card',
506
+ })
507
+ _resetForTests()
508
+ } finally {
509
+ rmSync(legacyDir, { recursive: true, force: true })
510
+ // Restore the per-test DB the shared afterEach expects to close.
511
+ initHistory(stateDir, 30)
512
+ }
513
+ })
514
+ })