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.
- package/dist/auth-broker/index.js +16 -1
- package/dist/cli/switchroom.js +1082 -873
- package/dist/host-control/main.js +1 -1
- 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/gateway/gateway.js +983 -537
- package/telegram-plugin/gateway/boot-card.ts +100 -0
- package/telegram-plugin/gateway/config-snapshot.ts +274 -0
- package/telegram-plugin/gateway/gateway.ts +235 -20
- 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 +45 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +48 -0
- package/telegram-plugin/tool-activity-summary.ts +47 -0
- package/telegram-plugin/turn-flush-safety.ts +47 -0
- package/telegram-plugin/uat/assertions.ts +4 -4
|
@@ -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('
|
|
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', () => {
|