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.
- package/dist/cli/switchroom.js +1952 -1538
- package/dist/host-control/main.js +285 -121
- package/package.json +1 -1
- package/skills/switchroom-release/SKILL.md +12 -1
- package/telegram-plugin/dist/gateway/gateway.js +1551 -850
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +2 -2
- package/telegram-plugin/gateway/boot-beacon.ts +9 -2
- package/telegram-plugin/gateway/gateway-heartbeat.ts +4 -3
- package/telegram-plugin/gateway/gateway.ts +50 -15
- package/telegram-plugin/gateway/inbound-router.ts +31 -6
- package/telegram-plugin/gateway/missed-approvals-store.ts +2 -2
- package/telegram-plugin/gateway/pending-card-store.ts +2 -2
- package/telegram-plugin/gateway/privacy-state.ts +2 -2
- package/telegram-plugin/gateway/scoped-grant-store.ts +2 -2
- package/telegram-plugin/gateway/system-message-observer.ts +418 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +3 -4
- package/telegram-plugin/history.ts +178 -11
- package/telegram-plugin/registry/turns-schema.ts +8 -2
- package/telegram-plugin/shared/sent-text-capture.ts +191 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +12 -1
- package/telegram-plugin/tests/card-history-lane.test.ts +514 -0
- package/telegram-plugin/tests/sent-text-capture.test.ts +526 -0
- package/telegram-plugin/tests/system-message-observer.test.ts +225 -0
|
@@ -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
|
+
})
|