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,225 @@
1
+ /**
2
+ * system-message-observer — the card-lane recorder's pure behaviour (#4571).
3
+ *
4
+ * The observer hangs off gateway.ts's single `robustApiCall` chokepoint and
5
+ * turns every card the gateway posts (activity summary, status pin, approval /
6
+ * boot / issues cards, progress lines) into ONE resolvable history row. The
7
+ * two properties that make it safe to run on the hot send path are asserted
8
+ * here against a fake writer pair:
9
+ *
10
+ * 1. an EDIT of a card it already recorded never creates a second row, and
11
+ * inside the refresh window costs zero writer calls at all;
12
+ * 2. an id that turns out to belong to a real reply / inbound is demoted to
13
+ * `foreign` and never written to again.
14
+ *
15
+ * The end-to-end proof (card posted → id resolvable → a reply pointing at it
16
+ * is understood) needs a real bun:sqlite history.db and lives in
17
+ * card-history-lane.test.ts (bun).
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest'
21
+ import {
22
+ makeSystemMessageObserver,
23
+ normalizeSendVerb,
24
+ extractSentMessage,
25
+ DEFAULT_EDIT_REFRESH_MS,
26
+ } from '../gateway/system-message-observer.js'
27
+
28
+ const CHAT = '5550001'
29
+
30
+ /**
31
+ * A Telegram `Message` response as the Bot API returns it from a PLAIN
32
+ * `sendMessage` / `editMessageText`.
33
+ *
34
+ * These cases exercise the observer's BOOKKEEPING (one row per card, edit
35
+ * throttling, foreign-lane demotion), for which the body is incidental. Do NOT
36
+ * assert card-body behaviour through this fixture: a card never goes out as a
37
+ * plain message, and hand-building a `text` field is exactly what let #4576
38
+ * ship an empty body on 100% of the fleet's card rows. Body coverage lives in
39
+ * `sent-text-capture.test.ts` (real grammy stack) and `card-history-lane.test.ts`.
40
+ */
41
+ function sentMessage(messageId: number, text: string, threadId?: number) {
42
+ return {
43
+ message_id: messageId,
44
+ chat: { id: Number(CHAT) },
45
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
46
+ text,
47
+ }
48
+ }
49
+
50
+ /** A writer pair backed by a Map, mirroring recordSystemOutbound /
51
+ * updateSystemOutboundText's real return contract. */
52
+ function fakeStore(seed?: Record<number, { role: 'system' | 'other'; text: string }>) {
53
+ const rows = new Map<number, { role: 'system' | 'other'; text: string; kind: string | null }>()
54
+ for (const [id, r] of Object.entries(seed ?? {})) {
55
+ rows.set(Number(id), { role: r.role, text: r.text, kind: null })
56
+ }
57
+ const calls = { insert: 0, update: 0 }
58
+ return {
59
+ rows,
60
+ calls,
61
+ insert(args: { message_id: number; kind: string | null; text: string }): boolean {
62
+ calls.insert++
63
+ if (rows.has(args.message_id)) return false
64
+ rows.set(args.message_id, { role: 'system', text: args.text, kind: args.kind })
65
+ return true
66
+ },
67
+ updateText(args: { message_id: number; text: string }): boolean {
68
+ calls.update++
69
+ const row = rows.get(args.message_id)
70
+ if (row == null || row.role !== 'system') return false
71
+ row.text = args.text
72
+ return true
73
+ },
74
+ }
75
+ }
76
+
77
+ describe('normalizeSendVerb', () => {
78
+ it('strips the transport suffix so a card OPEN and its EDITs classify identically', () => {
79
+ expect(normalizeSendVerb('activity-summary.send')).toBe('activity-summary')
80
+ expect(normalizeSendVerb('activity-summary.edit')).toBe('activity-summary')
81
+ })
82
+
83
+ it('passes a suffix-less verb through and returns null for an untagged send', () => {
84
+ expect(normalizeSendVerb('boot-card')).toBe('boot-card')
85
+ expect(normalizeSendVerb(undefined)).toBeNull()
86
+ expect(normalizeSendVerb(' ')).toBeNull()
87
+ })
88
+ })
89
+
90
+ describe('extractSentMessage', () => {
91
+ it('ignores non-Message results (pins/deletes/reactions return true, swallowed 400s undefined)', () => {
92
+ expect(extractSentMessage(true, { chat_id: CHAT })).toBeNull()
93
+ expect(extractSentMessage(undefined, { chat_id: CHAT })).toBeNull()
94
+ expect(extractSentMessage({ ok: true }, { chat_id: CHAT })).toBeNull()
95
+ })
96
+
97
+ it("prefers the response's own chat and thread over the caller's opts", () => {
98
+ const out = extractSentMessage(sentMessage(20267, 'card body', 77), {
99
+ chat_id: 'wrong',
100
+ threadId: 5,
101
+ })
102
+ expect(out).toEqual({ chatId: CHAT, messageId: 20267, threadId: 77, text: 'card body' })
103
+ })
104
+
105
+ it('falls back to the caller chat/thread when the response omits them', () => {
106
+ const out = extractSentMessage({ message_id: 9, text: 'x' }, { chat_id: CHAT, threadId: 5 })
107
+ expect(out).toEqual({ chatId: CHAT, messageId: 9, threadId: 5, text: 'x' })
108
+ })
109
+ })
110
+
111
+ describe('makeSystemMessageObserver', () => {
112
+ it('records a posted card once, and an EDIT of it never adds a second row', () => {
113
+ const store = fakeStore()
114
+ let now = 1_000
115
+ const observe = makeSystemMessageObserver({ insert: store.insert, updateText: store.updateText, now: () => now })
116
+
117
+ observe(sentMessage(20267, 'Working… reading history.ts'), {
118
+ chat_id: CHAT,
119
+ verb: 'activity-summary.send',
120
+ })
121
+ expect(store.rows.size).toBe(1)
122
+ expect(store.rows.get(20267)?.kind).toBe('activity-summary')
123
+
124
+ // 40 edits of the SAME card, spread well past the refresh window.
125
+ for (let i = 0; i < 40; i++) {
126
+ now += DEFAULT_EDIT_REFRESH_MS + 1
127
+ observe(sentMessage(20267, `Working… step ${i}`), {
128
+ chat_id: CHAT,
129
+ verb: 'activity-summary.edit',
130
+ })
131
+ }
132
+ // The outcome that matters: still exactly one row for that card.
133
+ expect(store.rows.size).toBe(1)
134
+ // …carrying a recent snapshot of the card body, not the stale open text.
135
+ expect(store.rows.get(20267)?.text).toBe('Working… step 39')
136
+ })
137
+
138
+ it('does no DB work at all for edits inside the refresh window', () => {
139
+ const store = fakeStore()
140
+ let now = 1_000
141
+ const observe = makeSystemMessageObserver({ insert: store.insert, updateText: store.updateText, now: () => now })
142
+
143
+ observe(sentMessage(30, 'open'), { chat_id: CHAT, verb: 'activity-summary.send' })
144
+ const afterOpen = { ...store.calls }
145
+
146
+ for (let i = 0; i < 25; i++) {
147
+ now += 100 // ~2.5s of a climbing activity card
148
+ observe(sentMessage(30, `tick ${i}`), { chat_id: CHAT, verb: 'activity-summary.edit' })
149
+ }
150
+ expect(store.calls.insert).toBe(afterOpen.insert)
151
+ expect(store.calls.update).toBe(afterOpen.update)
152
+ })
153
+
154
+ it('leaves a real reply / inbound alone and stops probing it', () => {
155
+ // 4242 already belongs to a non-system row (recordOutbound got there first).
156
+ const store = fakeStore({ 4242: { role: 'other', text: 'the real answer' } })
157
+ let now = 1_000
158
+ const observe = makeSystemMessageObserver({ insert: store.insert, updateText: store.updateText, now: () => now })
159
+
160
+ observe(sentMessage(4242, 'edited answer'), { chat_id: CHAT, verb: 'reply' })
161
+ expect(store.rows.get(4242)).toEqual({ role: 'other', text: 'the real answer', kind: null })
162
+
163
+ const afterProbe = { ...store.calls }
164
+ for (let i = 0; i < 10; i++) {
165
+ now += DEFAULT_EDIT_REFRESH_MS + 1
166
+ observe(sentMessage(4242, `edited answer ${i}`), { chat_id: CHAT, verb: 'reply' })
167
+ }
168
+ // One probing update total — then the id is marked foreign forever.
169
+ expect(store.calls.insert).toBe(afterProbe.insert)
170
+ expect(store.calls.update).toBe(afterProbe.update)
171
+ expect(store.rows.get(4242)?.text).toBe('the real answer')
172
+ })
173
+
174
+ it('demotes a card that got promoted to a real reply mid-turn', () => {
175
+ const store = fakeStore()
176
+ let now = 1_000
177
+ const observe = makeSystemMessageObserver({ insert: store.insert, updateText: store.updateText, now: () => now })
178
+
179
+ observe(sentMessage(55, 'card'), { chat_id: CHAT, verb: 'activity-summary.send' })
180
+ // recordOutbound promotes the row to a real assistant reply.
181
+ store.rows.set(55, { role: 'other', text: 'the real answer', kind: null })
182
+
183
+ now += DEFAULT_EDIT_REFRESH_MS + 1
184
+ observe(sentMessage(55, 'card edit'), { chat_id: CHAT, verb: 'activity-summary.edit' })
185
+ expect(store.rows.get(55)?.text).toBe('the real answer')
186
+
187
+ const after = { ...store.calls }
188
+ now += DEFAULT_EDIT_REFRESH_MS + 1
189
+ observe(sentMessage(55, 'card edit 2'), { chat_id: CHAT, verb: 'activity-summary.edit' })
190
+ expect(store.calls.update).toBe(after.update)
191
+ })
192
+
193
+ it('never throws when the writer blows up — observing a send cannot break the send', () => {
194
+ const observe = makeSystemMessageObserver({
195
+ insert: () => {
196
+ throw new Error('disk full')
197
+ },
198
+ updateText: () => {
199
+ throw new Error('disk full')
200
+ },
201
+ })
202
+ expect(() => observe(sentMessage(1, 'x'), { chat_id: CHAT })).not.toThrow()
203
+ })
204
+
205
+ it('evicts under load without losing the one-row-per-card guarantee', () => {
206
+ const store = fakeStore()
207
+ let now = 1_000
208
+ const observe = makeSystemMessageObserver(
209
+ { insert: store.insert, updateText: store.updateText, now: () => now },
210
+ { maxTracked: 8 },
211
+ )
212
+
213
+ for (let id = 1; id <= 64; id++) {
214
+ observe(sentMessage(id, `card ${id}`), { chat_id: CHAT, verb: 'boot-card' })
215
+ }
216
+ expect(store.rows.size).toBe(64)
217
+
218
+ // An evicted id re-observed: the insert is refused by the store, the probe
219
+ // finds a system row, and no duplicate appears.
220
+ now += DEFAULT_EDIT_REFRESH_MS + 1
221
+ observe(sentMessage(1, 'card 1 edited'), { chat_id: CHAT, verb: 'boot-card' })
222
+ expect(store.rows.size).toBe(64)
223
+ expect(store.rows.get(1)?.text).toBe('card 1 edited')
224
+ })
225
+ })