switchroom 0.20.21 → 0.21.0
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/auth-broker/index.js +1 -1
- package/dist/cli/switchroom.js +1953 -1539
- package/dist/host-control/main.js +286 -122
- package/dist/vault/approvals/kernel-server.js +1 -1
- package/dist/vault/broker/server.js +1 -1
- package/package.json +1 -1
- package/skills/switchroom-release/SKILL.md +12 -1
- package/telegram-plugin/dist/gateway/gateway.js +1405 -851
- 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 +42 -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 +242 -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/tests/buzz-mirror.test.ts +12 -1
- package/telegram-plugin/tests/card-history-lane.test.ts +394 -0
- package/telegram-plugin/tests/system-message-observer.test.ts +216 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +88 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +112 -11
- package/vendor/hindsight-memory/scripts/recall.py +11 -2
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +36 -0
- package/vendor/hindsight-memory/scripts/retain.py +6 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_retain_env.py +99 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_types_filter.py +81 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +113 -0
- package/vendor/hindsight-memory/settings.json +2 -2
- package/vendor/hindsight-memory/tests/test_config.py +8 -3
- package/vendor/hindsight-memory/tests/test_hooks.py +10 -1
- package/vendor/hindsight-memory/tests/test_retain_context.py +69 -0
|
@@ -0,0 +1,394 @@
|
|
|
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
|
+
|
|
53
|
+
const CHAT = '5550001'
|
|
54
|
+
const REPLY_TO_TEXT_MAX = 200
|
|
55
|
+
|
|
56
|
+
/** The gateway's own wiring, verbatim: history writers behind the observer. */
|
|
57
|
+
function makeObserver(now?: () => number) {
|
|
58
|
+
return makeSystemMessageObserver({
|
|
59
|
+
insert: recordSystemOutbound,
|
|
60
|
+
updateText: updateSystemOutboundText,
|
|
61
|
+
...(now != null ? { now } : {}),
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A Telegram Message response, as `robustApiCall` resolves with. */
|
|
66
|
+
function sentMessage(messageId: number, text: string) {
|
|
67
|
+
return { message_id: messageId, chat: { id: Number(CHAT) }, text }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The reply-antecedent lookup as gateway.ts binds it (#4571: includeSystem). */
|
|
71
|
+
function boundLookup(messageId: number) {
|
|
72
|
+
return lookupMessageRoleAndText(CHAT, messageId, { includeSystem: true })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeEnvelopeParams(overrides: Partial<EnvelopeBuildParams>): EnvelopeBuildParams {
|
|
76
|
+
return {
|
|
77
|
+
ctx: { message: { date: 1_700_000_000 } } as unknown as EnvelopeBuildParams['ctx'],
|
|
78
|
+
chat_id: CHAT,
|
|
79
|
+
messageThreadId: undefined,
|
|
80
|
+
msgId: 20268,
|
|
81
|
+
effectiveText: 'stop what you are doing and check the other repo',
|
|
82
|
+
imagePath: undefined,
|
|
83
|
+
attachment: undefined,
|
|
84
|
+
attachmentCount: 0,
|
|
85
|
+
extraMeta: {},
|
|
86
|
+
from: { id: 111, username: 'alice' },
|
|
87
|
+
access: { groups: {} },
|
|
88
|
+
isSteering: false,
|
|
89
|
+
isQueuedPrefix: false,
|
|
90
|
+
isQueuedMidTurn: false,
|
|
91
|
+
priorTurnInProgress: false,
|
|
92
|
+
secondsSinceTurnStart: undefined,
|
|
93
|
+
priorAssistantPreview: undefined,
|
|
94
|
+
replyToMessageId: undefined,
|
|
95
|
+
replyToTextEscaped: undefined,
|
|
96
|
+
replyToRole: undefined,
|
|
97
|
+
forwardOriginMeta: {},
|
|
98
|
+
topicFramingEnabled: false,
|
|
99
|
+
personDirectory: { byTelegramKey: {} },
|
|
100
|
+
isDmChatId: () => true,
|
|
101
|
+
...overrides,
|
|
102
|
+
} as EnvelopeBuildParams
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let stateDir: string
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
stateDir = mkdtempSync(join(tmpdir(), 'card-history-lane-'))
|
|
109
|
+
initHistory(stateDir, 30)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
afterEach(() => {
|
|
113
|
+
_resetForTests()
|
|
114
|
+
if (existsSync(stateDir)) rmSync(stateDir, { recursive: true, force: true })
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('a quote-reply to the live activity card is UNDERSTOOD, end to end', () => {
|
|
118
|
+
it('card posted → id resolvable → the reply carries role=system, the card kind, and its body', () => {
|
|
119
|
+
const observe = makeObserver()
|
|
120
|
+
const CARD_ID = 20267
|
|
121
|
+
|
|
122
|
+
// 1. The gateway opens the activity card. No `recordOutbound` anywhere —
|
|
123
|
+
// this is the exact path that used to leave NO row at all.
|
|
124
|
+
observe(sentMessage(CARD_ID, '⚙️ Working — reading history.ts, 3 tools'), {
|
|
125
|
+
chat_id: CHAT,
|
|
126
|
+
verb: 'activity-summary.send',
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// 2. The operator quote-replies to it. Telegram gives the id but NOT the
|
|
130
|
+
// text (the bot authored it), so the live reply text is empty.
|
|
131
|
+
const resolved = resolveReplyToFromBuffer({
|
|
132
|
+
replyToMessageId: CARD_ID,
|
|
133
|
+
replyToText: undefined,
|
|
134
|
+
replyToTextEscaped: undefined,
|
|
135
|
+
historyEnabled: true,
|
|
136
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
137
|
+
lookup: boundLookup,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// 3. The antecedent resolves — this is the assertion the bug fails.
|
|
141
|
+
expect(resolved.replyToRole).toBe('system')
|
|
142
|
+
expect(resolved.replyToKind).toBe('activity-summary')
|
|
143
|
+
expect(resolved.replyToText).toBe('⚙️ Working — reading history.ts, 3 tools')
|
|
144
|
+
|
|
145
|
+
// 4. …and reaches the agent on the inbound envelope, so it can read the
|
|
146
|
+
// card body instead of reporting amnesia.
|
|
147
|
+
const msg = buildInboundEnvelope(
|
|
148
|
+
makeEnvelopeParams({
|
|
149
|
+
replyToMessageId: CARD_ID,
|
|
150
|
+
replyToTextEscaped: resolved.replyToTextEscaped,
|
|
151
|
+
replyToRole: resolved.replyToRole,
|
|
152
|
+
replyToKind: resolved.replyToKind,
|
|
153
|
+
}),
|
|
154
|
+
)
|
|
155
|
+
expect(msg.meta?.reply_to_message_id).toBe(String(CARD_ID))
|
|
156
|
+
expect(msg.meta?.reply_to_role).toBe('system')
|
|
157
|
+
expect(msg.meta?.reply_to_kind).toBe('activity-summary')
|
|
158
|
+
expect(msg.meta?.reply_to_text).toBe('⚙️ Working — reading history.ts, 3 tools')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('WITHOUT the observer the same reply resolves to nothing (the bug, pinned)', () => {
|
|
162
|
+
// No observe() call — i.e. pre-#4571 behaviour for a card send.
|
|
163
|
+
const resolved = resolveReplyToFromBuffer({
|
|
164
|
+
replyToMessageId: 20267,
|
|
165
|
+
replyToText: undefined,
|
|
166
|
+
replyToTextEscaped: undefined,
|
|
167
|
+
historyEnabled: true,
|
|
168
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
169
|
+
lookup: boundLookup,
|
|
170
|
+
})
|
|
171
|
+
expect(resolved.replyToRole).toBeUndefined()
|
|
172
|
+
expect(resolved.replyToText).toBeUndefined()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('surfaces the LATEST card body, not the text it was opened with', () => {
|
|
176
|
+
let t = 1_000
|
|
177
|
+
const observe = makeObserver(() => t)
|
|
178
|
+
observe(sentMessage(20267, '⚙️ Working — starting'), {
|
|
179
|
+
chat_id: CHAT,
|
|
180
|
+
verb: 'activity-summary.send',
|
|
181
|
+
})
|
|
182
|
+
t += 60_000
|
|
183
|
+
observe(sentMessage(20267, '⚙️ Working — 11 tools, editing gateway.ts'), {
|
|
184
|
+
chat_id: CHAT,
|
|
185
|
+
verb: 'activity-summary.edit',
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const resolved = resolveReplyToFromBuffer({
|
|
189
|
+
replyToMessageId: 20267,
|
|
190
|
+
replyToText: undefined,
|
|
191
|
+
replyToTextEscaped: undefined,
|
|
192
|
+
historyEnabled: true,
|
|
193
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
194
|
+
lookup: boundLookup,
|
|
195
|
+
})
|
|
196
|
+
expect(resolved.replyToText).toBe('⚙️ Working — 11 tools, editing gateway.ts')
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('the card lane does not pollute normal history reads', () => {
|
|
201
|
+
it('query() (get_recent_messages) lists the conversation, never the cards', () => {
|
|
202
|
+
const observe = makeObserver()
|
|
203
|
+
recordInbound({
|
|
204
|
+
chat_id: CHAT,
|
|
205
|
+
thread_id: null,
|
|
206
|
+
message_id: 20200,
|
|
207
|
+
user: 'alice',
|
|
208
|
+
user_id: '111',
|
|
209
|
+
ts: 1_000,
|
|
210
|
+
text: 'what is the status of the migration?',
|
|
211
|
+
})
|
|
212
|
+
for (const id of [20209, 20210, 20212, 20213]) {
|
|
213
|
+
observe(sentMessage(id, `card ${id}`), { chat_id: CHAT, verb: 'activity-summary.send' })
|
|
214
|
+
}
|
|
215
|
+
recordOutbound({
|
|
216
|
+
chat_id: CHAT,
|
|
217
|
+
thread_id: null,
|
|
218
|
+
message_ids: [20214],
|
|
219
|
+
texts: ['Migration is applied on all three boxes.'],
|
|
220
|
+
ts: 2_000,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
const listed = query({ chat_id: CHAT, limit: 50 })
|
|
224
|
+
expect(listed.map((r) => r.message_id)).toEqual([20200, 20214])
|
|
225
|
+
expect(listed.some((r) => r.role === 'system')).toBe(false)
|
|
226
|
+
|
|
227
|
+
// Opt-in still sees them — resolvable, just not listed.
|
|
228
|
+
const withCards = query({ chat_id: CHAT, limit: 50, include_system: true })
|
|
229
|
+
expect(withCards.map((r) => r.message_id).sort((a, b) => a - b)).toEqual([
|
|
230
|
+
20200, 20209, 20210, 20212, 20213, 20214,
|
|
231
|
+
])
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('an authorship-sensitive lookup (reaction trigger) still cannot see a card', () => {
|
|
235
|
+
makeObserver()(sentMessage(20267, 'card'), { chat_id: CHAT, verb: 'activity-summary.send' })
|
|
236
|
+
// Default (no includeSystem) — the pre-#4571 contract, unchanged.
|
|
237
|
+
expect(lookupMessageRoleAndText(CHAT, 20267)).toBeNull()
|
|
238
|
+
expect(lookupMessageRoleAndText(CHAT, 20267, { includeSystem: true })?.role).toBe('system')
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
describe('a real reply always beats the provisional card row', () => {
|
|
243
|
+
it('recordOutbound promotes the id to assistant and leaves exactly one row', () => {
|
|
244
|
+
const observe = makeObserver()
|
|
245
|
+
const ID = 20261
|
|
246
|
+
// The observer fires first — it runs when the API call resolves, strictly
|
|
247
|
+
// before the caller reaches its own recordOutbound.
|
|
248
|
+
observe(sentMessage(ID, 'Migration is applied on all three boxes.'), {
|
|
249
|
+
chat_id: CHAT,
|
|
250
|
+
verb: 'reply',
|
|
251
|
+
})
|
|
252
|
+
recordOutbound({
|
|
253
|
+
chat_id: CHAT,
|
|
254
|
+
thread_id: null,
|
|
255
|
+
message_ids: [ID],
|
|
256
|
+
texts: ['Migration is applied on all three boxes.'],
|
|
257
|
+
ts: 2_000,
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
const rows = query({ chat_id: CHAT, limit: 50, include_system: true })
|
|
261
|
+
expect(rows.filter((r) => r.message_id === ID)).toHaveLength(1)
|
|
262
|
+
expect(rows[0]?.role).toBe('assistant')
|
|
263
|
+
// And a reply pointing at it reads as an ANSWER, not a card.
|
|
264
|
+
const resolved = resolveReplyToFromBuffer({
|
|
265
|
+
replyToMessageId: ID,
|
|
266
|
+
replyToText: undefined,
|
|
267
|
+
replyToTextEscaped: undefined,
|
|
268
|
+
historyEnabled: true,
|
|
269
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
270
|
+
lookup: boundLookup,
|
|
271
|
+
})
|
|
272
|
+
expect(resolved.replyToRole).toBe('assistant')
|
|
273
|
+
expect(resolved.replyToKind).toBeUndefined()
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('promotes even when the observer recorded a DIFFERENT thread for the id', () => {
|
|
277
|
+
// Telegram stamps message_thread_id on reply chains in plain supergroups,
|
|
278
|
+
// so the observer's thread can differ from the gateway's. The unique key
|
|
279
|
+
// folds thread_id, so without the explicit delete BOTH rows would survive
|
|
280
|
+
// and the card could shadow the answer.
|
|
281
|
+
recordSystemOutbound({
|
|
282
|
+
chat_id: CHAT,
|
|
283
|
+
thread_id: 77,
|
|
284
|
+
message_id: 20263,
|
|
285
|
+
kind: 'activity-summary',
|
|
286
|
+
text: 'card',
|
|
287
|
+
})
|
|
288
|
+
recordOutbound({
|
|
289
|
+
chat_id: CHAT,
|
|
290
|
+
thread_id: null,
|
|
291
|
+
message_ids: [20263],
|
|
292
|
+
texts: ['the real answer'],
|
|
293
|
+
ts: 2_000,
|
|
294
|
+
})
|
|
295
|
+
const rows = query({ chat_id: CHAT, limit: 50, include_system: true })
|
|
296
|
+
expect(rows).toHaveLength(1)
|
|
297
|
+
expect(rows[0]?.role).toBe('assistant')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('a card never overwrites an inbound that already owns the id', () => {
|
|
301
|
+
recordInbound({
|
|
302
|
+
chat_id: CHAT,
|
|
303
|
+
thread_id: null,
|
|
304
|
+
message_id: 20300,
|
|
305
|
+
user: 'alice',
|
|
306
|
+
user_id: '111',
|
|
307
|
+
ts: 1_000,
|
|
308
|
+
text: 'the operator said this',
|
|
309
|
+
})
|
|
310
|
+
expect(
|
|
311
|
+
recordSystemOutbound({
|
|
312
|
+
chat_id: CHAT,
|
|
313
|
+
thread_id: null,
|
|
314
|
+
message_id: 20300,
|
|
315
|
+
kind: 'activity-summary',
|
|
316
|
+
text: 'card',
|
|
317
|
+
}),
|
|
318
|
+
).toBe(false)
|
|
319
|
+
expect(updateSystemOutboundText({ chat_id: CHAT, message_id: 20300, text: 'card' })).toBe(false)
|
|
320
|
+
const row = lookupMessageRoleAndText(CHAT, 20300, { includeSystem: true })
|
|
321
|
+
expect(row?.role).toBe('user')
|
|
322
|
+
expect(row?.text).toBe('the operator said this')
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
describe('migration onto a live pre-#4571 history.db', () => {
|
|
327
|
+
it('adds `kind` without losing a row, and is a no-op when already migrated', () => {
|
|
328
|
+
_resetForTests()
|
|
329
|
+
const legacyDir = mkdtempSync(join(tmpdir(), 'card-history-legacy-'))
|
|
330
|
+
try {
|
|
331
|
+
// A pre-#4571 schema: no `kind` column, no logical-key index.
|
|
332
|
+
const raw = new Database(join(legacyDir, 'history.db'))
|
|
333
|
+
raw.exec(`
|
|
334
|
+
CREATE TABLE messages (
|
|
335
|
+
chat_id TEXT NOT NULL, thread_id INTEGER, message_id INTEGER NOT NULL,
|
|
336
|
+
role TEXT NOT NULL, user TEXT, user_id TEXT, ts INTEGER NOT NULL,
|
|
337
|
+
text TEXT NOT NULL, attachment_kind TEXT, group_id INTEGER,
|
|
338
|
+
PRIMARY KEY (chat_id, thread_id, message_id)
|
|
339
|
+
)
|
|
340
|
+
`)
|
|
341
|
+
const ins = raw.prepare(
|
|
342
|
+
`INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text)
|
|
343
|
+
VALUES (?, NULL, ?, ?, ?, ?, ?, ?)`,
|
|
344
|
+
)
|
|
345
|
+
const nowSec = Math.floor(Date.now() / 1000)
|
|
346
|
+
for (let i = 0; i < 200; i++) {
|
|
347
|
+
ins.run(CHAT, 19000 + i, i % 2 === 0 ? 'user' : 'assistant', 'alice', '111', nowSec, `row ${i}`)
|
|
348
|
+
}
|
|
349
|
+
raw.close()
|
|
350
|
+
|
|
351
|
+
const countRows = () => {
|
|
352
|
+
const d = new Database(join(legacyDir, 'history.db'), { readonly: true })
|
|
353
|
+
try {
|
|
354
|
+
return (d.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c
|
|
355
|
+
} finally {
|
|
356
|
+
d.close()
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
expect(countRows()).toBe(200)
|
|
360
|
+
|
|
361
|
+
// First migration.
|
|
362
|
+
initHistory(legacyDir, 30)
|
|
363
|
+
expect(countRows()).toBe(200)
|
|
364
|
+
// The new column is usable, and every legacy row kept its exact contents.
|
|
365
|
+
expect(lookupMessageRoleAndText(CHAT, 19000)).toEqual({
|
|
366
|
+
role: 'user',
|
|
367
|
+
text: 'row 0',
|
|
368
|
+
kind: null,
|
|
369
|
+
})
|
|
370
|
+
recordSystemOutbound({
|
|
371
|
+
chat_id: CHAT,
|
|
372
|
+
thread_id: null,
|
|
373
|
+
message_id: 19500,
|
|
374
|
+
kind: 'boot-card',
|
|
375
|
+
text: 'booted',
|
|
376
|
+
})
|
|
377
|
+
_resetForTests()
|
|
378
|
+
|
|
379
|
+
// Re-run against the ALREADY-migrated file: idempotent, non-destructive.
|
|
380
|
+
initHistory(legacyDir, 30)
|
|
381
|
+
expect(countRows()).toBe(201)
|
|
382
|
+
expect(lookupMessageRoleAndText(CHAT, 19500, { includeSystem: true })).toEqual({
|
|
383
|
+
role: 'system',
|
|
384
|
+
text: 'booted',
|
|
385
|
+
kind: 'boot-card',
|
|
386
|
+
})
|
|
387
|
+
_resetForTests()
|
|
388
|
+
} finally {
|
|
389
|
+
rmSync(legacyDir, { recursive: true, force: true })
|
|
390
|
+
// Restore the per-test DB the shared afterEach expects to close.
|
|
391
|
+
initHistory(stateDir, 30)
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
})
|
|
@@ -0,0 +1,216 @@
|
|
|
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
|
+
/** A Telegram `Message` response as the Bot API returns it from sendMessage /
|
|
31
|
+
* editMessageText. */
|
|
32
|
+
function sentMessage(messageId: number, text: string, threadId?: number) {
|
|
33
|
+
return {
|
|
34
|
+
message_id: messageId,
|
|
35
|
+
chat: { id: Number(CHAT) },
|
|
36
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
37
|
+
text,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A writer pair backed by a Map, mirroring recordSystemOutbound /
|
|
42
|
+
* updateSystemOutboundText's real return contract. */
|
|
43
|
+
function fakeStore(seed?: Record<number, { role: 'system' | 'other'; text: string }>) {
|
|
44
|
+
const rows = new Map<number, { role: 'system' | 'other'; text: string; kind: string | null }>()
|
|
45
|
+
for (const [id, r] of Object.entries(seed ?? {})) {
|
|
46
|
+
rows.set(Number(id), { role: r.role, text: r.text, kind: null })
|
|
47
|
+
}
|
|
48
|
+
const calls = { insert: 0, update: 0 }
|
|
49
|
+
return {
|
|
50
|
+
rows,
|
|
51
|
+
calls,
|
|
52
|
+
insert(args: { message_id: number; kind: string | null; text: string }): boolean {
|
|
53
|
+
calls.insert++
|
|
54
|
+
if (rows.has(args.message_id)) return false
|
|
55
|
+
rows.set(args.message_id, { role: 'system', text: args.text, kind: args.kind })
|
|
56
|
+
return true
|
|
57
|
+
},
|
|
58
|
+
updateText(args: { message_id: number; text: string }): boolean {
|
|
59
|
+
calls.update++
|
|
60
|
+
const row = rows.get(args.message_id)
|
|
61
|
+
if (row == null || row.role !== 'system') return false
|
|
62
|
+
row.text = args.text
|
|
63
|
+
return true
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('normalizeSendVerb', () => {
|
|
69
|
+
it('strips the transport suffix so a card OPEN and its EDITs classify identically', () => {
|
|
70
|
+
expect(normalizeSendVerb('activity-summary.send')).toBe('activity-summary')
|
|
71
|
+
expect(normalizeSendVerb('activity-summary.edit')).toBe('activity-summary')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('passes a suffix-less verb through and returns null for an untagged send', () => {
|
|
75
|
+
expect(normalizeSendVerb('boot-card')).toBe('boot-card')
|
|
76
|
+
expect(normalizeSendVerb(undefined)).toBeNull()
|
|
77
|
+
expect(normalizeSendVerb(' ')).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('extractSentMessage', () => {
|
|
82
|
+
it('ignores non-Message results (pins/deletes/reactions return true, swallowed 400s undefined)', () => {
|
|
83
|
+
expect(extractSentMessage(true, { chat_id: CHAT })).toBeNull()
|
|
84
|
+
expect(extractSentMessage(undefined, { chat_id: CHAT })).toBeNull()
|
|
85
|
+
expect(extractSentMessage({ ok: true }, { chat_id: CHAT })).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("prefers the response's own chat and thread over the caller's opts", () => {
|
|
89
|
+
const out = extractSentMessage(sentMessage(20267, 'card body', 77), {
|
|
90
|
+
chat_id: 'wrong',
|
|
91
|
+
threadId: 5,
|
|
92
|
+
})
|
|
93
|
+
expect(out).toEqual({ chatId: CHAT, messageId: 20267, threadId: 77, text: 'card body' })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('falls back to the caller chat/thread when the response omits them', () => {
|
|
97
|
+
const out = extractSentMessage({ message_id: 9, text: 'x' }, { chat_id: CHAT, threadId: 5 })
|
|
98
|
+
expect(out).toEqual({ chatId: CHAT, messageId: 9, threadId: 5, text: 'x' })
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('makeSystemMessageObserver', () => {
|
|
103
|
+
it('records a posted card once, and an EDIT of it never adds a second row', () => {
|
|
104
|
+
const store = fakeStore()
|
|
105
|
+
let now = 1_000
|
|
106
|
+
const observe = makeSystemMessageObserver({ insert: store.insert, updateText: store.updateText, now: () => now })
|
|
107
|
+
|
|
108
|
+
observe(sentMessage(20267, 'Working… reading history.ts'), {
|
|
109
|
+
chat_id: CHAT,
|
|
110
|
+
verb: 'activity-summary.send',
|
|
111
|
+
})
|
|
112
|
+
expect(store.rows.size).toBe(1)
|
|
113
|
+
expect(store.rows.get(20267)?.kind).toBe('activity-summary')
|
|
114
|
+
|
|
115
|
+
// 40 edits of the SAME card, spread well past the refresh window.
|
|
116
|
+
for (let i = 0; i < 40; i++) {
|
|
117
|
+
now += DEFAULT_EDIT_REFRESH_MS + 1
|
|
118
|
+
observe(sentMessage(20267, `Working… step ${i}`), {
|
|
119
|
+
chat_id: CHAT,
|
|
120
|
+
verb: 'activity-summary.edit',
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
// The outcome that matters: still exactly one row for that card.
|
|
124
|
+
expect(store.rows.size).toBe(1)
|
|
125
|
+
// …carrying a recent snapshot of the card body, not the stale open text.
|
|
126
|
+
expect(store.rows.get(20267)?.text).toBe('Working… step 39')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('does no DB work at all for edits inside the refresh window', () => {
|
|
130
|
+
const store = fakeStore()
|
|
131
|
+
let now = 1_000
|
|
132
|
+
const observe = makeSystemMessageObserver({ insert: store.insert, updateText: store.updateText, now: () => now })
|
|
133
|
+
|
|
134
|
+
observe(sentMessage(30, 'open'), { chat_id: CHAT, verb: 'activity-summary.send' })
|
|
135
|
+
const afterOpen = { ...store.calls }
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < 25; i++) {
|
|
138
|
+
now += 100 // ~2.5s of a climbing activity card
|
|
139
|
+
observe(sentMessage(30, `tick ${i}`), { chat_id: CHAT, verb: 'activity-summary.edit' })
|
|
140
|
+
}
|
|
141
|
+
expect(store.calls.insert).toBe(afterOpen.insert)
|
|
142
|
+
expect(store.calls.update).toBe(afterOpen.update)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('leaves a real reply / inbound alone and stops probing it', () => {
|
|
146
|
+
// 4242 already belongs to a non-system row (recordOutbound got there first).
|
|
147
|
+
const store = fakeStore({ 4242: { role: 'other', text: 'the real answer' } })
|
|
148
|
+
let now = 1_000
|
|
149
|
+
const observe = makeSystemMessageObserver({ insert: store.insert, updateText: store.updateText, now: () => now })
|
|
150
|
+
|
|
151
|
+
observe(sentMessage(4242, 'edited answer'), { chat_id: CHAT, verb: 'reply' })
|
|
152
|
+
expect(store.rows.get(4242)).toEqual({ role: 'other', text: 'the real answer', kind: null })
|
|
153
|
+
|
|
154
|
+
const afterProbe = { ...store.calls }
|
|
155
|
+
for (let i = 0; i < 10; i++) {
|
|
156
|
+
now += DEFAULT_EDIT_REFRESH_MS + 1
|
|
157
|
+
observe(sentMessage(4242, `edited answer ${i}`), { chat_id: CHAT, verb: 'reply' })
|
|
158
|
+
}
|
|
159
|
+
// One probing update total — then the id is marked foreign forever.
|
|
160
|
+
expect(store.calls.insert).toBe(afterProbe.insert)
|
|
161
|
+
expect(store.calls.update).toBe(afterProbe.update)
|
|
162
|
+
expect(store.rows.get(4242)?.text).toBe('the real answer')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('demotes a card that got promoted to a real reply mid-turn', () => {
|
|
166
|
+
const store = fakeStore()
|
|
167
|
+
let now = 1_000
|
|
168
|
+
const observe = makeSystemMessageObserver({ insert: store.insert, updateText: store.updateText, now: () => now })
|
|
169
|
+
|
|
170
|
+
observe(sentMessage(55, 'card'), { chat_id: CHAT, verb: 'activity-summary.send' })
|
|
171
|
+
// recordOutbound promotes the row to a real assistant reply.
|
|
172
|
+
store.rows.set(55, { role: 'other', text: 'the real answer', kind: null })
|
|
173
|
+
|
|
174
|
+
now += DEFAULT_EDIT_REFRESH_MS + 1
|
|
175
|
+
observe(sentMessage(55, 'card edit'), { chat_id: CHAT, verb: 'activity-summary.edit' })
|
|
176
|
+
expect(store.rows.get(55)?.text).toBe('the real answer')
|
|
177
|
+
|
|
178
|
+
const after = { ...store.calls }
|
|
179
|
+
now += DEFAULT_EDIT_REFRESH_MS + 1
|
|
180
|
+
observe(sentMessage(55, 'card edit 2'), { chat_id: CHAT, verb: 'activity-summary.edit' })
|
|
181
|
+
expect(store.calls.update).toBe(after.update)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('never throws when the writer blows up — observing a send cannot break the send', () => {
|
|
185
|
+
const observe = makeSystemMessageObserver({
|
|
186
|
+
insert: () => {
|
|
187
|
+
throw new Error('disk full')
|
|
188
|
+
},
|
|
189
|
+
updateText: () => {
|
|
190
|
+
throw new Error('disk full')
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
expect(() => observe(sentMessage(1, 'x'), { chat_id: CHAT })).not.toThrow()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('evicts under load without losing the one-row-per-card guarantee', () => {
|
|
197
|
+
const store = fakeStore()
|
|
198
|
+
let now = 1_000
|
|
199
|
+
const observe = makeSystemMessageObserver(
|
|
200
|
+
{ insert: store.insert, updateText: store.updateText, now: () => now },
|
|
201
|
+
{ maxTracked: 8 },
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
for (let id = 1; id <= 64; id++) {
|
|
205
|
+
observe(sentMessage(id, `card ${id}`), { chat_id: CHAT, verb: 'boot-card' })
|
|
206
|
+
}
|
|
207
|
+
expect(store.rows.size).toBe(64)
|
|
208
|
+
|
|
209
|
+
// An evicted id re-observed: the insert is refused by the store, the probe
|
|
210
|
+
// finds a system row, and no duplicate appears.
|
|
211
|
+
now += DEFAULT_EDIT_REFRESH_MS + 1
|
|
212
|
+
observe(sentMessage(1, 'card 1 edited'), { chat_id: CHAT, verb: 'boot-card' })
|
|
213
|
+
expect(store.rows.size).toBe(64)
|
|
214
|
+
expect(store.rows.get(1)?.text).toBe('card 1 edited')
|
|
215
|
+
})
|
|
216
|
+
})
|