switchroom 0.14.0 → 0.14.2

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.
@@ -15,11 +15,13 @@ import {
15
15
  renderFallbackAnnouncement,
16
16
  buildSnapshotKeyboard,
17
17
  buildSnapshotsFromState,
18
+ buildSnapshotsFromCachedState,
19
+ reviveLastQuota,
18
20
  THROTTLING_THRESHOLD_PCT,
19
21
  type AccountSnapshot,
20
22
  } from '../auth-snapshot-format.js';
21
23
  import type { QuotaUtilization } from '../quota-check.js';
22
- import type { ListStateData } from '../../src/auth/broker/client.js';
24
+ import type { LastQuotaSnapshot, ListStateData } from '../../src/auth/broker/client.js';
23
25
 
24
26
  // Frozen "now" for all reset-time math. Friday May 15 2026 10:53 AM Melbourne
25
27
  // = 2026-05-15T00:53:00Z. Reset epochs in fixtures are in seconds.
@@ -406,6 +408,136 @@ describe('buildSnapshotsFromState', () => {
406
408
  });
407
409
  });
408
410
 
411
+ // ── reviveLastQuota ──────────────────────────────────────────────────
412
+
413
+ describe('reviveLastQuota', () => {
414
+ it('returns null for null input', () => {
415
+ expect(reviveLastQuota(null)).toBeNull();
416
+ });
417
+
418
+ it('returns null for undefined input', () => {
419
+ expect(reviveLastQuota(undefined)).toBeNull();
420
+ });
421
+
422
+ it('converts ISO-string dates to Date objects', () => {
423
+ const isoFive = '2026-05-15T06:00:00.000Z';
424
+ const isoSeven = '2026-05-22T06:00:00.000Z';
425
+ const lq: LastQuotaSnapshot = {
426
+ fiveHourUtilizationPct: 45,
427
+ sevenDayUtilizationPct: 72,
428
+ fiveHourResetAt: isoFive,
429
+ sevenDayResetAt: isoSeven,
430
+ representativeClaim: 'five_hour',
431
+ overageStatus: 'allowed',
432
+ overageDisabledReason: null,
433
+ capturedAt: Date.now(),
434
+ };
435
+ const q = reviveLastQuota(lq);
436
+ expect(q).not.toBeNull();
437
+ expect(q!.fiveHourUtilizationPct).toBe(45);
438
+ expect(q!.sevenDayUtilizationPct).toBe(72);
439
+ expect(q!.fiveHourResetAt).toBeInstanceOf(Date);
440
+ expect(q!.fiveHourResetAt!.toISOString()).toBe(isoFive);
441
+ expect(q!.sevenDayResetAt).toBeInstanceOf(Date);
442
+ expect(q!.sevenDayResetAt!.toISOString()).toBe(isoSeven);
443
+ expect(q!.representativeClaim).toBe('five_hour');
444
+ expect(q!.overageStatus).toBe('allowed');
445
+ expect(q!.overageDisabledReason).toBeNull();
446
+ });
447
+
448
+ it('passes through null dates as null', () => {
449
+ const lq: LastQuotaSnapshot = {
450
+ fiveHourUtilizationPct: 20,
451
+ sevenDayUtilizationPct: 30,
452
+ fiveHourResetAt: null,
453
+ sevenDayResetAt: null,
454
+ representativeClaim: null,
455
+ overageStatus: null,
456
+ overageDisabledReason: null,
457
+ capturedAt: Date.now(),
458
+ };
459
+ const q = reviveLastQuota(lq);
460
+ expect(q!.fiveHourResetAt).toBeNull();
461
+ expect(q!.sevenDayResetAt).toBeNull();
462
+ });
463
+ });
464
+
465
+ // ── buildSnapshotsFromCachedState ────────────────────────────────────
466
+
467
+ describe('buildSnapshotsFromCachedState', () => {
468
+ function makeLastQuota(fivePct: number, sevenPct: number): LastQuotaSnapshot {
469
+ return {
470
+ fiveHourUtilizationPct: fivePct,
471
+ sevenDayUtilizationPct: sevenPct,
472
+ fiveHourResetAt: null,
473
+ sevenDayResetAt: null,
474
+ representativeClaim: null,
475
+ overageStatus: null,
476
+ overageDisabledReason: null,
477
+ capturedAt: Date.now(),
478
+ };
479
+ }
480
+
481
+ it('produces quota=null for accounts with no cached snapshot', () => {
482
+ const state: ListStateData = {
483
+ active: 'a@x',
484
+ fallback_order: [],
485
+ accounts: [{ label: 'a@x', exhausted: false, last_quota: null }],
486
+ agents: [],
487
+ consumers: [],
488
+ };
489
+ const snaps = buildSnapshotsFromCachedState(state);
490
+ expect(snaps[0]!.quota).toBeNull();
491
+ // classifyHealth on this snap returns 'unknown'
492
+ expect(snaps[0]!.quotaError).toContain('no cached quota');
493
+ });
494
+
495
+ it('revives cached utilization into Date-bearing QuotaUtilization', () => {
496
+ const state: ListStateData = {
497
+ active: 'b@x',
498
+ fallback_order: [],
499
+ accounts: [
500
+ { label: 'a@x', exhausted: false, last_quota: makeLastQuota(20, 30) },
501
+ { label: 'b@x', exhausted: false, last_quota: makeLastQuota(85, 40) },
502
+ ],
503
+ agents: [],
504
+ consumers: [],
505
+ };
506
+ const snaps = buildSnapshotsFromCachedState(state);
507
+ expect(snaps[0]!.quota?.fiveHourUtilizationPct).toBe(20);
508
+ expect(snaps[1]!.quota?.sevenDayUtilizationPct).toBe(40);
509
+ expect(snaps[1]!.isActive).toBe(true);
510
+ });
511
+
512
+ it('classifyHealth correctly classifies cached throttling account (≥80% threshold)', () => {
513
+ const state: ListStateData = {
514
+ active: 'a@x',
515
+ fallback_order: [],
516
+ accounts: [
517
+ { label: 'a@x', exhausted: false, last_quota: makeLastQuota(85, 40) },
518
+ ],
519
+ agents: [],
520
+ consumers: [],
521
+ };
522
+ const snaps = buildSnapshotsFromCachedState(state);
523
+ // With cached 85% 5h utilization, classifyHealth should return 'throttling'
524
+ expect(classifyHealth(snaps[0]!)).toBe('throttling');
525
+ });
526
+
527
+ it('treats absent last_quota (undefined) the same as null', () => {
528
+ const state: ListStateData = {
529
+ active: 'a@x',
530
+ fallback_order: [],
531
+ // last_quota absent — simulates old broker version / cold broker start
532
+ accounts: [{ label: 'a@x', exhausted: false }],
533
+ agents: [],
534
+ consumers: [],
535
+ };
536
+ const snaps = buildSnapshotsFromCachedState(state);
537
+ expect(snaps[0]!.quota).toBeNull();
538
+ });
539
+ });
540
+
409
541
  // ── recommendation logic edge cases ──────────────────────────────────
410
542
 
411
543
  describe('recommendation', () => {
@@ -364,3 +364,96 @@ describe('renderBootCard — resolved / snooze rendering', () => {
364
364
  expect(out).toContain('✅ <b>Broker</b> resolved')
365
365
  })
366
366
  })
367
+
368
+ // ── Config-change row rendering (E3) ─────────────────────────────────────────
369
+
370
+ describe('renderBootCard — configChanges rows', () => {
371
+ it('silent when configChanges is absent', () => {
372
+ const out = renderBootCard({ agentName: 'k', version: 'v' })
373
+ expect(out).toBe('✅ <b>k</b> back up · v')
374
+ expect(out).not.toContain('Config')
375
+ })
376
+
377
+ it('silent when configChanges is empty array', () => {
378
+ const out = renderBootCard({ agentName: 'k', version: 'v', configChanges: [] })
379
+ expect(out).toBe('✅ <b>k</b> back up · v')
380
+ expect(out).not.toContain('Config')
381
+ })
382
+
383
+ it('renders a model-change row when model changed', () => {
384
+ const out = renderBootCard({
385
+ agentName: 'k',
386
+ version: 'v',
387
+ configChanges: [{ field: 'model', from: 'claude-opus-4', to: 'claude-sonnet-4-5' }],
388
+ })
389
+ expect(out).toContain('⚙️ <b>Config</b>')
390
+ expect(out).toContain('claude-opus-4')
391
+ expect(out).toContain('claude-sonnet-4-5')
392
+ expect(out).toContain('→')
393
+ })
394
+
395
+ it('renders a coarse tools-changed row for tools diff', () => {
396
+ const out = renderBootCard({
397
+ agentName: 'k',
398
+ version: 'v',
399
+ configChanges: [{ field: 'tools', from: 'aaa', to: 'bbb' }],
400
+ })
401
+ expect(out).toContain('tools allowlist changed')
402
+ expect(out).toContain('/status')
403
+ expect(out).not.toContain('aaa')
404
+ expect(out).not.toContain('bbb')
405
+ })
406
+
407
+ it('renders a coarse skills-changed row for skills diff', () => {
408
+ const out = renderBootCard({
409
+ agentName: 'k',
410
+ version: 'v',
411
+ configChanges: [{ field: 'skills', from: 'ccc', to: 'ddd' }],
412
+ })
413
+ expect(out).toContain('skills changed')
414
+ expect(out).toContain('/status')
415
+ })
416
+
417
+ it('renders multiple config-change rows (all four fields)', () => {
418
+ const out = renderBootCard({
419
+ agentName: 'k',
420
+ version: 'v',
421
+ configChanges: [
422
+ { field: 'model', from: 'claude-opus-4', to: 'claude-sonnet-4-5' },
423
+ { field: 'tools', from: 'abc', to: 'def' },
424
+ { field: 'skills', from: 'ghi', to: 'jkl' },
425
+ { field: 'memoryBackend', from: 'finn', to: 'finn-v2' },
426
+ ],
427
+ })
428
+ const lines = out.split('\n')
429
+ // Should have more than 1 line (ack + separator + config rows).
430
+ expect(lines.length).toBeGreaterThan(2)
431
+ expect(out).toContain('claude-opus-4')
432
+ expect(out).toContain('tools allowlist changed')
433
+ expect(out).toContain('skills changed')
434
+ expect(out).toContain('finn')
435
+ expect(out).toContain('finn-v2')
436
+ })
437
+
438
+ it('config-change rows appear after probe rows', () => {
439
+ const out = renderBootCard({
440
+ agentName: 'k',
441
+ version: 'v',
442
+ probes: {
443
+ broker: { status: 'fail', label: 'Broker', detail: 'socket missing' },
444
+ },
445
+ configChanges: [{ field: 'model', from: 'a', to: 'b' }],
446
+ })
447
+ const brokerIdx = out.indexOf('Broker')
448
+ const configIdx = out.indexOf('Config')
449
+ expect(brokerIdx).toBeGreaterThan(-1)
450
+ expect(configIdx).toBeGreaterThan(-1)
451
+ expect(configIdx).toBeGreaterThan(brokerIdx)
452
+ })
453
+
454
+ it('bare ack still returned when configChanges fires but produces only empty rows (defensive)', () => {
455
+ // Edge: empty configChanges array passed → should not produce rows.
456
+ const out = renderBootCard({ agentName: 'k', version: 'v', configChanges: [] })
457
+ expect(out).toBe('✅ <b>k</b> back up · v')
458
+ })
459
+ })
@@ -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('offers swap + add slot buttons', () => {
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
- expect(buttons.some(b => b.callback_data?.includes('swap-slot'))).toBe(true)
185
- expect(buttons.some(b => b.callback_data?.includes('add-slot'))).toBe(true)
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 + swap/add buttons', () => {
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
- expect(buttons.some(b => b.callback_data?.includes('swap-slot'))).toBe(true)
196
- expect(buttons.some(b => b.callback_data?.includes('add-slot'))).toBe(true)
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', () => {