switchroom 0.13.65 → 0.14.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/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +96 -81
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +1883 -1479
- package/dist/host-control/main.js +149 -149
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +1 -1
- package/profiles/_shared/telegram-style.md.hbs +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +47 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +1226 -696
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/boot-card.ts +100 -0
- package/telegram-plugin/gateway/config-snapshot.ts +274 -0
- package/telegram-plugin/gateway/gateway.ts +256 -36
- package/telegram-plugin/operator-events.ts +2 -10
- package/telegram-plugin/quota-watch.ts +276 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +133 -1
- package/telegram-plugin/tests/boot-card-render.test.ts +93 -0
- package/telegram-plugin/tests/config-snapshot.test.ts +409 -0
- package/telegram-plugin/tests/operator-events.test.ts +12 -6
- package/telegram-plugin/tests/quota-watch.test.ts +366 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +66 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +48 -0
- package/telegram-plugin/tool-activity-summary.ts +137 -0
- package/telegram-plugin/turn-flush-safety.ts +47 -0
- package/telegram-plugin/uat/assertions.ts +4 -4
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the config-snapshot module (config-snapshot.ts).
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* 1. captureConfigSnapshot — field normalization, null handling
|
|
6
|
+
* 2. diffSnapshots — model changed only; tools changed only; everything
|
|
7
|
+
* changed; nothing changed; first boot (previous=null)
|
|
8
|
+
* 3. renderConfigChangeDim — verbatim for model/memory, coarse for tools/skills
|
|
9
|
+
* 4. loadSnapshot / persistSnapshot — round-trip, corrupt file, first boot
|
|
10
|
+
* 5. hashStringArray — deterministic, order-independent, null/empty handling
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
14
|
+
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'fs'
|
|
15
|
+
import { tmpdir } from 'os'
|
|
16
|
+
import { join } from 'path'
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
captureConfigSnapshot,
|
|
20
|
+
diffSnapshots,
|
|
21
|
+
renderConfigChangeDim,
|
|
22
|
+
hashStringArray,
|
|
23
|
+
normalizeModel,
|
|
24
|
+
loadSnapshot,
|
|
25
|
+
persistSnapshot,
|
|
26
|
+
type ConfigSnapshot,
|
|
27
|
+
type ConfigDiff,
|
|
28
|
+
} from '../gateway/config-snapshot.js'
|
|
29
|
+
|
|
30
|
+
let tmp: string
|
|
31
|
+
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'cfg-snap-')) })
|
|
32
|
+
afterEach(() => { rmSync(tmp, { recursive: true, force: true }) })
|
|
33
|
+
|
|
34
|
+
// ── hashStringArray ───────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
describe('hashStringArray', () => {
|
|
37
|
+
it('returns null for null input', () => {
|
|
38
|
+
expect(hashStringArray(null)).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns null for empty array', () => {
|
|
42
|
+
expect(hashStringArray([])).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('returns a 12-char hex string for a non-empty array', () => {
|
|
46
|
+
const h = hashStringArray(['bash', 'computer'])
|
|
47
|
+
expect(typeof h).toBe('string')
|
|
48
|
+
expect(h!.length).toBe(12)
|
|
49
|
+
expect(/^[0-9a-f]{12}$/.test(h!)).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('is order-independent — sorted before hashing', () => {
|
|
53
|
+
const h1 = hashStringArray(['bash', 'computer', 'str_replace_based_edit_tool'])
|
|
54
|
+
const h2 = hashStringArray(['str_replace_based_edit_tool', 'bash', 'computer'])
|
|
55
|
+
expect(h1).toBe(h2)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('produces different hashes for different content', () => {
|
|
59
|
+
const h1 = hashStringArray(['bash'])
|
|
60
|
+
const h2 = hashStringArray(['computer'])
|
|
61
|
+
expect(h1).not.toBe(h2)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// ── normalizeModel ────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
describe('normalizeModel', () => {
|
|
68
|
+
it('lowercases the model string', () => {
|
|
69
|
+
expect(normalizeModel('Claude-Sonnet-4-5')).toBe('claude-sonnet-4-5')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('trims whitespace', () => {
|
|
73
|
+
expect(normalizeModel(' claude-opus-4 ')).toBe('claude-opus-4')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('returns null for null, undefined, or empty string', () => {
|
|
77
|
+
expect(normalizeModel(null)).toBeNull()
|
|
78
|
+
expect(normalizeModel(undefined)).toBeNull()
|
|
79
|
+
expect(normalizeModel('')).toBeNull()
|
|
80
|
+
expect(normalizeModel(' ')).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// ── captureConfigSnapshot ────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe('captureConfigSnapshot', () => {
|
|
87
|
+
it('captures all fields from config', () => {
|
|
88
|
+
const snap = captureConfigSnapshot({
|
|
89
|
+
agentName: 'finn',
|
|
90
|
+
model: 'claude-sonnet-4-5',
|
|
91
|
+
toolsAllow: ['bash', 'computer'],
|
|
92
|
+
skills: ['search'],
|
|
93
|
+
memoryCollection: 'finn-memories',
|
|
94
|
+
now: () => 1000,
|
|
95
|
+
})
|
|
96
|
+
expect(snap.schema).toBe(1)
|
|
97
|
+
expect(snap.capturedAtMs).toBe(1000)
|
|
98
|
+
expect(snap.model).toBe('claude-sonnet-4-5')
|
|
99
|
+
expect(snap.toolsHash).not.toBeNull()
|
|
100
|
+
expect(snap.skillsHash).not.toBeNull()
|
|
101
|
+
expect(snap.memoryBackend).toBe('finn-memories')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('produces null fields when config fields are absent', () => {
|
|
105
|
+
const snap = captureConfigSnapshot({ agentName: 'finn' })
|
|
106
|
+
expect(snap.model).toBeNull()
|
|
107
|
+
expect(snap.toolsHash).toBeNull()
|
|
108
|
+
expect(snap.skillsHash).toBeNull()
|
|
109
|
+
expect(snap.memoryBackend).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('normalizes model to lowercase', () => {
|
|
113
|
+
const snap = captureConfigSnapshot({ agentName: 'finn', model: 'Claude-Sonnet-4-5' })
|
|
114
|
+
expect(snap.model).toBe('claude-sonnet-4-5')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('tools hash is order-independent', () => {
|
|
118
|
+
const a = captureConfigSnapshot({ agentName: 'x', toolsAllow: ['bash', 'computer'] })
|
|
119
|
+
const b = captureConfigSnapshot({ agentName: 'x', toolsAllow: ['computer', 'bash'] })
|
|
120
|
+
expect(a.toolsHash).toBe(b.toolsHash)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// ── diffSnapshots ─────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function makeSnap(overrides: Partial<ConfigSnapshot> = {}): ConfigSnapshot {
|
|
127
|
+
return {
|
|
128
|
+
schema: 1,
|
|
129
|
+
capturedAtMs: 1000,
|
|
130
|
+
model: 'claude-sonnet-4-5',
|
|
131
|
+
toolsHash: hashStringArray(['bash', 'computer']),
|
|
132
|
+
skillsHash: hashStringArray(['search']),
|
|
133
|
+
memoryBackend: 'finn',
|
|
134
|
+
...overrides,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe('diffSnapshots', () => {
|
|
139
|
+
it('returns empty diff when previous is null (first boot)', () => {
|
|
140
|
+
const current = makeSnap()
|
|
141
|
+
expect(diffSnapshots(current, null)).toEqual([])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('returns empty diff when nothing changed', () => {
|
|
145
|
+
const snap = makeSnap()
|
|
146
|
+
expect(diffSnapshots(snap, snap)).toEqual([])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('detects model change only', () => {
|
|
150
|
+
const prev = makeSnap({ model: 'claude-opus-4' })
|
|
151
|
+
const curr = makeSnap({ model: 'claude-sonnet-4-5' })
|
|
152
|
+
const diff = diffSnapshots(curr, prev)
|
|
153
|
+
expect(diff).toHaveLength(1)
|
|
154
|
+
expect(diff[0].field).toBe('model')
|
|
155
|
+
expect(diff[0].from).toBe('claude-opus-4')
|
|
156
|
+
expect(diff[0].to).toBe('claude-sonnet-4-5')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('detects tools allowlist change only', () => {
|
|
160
|
+
const prevHash = hashStringArray(['bash'])
|
|
161
|
+
const currHash = hashStringArray(['bash', 'computer'])
|
|
162
|
+
const prev = makeSnap({ toolsHash: prevHash })
|
|
163
|
+
const curr = makeSnap({ toolsHash: currHash })
|
|
164
|
+
const diff = diffSnapshots(curr, prev)
|
|
165
|
+
expect(diff).toHaveLength(1)
|
|
166
|
+
expect(diff[0].field).toBe('tools')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('detects skills change only', () => {
|
|
170
|
+
const prev = makeSnap({ skillsHash: hashStringArray(['search']) })
|
|
171
|
+
const curr = makeSnap({ skillsHash: hashStringArray(['search', 'code']) })
|
|
172
|
+
const diff = diffSnapshots(curr, prev)
|
|
173
|
+
expect(diff).toHaveLength(1)
|
|
174
|
+
expect(diff[0].field).toBe('skills')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('detects memory backend change only', () => {
|
|
178
|
+
const prev = makeSnap({ memoryBackend: 'finn' })
|
|
179
|
+
const curr = makeSnap({ memoryBackend: 'finn-v2' })
|
|
180
|
+
const diff = diffSnapshots(curr, prev)
|
|
181
|
+
expect(diff).toHaveLength(1)
|
|
182
|
+
expect(diff[0].field).toBe('memoryBackend')
|
|
183
|
+
expect(diff[0].from).toBe('finn')
|
|
184
|
+
expect(diff[0].to).toBe('finn-v2')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('detects all four fields changing at once', () => {
|
|
188
|
+
const prev = makeSnap({
|
|
189
|
+
model: 'claude-opus-4',
|
|
190
|
+
toolsHash: hashStringArray(['bash']),
|
|
191
|
+
skillsHash: hashStringArray(['search']),
|
|
192
|
+
memoryBackend: 'old-bank',
|
|
193
|
+
})
|
|
194
|
+
const curr = makeSnap({
|
|
195
|
+
model: 'claude-sonnet-4-5',
|
|
196
|
+
toolsHash: hashStringArray(['bash', 'computer']),
|
|
197
|
+
skillsHash: hashStringArray(['search', 'code']),
|
|
198
|
+
memoryBackend: 'new-bank',
|
|
199
|
+
})
|
|
200
|
+
const diff = diffSnapshots(curr, prev)
|
|
201
|
+
expect(diff).toHaveLength(4)
|
|
202
|
+
const fields = diff.map(d => d.field)
|
|
203
|
+
expect(fields).toContain('model')
|
|
204
|
+
expect(fields).toContain('tools')
|
|
205
|
+
expect(fields).toContain('skills')
|
|
206
|
+
expect(fields).toContain('memoryBackend')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('does not fire when both sides have null for the same field', () => {
|
|
210
|
+
const prev = makeSnap({ model: null })
|
|
211
|
+
const curr = makeSnap({ model: null })
|
|
212
|
+
const diff = diffSnapshots(curr, prev)
|
|
213
|
+
const modelDiff = diff.find(d => d.field === 'model')
|
|
214
|
+
expect(modelDiff).toBeUndefined()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('fires when model transitions from null to a value (first explicit model set)', () => {
|
|
218
|
+
const prev = makeSnap({ model: null })
|
|
219
|
+
const curr = makeSnap({ model: 'claude-sonnet-4-5' })
|
|
220
|
+
const diff = diffSnapshots(curr, prev)
|
|
221
|
+
const modelDiff = diff.find(d => d.field === 'model')
|
|
222
|
+
expect(modelDiff).toBeDefined()
|
|
223
|
+
expect(modelDiff!.from).toBeNull()
|
|
224
|
+
expect(modelDiff!.to).toBe('claude-sonnet-4-5')
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// ── renderConfigChangeDim ─────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
describe('renderConfigChangeDim', () => {
|
|
231
|
+
it('model: shows verbatim from → to', () => {
|
|
232
|
+
const row = renderConfigChangeDim({
|
|
233
|
+
field: 'model',
|
|
234
|
+
from: 'claude-opus-4',
|
|
235
|
+
to: 'claude-sonnet-4-5',
|
|
236
|
+
})
|
|
237
|
+
expect(row).toContain('claude-opus-4')
|
|
238
|
+
expect(row).toContain('claude-sonnet-4-5')
|
|
239
|
+
expect(row).toContain('→')
|
|
240
|
+
expect(row).toContain('model:')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('model: shows "(default)" when from or to is null', () => {
|
|
244
|
+
const row = renderConfigChangeDim({ field: 'model', from: null, to: 'claude-sonnet-4-5' })
|
|
245
|
+
expect(row).toContain('(default)')
|
|
246
|
+
expect(row).toContain('claude-sonnet-4-5')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('memoryBackend: shows verbatim from → to', () => {
|
|
250
|
+
const row = renderConfigChangeDim({
|
|
251
|
+
field: 'memoryBackend',
|
|
252
|
+
from: 'finn',
|
|
253
|
+
to: 'finn-v2',
|
|
254
|
+
})
|
|
255
|
+
expect(row).toContain('finn')
|
|
256
|
+
expect(row).toContain('finn-v2')
|
|
257
|
+
expect(row).toContain('memory backend:')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('tools: coarse message with /status hint', () => {
|
|
261
|
+
const row = renderConfigChangeDim({
|
|
262
|
+
field: 'tools',
|
|
263
|
+
from: 'abc123',
|
|
264
|
+
to: 'def456',
|
|
265
|
+
})
|
|
266
|
+
expect(row).toContain('tools allowlist changed')
|
|
267
|
+
expect(row).toContain('/status')
|
|
268
|
+
// Should NOT contain raw hashes in the user-facing text.
|
|
269
|
+
expect(row).not.toContain('abc123')
|
|
270
|
+
expect(row).not.toContain('def456')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('skills: coarse message with /status hint', () => {
|
|
274
|
+
const row = renderConfigChangeDim({
|
|
275
|
+
field: 'skills',
|
|
276
|
+
from: 'abc123',
|
|
277
|
+
to: 'def456',
|
|
278
|
+
})
|
|
279
|
+
expect(row).toContain('skills changed')
|
|
280
|
+
expect(row).toContain('/status')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('all rows start with the ⚙️ Config prefix', () => {
|
|
284
|
+
const fields: Array<ConfigDiff[number]['field']> = ['model', 'tools', 'skills', 'memoryBackend']
|
|
285
|
+
for (const field of fields) {
|
|
286
|
+
const row = renderConfigChangeDim({ field, from: 'a', to: 'b' })
|
|
287
|
+
expect(row).toContain('⚙️')
|
|
288
|
+
expect(row).toContain('<b>Config</b>')
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// ── loadSnapshot / persistSnapshot ───────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
describe('loadSnapshot / persistSnapshot — persistence', () => {
|
|
296
|
+
it('returns null when file does not exist (first boot)', () => {
|
|
297
|
+
const path = join(tmp, 'snap.json')
|
|
298
|
+
expect(loadSnapshot(path)).toBeNull()
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('round-trips: persist then load yields the same snapshot', () => {
|
|
302
|
+
const path = join(tmp, 'snap.json')
|
|
303
|
+
const snap = makeSnap({ capturedAtMs: 9999 })
|
|
304
|
+
persistSnapshot(path, snap)
|
|
305
|
+
expect(existsSync(path)).toBe(true)
|
|
306
|
+
const loaded = loadSnapshot(path)
|
|
307
|
+
expect(loaded).not.toBeNull()
|
|
308
|
+
expect(loaded!.model).toBe(snap.model)
|
|
309
|
+
expect(loaded!.toolsHash).toBe(snap.toolsHash)
|
|
310
|
+
expect(loaded!.skillsHash).toBe(snap.skillsHash)
|
|
311
|
+
expect(loaded!.memoryBackend).toBe(snap.memoryBackend)
|
|
312
|
+
expect(loaded!.capturedAtMs).toBe(9999)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('corrupt JSON file is renamed aside and null is returned', () => {
|
|
316
|
+
const path = join(tmp, 'snap.json')
|
|
317
|
+
writeFileSync(path, 'not-valid-json-{{{')
|
|
318
|
+
const loaded = loadSnapshot(path, () => 55555)
|
|
319
|
+
expect(loaded).toBeNull()
|
|
320
|
+
// Corrupt file preserved with timestamp suffix.
|
|
321
|
+
expect(existsSync(`${path}.corrupt-55555`)).toBe(true)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('schema mismatch returns null', () => {
|
|
325
|
+
const path = join(tmp, 'snap.json')
|
|
326
|
+
writeFileSync(path, JSON.stringify({ schema: 99, capturedAtMs: 1, model: null, toolsHash: null, skillsHash: null, memoryBackend: null }))
|
|
327
|
+
expect(loadSnapshot(path)).toBeNull()
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('persists and overwrites on subsequent calls (baseline update)', () => {
|
|
331
|
+
const path = join(tmp, 'snap.json')
|
|
332
|
+
const first = makeSnap({ model: 'claude-opus-4' })
|
|
333
|
+
const second = makeSnap({ model: 'claude-sonnet-4-5' })
|
|
334
|
+
persistSnapshot(path, first)
|
|
335
|
+
persistSnapshot(path, second)
|
|
336
|
+
const loaded = loadSnapshot(path)
|
|
337
|
+
expect(loaded!.model).toBe('claude-sonnet-4-5')
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
// ── Integration: full boot-to-boot lifecycle ──────────────────────────────────
|
|
342
|
+
|
|
343
|
+
describe('boot-to-boot lifecycle', () => {
|
|
344
|
+
it('first boot: no diff; subsequent boot with same config: no diff', () => {
|
|
345
|
+
const path = join(tmp, 'snap.json')
|
|
346
|
+
const snap1 = captureConfigSnapshot({
|
|
347
|
+
agentName: 'finn',
|
|
348
|
+
model: 'claude-sonnet-4-5',
|
|
349
|
+
toolsAllow: ['bash'],
|
|
350
|
+
skills: ['search'],
|
|
351
|
+
memoryCollection: 'finn',
|
|
352
|
+
now: () => 1000,
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
// First boot: no prior snapshot → no diff.
|
|
356
|
+
const prev1 = loadSnapshot(path)
|
|
357
|
+
const diff1 = diffSnapshots(snap1, prev1)
|
|
358
|
+
expect(diff1).toEqual([])
|
|
359
|
+
persistSnapshot(path, snap1)
|
|
360
|
+
|
|
361
|
+
// Second boot with SAME config → no diff.
|
|
362
|
+
const snap2 = captureConfigSnapshot({
|
|
363
|
+
agentName: 'finn',
|
|
364
|
+
model: 'claude-sonnet-4-5',
|
|
365
|
+
toolsAllow: ['bash'],
|
|
366
|
+
skills: ['search'],
|
|
367
|
+
memoryCollection: 'finn',
|
|
368
|
+
now: () => 2000,
|
|
369
|
+
})
|
|
370
|
+
const prev2 = loadSnapshot(path)
|
|
371
|
+
const diff2 = diffSnapshots(snap2, prev2)
|
|
372
|
+
expect(diff2).toEqual([])
|
|
373
|
+
persistSnapshot(path, snap2)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('third boot after model change: diff fires once, then silent again', () => {
|
|
377
|
+
const path = join(tmp, 'snap.json')
|
|
378
|
+
|
|
379
|
+
// Boot 1: snapshot with old model.
|
|
380
|
+
const snap1 = captureConfigSnapshot({
|
|
381
|
+
agentName: 'finn',
|
|
382
|
+
model: 'claude-opus-4',
|
|
383
|
+
now: () => 1000,
|
|
384
|
+
})
|
|
385
|
+
persistSnapshot(path, snap1)
|
|
386
|
+
|
|
387
|
+
// Boot 2: model changed.
|
|
388
|
+
const snap2 = captureConfigSnapshot({
|
|
389
|
+
agentName: 'finn',
|
|
390
|
+
model: 'claude-sonnet-4-5',
|
|
391
|
+
now: () => 2000,
|
|
392
|
+
})
|
|
393
|
+
const prev2 = loadSnapshot(path)
|
|
394
|
+
const diff2 = diffSnapshots(snap2, prev2)
|
|
395
|
+
expect(diff2).toHaveLength(1)
|
|
396
|
+
expect(diff2[0].field).toBe('model')
|
|
397
|
+
persistSnapshot(path, snap2)
|
|
398
|
+
|
|
399
|
+
// Boot 3: same model as boot 2 → no diff.
|
|
400
|
+
const snap3 = captureConfigSnapshot({
|
|
401
|
+
agentName: 'finn',
|
|
402
|
+
model: 'claude-sonnet-4-5',
|
|
403
|
+
now: () => 3000,
|
|
404
|
+
})
|
|
405
|
+
const prev3 = loadSnapshot(path)
|
|
406
|
+
const diff3 = diffSnapshots(snap3, prev3)
|
|
407
|
+
expect(diff3).toEqual([])
|
|
408
|
+
})
|
|
409
|
+
})
|
|
@@ -177,23 +177,29 @@ describe('renderOperatorEvent — credentials-invalid', () => {
|
|
|
177
177
|
})
|
|
178
178
|
|
|
179
179
|
describe('renderOperatorEvent — credit-exhausted', () => {
|
|
180
|
-
it('
|
|
180
|
+
it('shows credit balance text with /auth use hint and no stub buttons (E5)', () => {
|
|
181
181
|
const { text, keyboard } = renderOperatorEvent(makeEvent('credit-exhausted'))
|
|
182
182
|
expect(text).toContain('Credit balance')
|
|
183
|
+
expect(text).toContain('/auth use')
|
|
183
184
|
const buttons = keyboard.inline_keyboard.flat()
|
|
184
|
-
|
|
185
|
-
expect(buttons.some(b => b.callback_data?.includes('
|
|
185
|
+
// swap-slot and add-slot buttons removed (E5 — they redirected to terminal)
|
|
186
|
+
expect(buttons.some(b => b.callback_data?.includes('swap-slot'))).toBe(false)
|
|
187
|
+
expect(buttons.some(b => b.callback_data?.includes('add-slot'))).toBe(false)
|
|
188
|
+
expect(buttons.some(b => b.callback_data?.includes('dismiss'))).toBe(true)
|
|
186
189
|
})
|
|
187
190
|
})
|
|
188
191
|
|
|
189
192
|
describe('renderOperatorEvent — quota-exhausted', () => {
|
|
190
|
-
it('renders quota text
|
|
193
|
+
it('renders quota text with /auth use hint and no stub buttons (E5)', () => {
|
|
191
194
|
const { text, keyboard } = renderOperatorEvent(makeEvent('quota-exhausted'))
|
|
192
195
|
expect(text).toContain('Quota exhausted')
|
|
193
196
|
expect(text).toContain('<b>gymbro</b>')
|
|
197
|
+
expect(text).toContain('/auth use')
|
|
194
198
|
const buttons = keyboard.inline_keyboard.flat()
|
|
195
|
-
|
|
196
|
-
expect(buttons.some(b => b.callback_data?.includes('
|
|
199
|
+
// swap-slot and add-slot buttons removed (E5 — they redirected to terminal)
|
|
200
|
+
expect(buttons.some(b => b.callback_data?.includes('swap-slot'))).toBe(false)
|
|
201
|
+
expect(buttons.some(b => b.callback_data?.includes('add-slot'))).toBe(false)
|
|
202
|
+
expect(buttons.some(b => b.callback_data?.includes('dismiss'))).toBe(true)
|
|
197
203
|
})
|
|
198
204
|
|
|
199
205
|
it('contains auto-fallback slot info in detail when provided', () => {
|