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.
Files changed (30) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +96 -81
  3. package/dist/cli/drive-write-pretool.mjs +10 -10
  4. package/dist/cli/notion-write-pretool.mjs +82 -82
  5. package/dist/cli/skill-validate-pretool.mjs +72 -72
  6. package/dist/cli/switchroom.js +1883 -1479
  7. package/dist/host-control/main.js +149 -149
  8. package/dist/vault/approvals/kernel-server.js +82 -82
  9. package/dist/vault/broker/server.js +83 -83
  10. package/package.json +1 -1
  11. package/profiles/_shared/telegram-style.md.hbs +1 -1
  12. package/telegram-plugin/auth-snapshot-format.ts +47 -1
  13. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  14. package/telegram-plugin/dist/gateway/gateway.js +1226 -696
  15. package/telegram-plugin/dist/server.js +160 -160
  16. package/telegram-plugin/gateway/boot-card.ts +100 -0
  17. package/telegram-plugin/gateway/config-snapshot.ts +274 -0
  18. package/telegram-plugin/gateway/gateway.ts +256 -36
  19. package/telegram-plugin/operator-events.ts +2 -10
  20. package/telegram-plugin/quota-watch.ts +276 -0
  21. package/telegram-plugin/tests/auth-snapshot-format.test.ts +133 -1
  22. package/telegram-plugin/tests/boot-card-render.test.ts +93 -0
  23. package/telegram-plugin/tests/config-snapshot.test.ts +409 -0
  24. package/telegram-plugin/tests/operator-events.test.ts +12 -6
  25. package/telegram-plugin/tests/quota-watch.test.ts +366 -0
  26. package/telegram-plugin/tests/tool-activity-summary.test.ts +66 -0
  27. package/telegram-plugin/tests/turn-flush-safety.test.ts +48 -0
  28. package/telegram-plugin/tool-activity-summary.ts +137 -0
  29. package/telegram-plugin/turn-flush-safety.ts +47 -0
  30. package/telegram-plugin/uat/assertions.ts +4 -4
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Proactive quota threshold-tier push (#E4).
3
+ *
4
+ * Background: JTBD `track-plan-quota-live` anti-pattern: "Quota visible
5
+ * only in a separate dashboard or a command. If the user has to go
6
+ * looking, they won't, and they'll hit the wall." The existing stack
7
+ * covers the wall (auto-fallback at 99.5%, credits-watch on fatal billing
8
+ * transitions) but fires zero proactive signal at 80% — the point where
9
+ * the user can still act by switching accounts. This module closes that gap.
10
+ *
11
+ * It is a pure decision layer. It reads the broker's cached quota state
12
+ * for all accounts, classifies health via the same `classifyHealth`
13
+ * three-state machine used by the /auth dashboard, compares against a
14
+ * persisted last-notified state, and tells the gateway whether to emit
15
+ * a Telegram message + what to say. The gateway wires the actual
16
+ * `bot.api.sendMessage` call (via `swallowingApiCall`) — same as
17
+ * `credits-watch.ts`.
18
+ *
19
+ * Edge-trigger discipline: only fires on health *transitions*
20
+ * (healthy → throttling and throttling → healthy). Does NOT fire on
21
+ * healthy → blocked or blocked → healthy — `credits-watch.ts` already
22
+ * covers those via the fatal-billing path. Steady-state throttling
23
+ * never re-notifies.
24
+ *
25
+ * Scope: per-account across the whole pool, not just the active one.
26
+ * The user's natural recovery action is switching to a healthy account,
27
+ * so they need visibility into non-active accounts too.
28
+ *
29
+ * Source data: broker `listState` + `probeQuota`. `listState` is a local
30
+ * IPC call (cheap). `probeQuota` is only called on state-change (when
31
+ * we're going to send a message anyway) to get fresh numbers for the
32
+ * notification body. On no-change polls, only `listState` is called.
33
+ */
34
+
35
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
36
+ import { join } from "path";
37
+ import type { AccountSnapshot } from "./auth-snapshot-format.js";
38
+ import {
39
+ classifyHealth,
40
+ type AccountHealth,
41
+ THROTTLING_THRESHOLD_PCT,
42
+ bindingWindow,
43
+ formatRelative,
44
+ fmtPct,
45
+ } from "./auth-snapshot-format.js";
46
+ import type { QuotaUtilization } from "./quota-check.js";
47
+
48
+ const STATE_FILE = "quota-watch.json";
49
+
50
+ // ─── State types ──────────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Per-account last-notified health. We only care about the
54
+ * healthy ↔ throttling boundary — blocked is `credits-watch`'s domain.
55
+ * `null` means "never notified" (treat as healthy for transition logic).
56
+ */
57
+ export type QuotaWatchHealth = "healthy" | "throttling" | null;
58
+
59
+ export interface QuotaWatchAccountState {
60
+ /** Last health we sent a notification for. null = never notified. */
61
+ lastNotifiedHealth: QuotaWatchHealth;
62
+ /** Wall-clock ms of the last notification. */
63
+ lastNotifiedAt: number;
64
+ }
65
+
66
+ export type QuotaWatchState = Record<string, QuotaWatchAccountState>;
67
+
68
+ export function emptyQuotaWatchState(): QuotaWatchState {
69
+ return {};
70
+ }
71
+
72
+ export function emptyAccountState(): QuotaWatchAccountState {
73
+ return { lastNotifiedHealth: null, lastNotifiedAt: 0 };
74
+ }
75
+
76
+ // ─── Decision logic ───────────────────────────────────────────────────────────
77
+
78
+ export type QuotaWatchTransition =
79
+ | "entered-throttling"
80
+ | "recovered-to-healthy";
81
+
82
+ export type QuotaWatchDecision =
83
+ | {
84
+ kind: "notify";
85
+ accountLabel: string;
86
+ message: string;
87
+ newAccountState: QuotaWatchAccountState;
88
+ transition: QuotaWatchTransition;
89
+ }
90
+ | { kind: "skip"; accountLabel: string; reason: string };
91
+
92
+ /**
93
+ * Evaluate one account's quota state against its last-notified health.
94
+ *
95
+ * Transition table:
96
+ * healthy → healthy skip (steady-state)
97
+ * healthy → throttling notify (entered-throttling)
98
+ * healthy → blocked skip (credits-watch covers this)
99
+ * throttling → healthy notify (recovered-to-healthy)
100
+ * throttling → throttling skip (already notified)
101
+ * throttling → blocked skip (credits-watch covers blocked)
102
+ * blocked → * skip (credits-watch domain)
103
+ * unknown → * skip (no quota data — don't spam)
104
+ * * → unknown skip (probe failed — transient, don't alarm)
105
+ */
106
+ export function evaluateQuotaWatchAccount(args: {
107
+ agentName: string;
108
+ snap: AccountSnapshot;
109
+ prev: QuotaWatchAccountState;
110
+ now: number;
111
+ }): QuotaWatchDecision {
112
+ const { agentName, snap, prev, now } = args;
113
+ const label = snap.label;
114
+ const currentHealth = classifyHealth(snap);
115
+
116
+ // Unknown (probe failed) or blocked — skip entirely.
117
+ if (currentHealth === "unknown" || currentHealth === "blocked") {
118
+ return { kind: "skip", accountLabel: label, reason: `${currentHealth}-not-our-domain` };
119
+ }
120
+
121
+ // Normalise prev: null means healthy (never alerted = was healthy).
122
+ const prevHealth: "healthy" | "throttling" = prev.lastNotifiedHealth ?? "healthy";
123
+
124
+ // Steady-state — no change.
125
+ if (currentHealth === prevHealth) {
126
+ return { kind: "skip", accountLabel: label, reason: "steady-state" };
127
+ }
128
+
129
+ // healthy → throttling: proactive threshold push.
130
+ if (currentHealth === "throttling" && prevHealth === "healthy") {
131
+ const newState: QuotaWatchAccountState = {
132
+ lastNotifiedHealth: "throttling",
133
+ lastNotifiedAt: now,
134
+ };
135
+ return {
136
+ kind: "notify",
137
+ accountLabel: label,
138
+ message: buildThrottlingMessage(agentName, snap),
139
+ newAccountState: newState,
140
+ transition: "entered-throttling",
141
+ };
142
+ }
143
+
144
+ // throttling → healthy: recovery.
145
+ if (currentHealth === "healthy" && prevHealth === "throttling") {
146
+ const newState: QuotaWatchAccountState = {
147
+ lastNotifiedHealth: "healthy",
148
+ lastNotifiedAt: now,
149
+ };
150
+ return {
151
+ kind: "notify",
152
+ accountLabel: label,
153
+ message: buildRecoveryMessage(agentName, snap),
154
+ newAccountState: newState,
155
+ transition: "recovered-to-healthy",
156
+ };
157
+ }
158
+
159
+ // Any other combination (e.g. blocked → healthy, etc.) — skip.
160
+ return { kind: "skip", accountLabel: label, reason: "no-matching-transition" };
161
+ }
162
+
163
+ // ─── Message builders ─────────────────────────────────────────────────────────
164
+
165
+ function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): string {
166
+ const q = snap.quota!; // classifyHealth returned throttling, so quota is non-null
167
+ const fiveStr = fmtPct(q.fiveHourUtilizationPct);
168
+ const sevenStr = fmtPct(q.sevenDayUtilizationPct);
169
+ const max = Math.max(q.fiveHourUtilizationPct, q.sevenDayUtilizationPct);
170
+ const win = max === q.fiveHourUtilizationPct ? "5h" : "7d";
171
+ const winLabel = win === "5h" ? "5-hour" : "7-day";
172
+ const resetAt = win === "5h" ? q.fiveHourResetAt : q.sevenDayResetAt;
173
+ const resetStr = resetAt
174
+ ? ` · refills in ${formatRelative(resetAt, new Date())}`
175
+ : "";
176
+
177
+ const activeNote = snap.isActive
178
+ ? ""
179
+ : `\nThis is a non-active account. Consider <code>/auth use ${escapeHtml(snap.label)}</code> to switch, or keep it as a fallback reserve.`;
180
+
181
+ const altNote = snap.isActive
182
+ ? `\nConsider <code>/auth use &lt;other-account&gt;</code> if you have a healthier account, or wait for the ${winLabel} window to refill${resetStr}.`
183
+ : "";
184
+
185
+ return [
186
+ `🟡 <b>Quota approaching limit</b> — <code>${escapeHtml(snap.label)}</code>`,
187
+ ``,
188
+ `${fiveStr} of 5h · ${sevenStr} of 7d`,
189
+ `Binding window: ${winLabel}${resetStr}`,
190
+ `${activeNote}${altNote}`,
191
+ ``,
192
+ `<i>Threshold: ${THROTTLING_THRESHOLD_PCT}% on either window. Source: broker quota cache.</i>`,
193
+ `<i>Run /auth for full fleet status or /usage for the active account.</i>`,
194
+ ]
195
+ .join("\n")
196
+ .replace(/\n\n\n+/g, "\n\n")
197
+ .trim();
198
+ }
199
+
200
+ function buildRecoveryMessage(agentName: string, snap: AccountSnapshot): string {
201
+ const q = snap.quota;
202
+ const utilLine = q
203
+ ? `Current: ${fmtPct(q.fiveHourUtilizationPct)} of 5h · ${fmtPct(q.sevenDayUtilizationPct)} of 7d`
204
+ : "Current quota data unavailable.";
205
+
206
+ return [
207
+ `🟢 <b>Quota back in healthy range</b> — <code>${escapeHtml(snap.label)}</code>`,
208
+ ``,
209
+ utilLine,
210
+ ``,
211
+ `<i>Below ${THROTTLING_THRESHOLD_PCT}% on both windows.</i>`,
212
+ ].join("\n");
213
+ }
214
+
215
+ function escapeHtml(s: string): string {
216
+ return s
217
+ .replace(/&/g, "&amp;")
218
+ .replace(/</g, "&lt;")
219
+ .replace(/>/g, "&gt;")
220
+ .replace(/"/g, "&quot;")
221
+ .replace(/'/g, "&#39;");
222
+ }
223
+
224
+ // ─── State persistence ────────────────────────────────────────────────────────
225
+
226
+ export function loadQuotaWatchState(stateDir: string): QuotaWatchState {
227
+ const path = join(stateDir, STATE_FILE);
228
+ if (!existsSync(path)) return emptyQuotaWatchState();
229
+ try {
230
+ const raw = readFileSync(path, "utf-8");
231
+ const parsed = JSON.parse(raw);
232
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
233
+ return emptyQuotaWatchState();
234
+ }
235
+ // Validate each entry — drop malformed ones rather than failing the whole file.
236
+ const result: QuotaWatchState = {};
237
+ for (const [key, val] of Object.entries(parsed)) {
238
+ if (
239
+ val &&
240
+ typeof val === "object" &&
241
+ !Array.isArray(val) &&
242
+ (
243
+ (val as Record<string, unknown>).lastNotifiedHealth === null ||
244
+ (val as Record<string, unknown>).lastNotifiedHealth === "healthy" ||
245
+ (val as Record<string, unknown>).lastNotifiedHealth === "throttling"
246
+ ) &&
247
+ typeof (val as Record<string, unknown>).lastNotifiedAt === "number" &&
248
+ Number.isFinite((val as Record<string, unknown>).lastNotifiedAt as number)
249
+ ) {
250
+ result[key] = val as QuotaWatchAccountState;
251
+ }
252
+ }
253
+ return result;
254
+ } catch {
255
+ return emptyQuotaWatchState();
256
+ }
257
+ }
258
+
259
+ export function saveQuotaWatchState(stateDir: string, state: QuotaWatchState): void {
260
+ mkdirSync(stateDir, { recursive: true });
261
+ const path = join(stateDir, STATE_FILE);
262
+ writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
263
+ }
264
+
265
+ /**
266
+ * Merge one account's updated state into a full `QuotaWatchState` map.
267
+ * Callers use this after each `evaluateQuotaWatchAccount` that returns
268
+ * `kind: "notify"` to produce the new map to persist.
269
+ */
270
+ export function patchQuotaWatchState(
271
+ current: QuotaWatchState,
272
+ accountLabel: string,
273
+ accountState: QuotaWatchAccountState,
274
+ ): QuotaWatchState {
275
+ return { ...current, [accountLabel]: accountState };
276
+ }
@@ -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
+ })