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,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
|
+
})
|