switchroom 0.10.0 → 0.11.0
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/README.md +5 -4
- package/dist/cli/drive-write-pretool.mjs +5418 -0
- package/dist/cli/switchroom.js +201 -24
- package/package.json +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
- package/telegram-plugin/admin-commands/index.ts +2 -0
- package/telegram-plugin/auth-snapshot-format.ts +612 -0
- package/telegram-plugin/auto-fallback-fleet.ts +215 -0
- package/telegram-plugin/auto-fallback.ts +28 -301
- package/telegram-plugin/dist/gateway/gateway.js +4407 -2252
- package/telegram-plugin/fleet-fallback-gate.ts +105 -0
- package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
- package/telegram-plugin/gateway/approval-callback.ts +31 -3
- package/telegram-plugin/gateway/auth-command.ts +121 -10
- package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
- package/telegram-plugin/gateway/boot-card.ts +1 -1
- package/telegram-plugin/gateway/boot-probes.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
- package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
- package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
- package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
- package/telegram-plugin/gateway/gateway.ts +876 -173
- package/telegram-plugin/gateway/hostd-dispatch.ts +127 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
- package/telegram-plugin/gateway/ipc-server.ts +69 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
- package/telegram-plugin/model-unavailable.ts +28 -12
- package/telegram-plugin/silence-poke.ts +153 -1
- package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
- package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
- package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
- package/telegram-plugin/tests/boot-probes.test.ts +16 -18
- package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
- package/telegram-plugin/tests/silence-poke.test.ts +237 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
- package/telegram-plugin/turn-flush-safety.ts +55 -1
- package/telegram-plugin/uat/SETUP.md +16 -12
- package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
- package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
- package/telegram-plugin/tests/hostd-dispatch.test.ts +0 -129
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for auth-snapshot-format.ts — Format 2 + causal auto-fallback
|
|
3
|
+
* announcement. Pure functions, fully covered by frozen-clock tests
|
|
4
|
+
* with hand-crafted QuotaUtilization fixtures.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
classifyHealth,
|
|
9
|
+
bindingWindow,
|
|
10
|
+
formatRelative,
|
|
11
|
+
formatAbsolute,
|
|
12
|
+
fmtPct,
|
|
13
|
+
recommendation,
|
|
14
|
+
renderAuthSnapshotFormat2,
|
|
15
|
+
renderFallbackAnnouncement,
|
|
16
|
+
buildSnapshotKeyboard,
|
|
17
|
+
buildSnapshotsFromState,
|
|
18
|
+
THROTTLING_THRESHOLD_PCT,
|
|
19
|
+
type AccountSnapshot,
|
|
20
|
+
} from '../auth-snapshot-format.js';
|
|
21
|
+
import type { QuotaUtilization } from '../quota-check.js';
|
|
22
|
+
import type { ListStateData } from '../../src/auth/broker/client.js';
|
|
23
|
+
|
|
24
|
+
// Frozen "now" for all reset-time math. Friday May 15 2026 10:53 AM Melbourne
|
|
25
|
+
// = 2026-05-15T00:53:00Z. Reset epochs in fixtures are in seconds.
|
|
26
|
+
const NOW = new Date('2026-05-15T00:53:00Z');
|
|
27
|
+
|
|
28
|
+
function quota(part: Partial<QuotaUtilization>): QuotaUtilization {
|
|
29
|
+
return {
|
|
30
|
+
fiveHourUtilizationPct: 0,
|
|
31
|
+
sevenDayUtilizationPct: 0,
|
|
32
|
+
fiveHourResetAt: null,
|
|
33
|
+
sevenDayResetAt: null,
|
|
34
|
+
representativeClaim: null,
|
|
35
|
+
overageStatus: null,
|
|
36
|
+
overageDisabledReason: null,
|
|
37
|
+
...part,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function snap(part: Partial<AccountSnapshot>): AccountSnapshot {
|
|
42
|
+
return {
|
|
43
|
+
label: 'unset@example.com',
|
|
44
|
+
isActive: false,
|
|
45
|
+
quota: null,
|
|
46
|
+
...part,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── classifyHealth ───────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
describe('classifyHealth', () => {
|
|
53
|
+
it('returns healthy for low utilization on both windows', () => {
|
|
54
|
+
expect(classifyHealth(snap({ quota: quota({ fiveHourUtilizationPct: 8, sevenDayUtilizationPct: 20 }) }))).toBe('healthy');
|
|
55
|
+
});
|
|
56
|
+
it('returns throttling when either window crosses the 80% threshold', () => {
|
|
57
|
+
expect(classifyHealth(snap({ quota: quota({ fiveHourUtilizationPct: 85, sevenDayUtilizationPct: 20 }) }))).toBe('throttling');
|
|
58
|
+
expect(classifyHealth(snap({ quota: quota({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 95 }) }))).toBe('throttling');
|
|
59
|
+
});
|
|
60
|
+
it('returns blocked at 99.5%+ utilization on either window', () => {
|
|
61
|
+
expect(classifyHealth(snap({ quota: quota({ fiveHourUtilizationPct: 100, sevenDayUtilizationPct: 0 }) }))).toBe('blocked');
|
|
62
|
+
expect(classifyHealth(snap({ quota: quota({ fiveHourUtilizationPct: 0, sevenDayUtilizationPct: 100 }) }))).toBe('blocked');
|
|
63
|
+
expect(classifyHealth(snap({ quota: quota({ fiveHourUtilizationPct: 99.6, sevenDayUtilizationPct: 0 }) }))).toBe('blocked');
|
|
64
|
+
});
|
|
65
|
+
it('returns unknown when quota probe failed', () => {
|
|
66
|
+
expect(classifyHealth(snap({ quota: null, quotaError: 'HTTP 401' }))).toBe('unknown');
|
|
67
|
+
});
|
|
68
|
+
it('THROTTLING_THRESHOLD_PCT is 80 (regression — design choice, see jtbd)', () => {
|
|
69
|
+
// If this number changes, the recommendation footer + button visibility
|
|
70
|
+
// shift; bump it deliberately.
|
|
71
|
+
expect(THROTTLING_THRESHOLD_PCT).toBe(80);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── bindingWindow ────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
describe('bindingWindow', () => {
|
|
78
|
+
it('respects representative_claim when present (server-authoritative)', () => {
|
|
79
|
+
expect(bindingWindow(quota({ representativeClaim: 'five_hour', fiveHourUtilizationPct: 10, sevenDayUtilizationPct: 90 }))).toBe('5h');
|
|
80
|
+
expect(bindingWindow(quota({ representativeClaim: 'seven_day', fiveHourUtilizationPct: 90, sevenDayUtilizationPct: 10 }))).toBe('7d');
|
|
81
|
+
});
|
|
82
|
+
it('falls back to higher window when no claim is present', () => {
|
|
83
|
+
expect(bindingWindow(quota({ fiveHourUtilizationPct: 10, sevenDayUtilizationPct: 90 }))).toBe('7d');
|
|
84
|
+
expect(bindingWindow(quota({ fiveHourUtilizationPct: 90, sevenDayUtilizationPct: 10 }))).toBe('5h');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── formatRelative ───────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
describe('formatRelative', () => {
|
|
91
|
+
it('renders sub-hour countdowns in minutes', () => {
|
|
92
|
+
expect(formatRelative(new Date('2026-05-15T01:00:00Z'), NOW)).toBe('7m');
|
|
93
|
+
});
|
|
94
|
+
it('renders sub-day countdowns in h+m', () => {
|
|
95
|
+
expect(formatRelative(new Date('2026-05-15T05:50:00Z'), NOW)).toBe('4h 57m');
|
|
96
|
+
});
|
|
97
|
+
it('renders multi-day countdowns in d+h', () => {
|
|
98
|
+
expect(formatRelative(new Date('2026-05-17T10:00:00Z'), NOW)).toBe('2d 9h');
|
|
99
|
+
});
|
|
100
|
+
it('returns "—" for null and "now" for past targets', () => {
|
|
101
|
+
expect(formatRelative(null, NOW)).toBe('—');
|
|
102
|
+
expect(formatRelative(new Date('2026-05-14T00:00:00Z'), NOW)).toBe('now');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── fmtPct ───────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
describe('fmtPct', () => {
|
|
109
|
+
it('rounds to nearest integer percent', () => {
|
|
110
|
+
expect(fmtPct(8.4)).toBe('8%');
|
|
111
|
+
expect(fmtPct(8.6)).toBe('9%');
|
|
112
|
+
expect(fmtPct(99.6)).toBe('100%');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── formatAbsolute ───────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe('formatAbsolute', () => {
|
|
119
|
+
it('renders weekday + hour + minute in the given timezone', () => {
|
|
120
|
+
const out = formatAbsolute(new Date('2026-05-15T05:50:00Z'), 'Australia/Melbourne');
|
|
121
|
+
// Just sanity-check the contract: weekday name, hour:minute, AM/PM
|
|
122
|
+
expect(out).toMatch(/^(Sun|Mon|Tue|Wed|Thu|Fri|Sat)\b/);
|
|
123
|
+
expect(out).toMatch(/\d{1,2}:\d{2}\s?(AM|PM)/);
|
|
124
|
+
});
|
|
125
|
+
it('returns "—" for null', () => {
|
|
126
|
+
expect(formatAbsolute(null, 'UTC')).toBe('—');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ── renderAuthSnapshotFormat2 ────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
describe('renderAuthSnapshotFormat2', () => {
|
|
133
|
+
// Matches the live snapshot we proved against claude.ai for the user's
|
|
134
|
+
// own three accounts (15 May 2026, 10:53 AM Mel) — this is the gold
|
|
135
|
+
// fixture. If the formatter changes shape, update these expectations.
|
|
136
|
+
const fixtureSnaps: AccountSnapshot[] = [
|
|
137
|
+
snap({
|
|
138
|
+
label: 'ken.thompson@outlook.com.au',
|
|
139
|
+
isActive: false,
|
|
140
|
+
quota: quota({
|
|
141
|
+
fiveHourUtilizationPct: 0,
|
|
142
|
+
sevenDayUtilizationPct: 23,
|
|
143
|
+
fiveHourResetAt: new Date('2026-05-15T05:50:00Z'),
|
|
144
|
+
sevenDayResetAt: new Date('2026-05-18T19:00:00Z'),
|
|
145
|
+
representativeClaim: 'five_hour',
|
|
146
|
+
}),
|
|
147
|
+
}),
|
|
148
|
+
snap({
|
|
149
|
+
label: 'me@kenthompson.com.au',
|
|
150
|
+
isActive: false,
|
|
151
|
+
quota: quota({
|
|
152
|
+
fiveHourUtilizationPct: 0,
|
|
153
|
+
sevenDayUtilizationPct: 100,
|
|
154
|
+
fiveHourResetAt: new Date('2026-05-15T00:50:00Z'),
|
|
155
|
+
sevenDayResetAt: new Date('2026-05-17T10:00:00Z'),
|
|
156
|
+
representativeClaim: 'seven_day',
|
|
157
|
+
}),
|
|
158
|
+
}),
|
|
159
|
+
snap({
|
|
160
|
+
label: 'pixsoul@gmail.com',
|
|
161
|
+
isActive: true,
|
|
162
|
+
quota: quota({
|
|
163
|
+
fiveHourUtilizationPct: 8,
|
|
164
|
+
sevenDayUtilizationPct: 20,
|
|
165
|
+
fiveHourResetAt: new Date('2026-05-15T01:00:00Z'),
|
|
166
|
+
sevenDayResetAt: new Date('2026-05-17T01:00:00Z'),
|
|
167
|
+
representativeClaim: 'five_hour',
|
|
168
|
+
}),
|
|
169
|
+
}),
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
it('renders three health-grouped sections (BLOCKED first, then HEALTHY)', () => {
|
|
173
|
+
const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
|
|
174
|
+
// Headers present
|
|
175
|
+
expect(out).toContain('🔋 <b>Auth — fleet status</b>');
|
|
176
|
+
expect(out).toContain('🔴 <b>BLOCKED</b> (1)');
|
|
177
|
+
expect(out).toContain('🟢 <b>HEALTHY</b> (2)');
|
|
178
|
+
// Order: BLOCKED before HEALTHY
|
|
179
|
+
expect(out.indexOf('🔴')).toBeLessThan(out.indexOf('🟢'));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('marks the active account with ●', () => {
|
|
183
|
+
const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
|
|
184
|
+
expect(out).toMatch(/●\s*<code>pixsoul@gmail\.com<\/code>/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('shows "back …" for blocked accounts with binding-window word', () => {
|
|
188
|
+
const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
|
|
189
|
+
// me@kenthompson is blocked on 7d, recovers Sun
|
|
190
|
+
expect(out).toMatch(/me@kenthompson\.com\.au[\s\S]*back .* 7-day cap/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('puts the imminent window first on healthy/throttling rows', () => {
|
|
194
|
+
const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
|
|
195
|
+
// pixsoul: 5h reset is in 7m, 7d reset is in 2d. 5h should come first.
|
|
196
|
+
const pixRow = out.split('\n').find((l) => l.includes('5h refills') && l.includes('7d resets'));
|
|
197
|
+
expect(pixRow).toBeDefined();
|
|
198
|
+
expect(pixRow!.indexOf('5h refills')).toBeLessThan(pixRow!.indexOf('7d resets'));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('emits a recommendation footer that names a healthy alternative when active is throttling', () => {
|
|
202
|
+
const throttlingSnaps: AccountSnapshot[] = [
|
|
203
|
+
snap({ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 90 }) }),
|
|
204
|
+
snap({ label: 'b@x', quota: quota({ fiveHourUtilizationPct: 5 }) }),
|
|
205
|
+
];
|
|
206
|
+
const out = renderAuthSnapshotFormat2(throttlingSnaps, { now: NOW });
|
|
207
|
+
expect(out).toMatch(/Recommendation:.*active a@x is throttling.*Switch to b@x/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('"stay on" when active is healthy', () => {
|
|
211
|
+
const happySnaps: AccountSnapshot[] = [
|
|
212
|
+
snap({ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 5 }) }),
|
|
213
|
+
];
|
|
214
|
+
const out = renderAuthSnapshotFormat2(happySnaps, { now: NOW });
|
|
215
|
+
expect(out).toMatch(/Recommendation: stay on a@x\./);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('falls back gracefully when quota probe failed', () => {
|
|
219
|
+
const errSnaps: AccountSnapshot[] = [
|
|
220
|
+
snap({ label: 'broken@x', isActive: true, quota: null, quotaError: 'HTTP 401' }),
|
|
221
|
+
];
|
|
222
|
+
const out = renderAuthSnapshotFormat2(errSnaps, { now: NOW });
|
|
223
|
+
expect(out).toContain('quota probe failed');
|
|
224
|
+
expect(out).toContain('HTTP 401');
|
|
225
|
+
expect(out).toContain('⚪ <b>UNKNOWN</b>');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('renders refresh stamp when liveProbedAtMs given', () => {
|
|
229
|
+
const out = renderAuthSnapshotFormat2(fixtureSnaps.slice(0, 1), {
|
|
230
|
+
now: NOW,
|
|
231
|
+
liveProbedAtMs: Date.now() - 12_000,
|
|
232
|
+
});
|
|
233
|
+
expect(out).toMatch(/<i>Live · refreshed \d+s ago<\/i>/);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ── renderFallbackAnnouncement ───────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
describe('renderFallbackAnnouncement', () => {
|
|
240
|
+
const KEN_5H_BLOWN = quota({
|
|
241
|
+
fiveHourUtilizationPct: 100,
|
|
242
|
+
sevenDayUtilizationPct: 23,
|
|
243
|
+
fiveHourResetAt: new Date('2026-05-15T05:50:00Z'),
|
|
244
|
+
sevenDayResetAt: new Date('2026-05-18T19:00:00Z'),
|
|
245
|
+
representativeClaim: 'five_hour',
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const PIXSOUL_HEALTHY = quota({
|
|
249
|
+
fiveHourUtilizationPct: 8,
|
|
250
|
+
sevenDayUtilizationPct: 20,
|
|
251
|
+
fiveHourResetAt: new Date('2026-05-15T01:00:00Z'),
|
|
252
|
+
sevenDayResetAt: new Date('2026-05-17T01:00:00Z'),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('headlines the limit type explicitly (5-hour vs 7-day) — JTBD core', () => {
|
|
256
|
+
const out5 = renderFallbackAnnouncement({
|
|
257
|
+
oldLabel: 'ken@x',
|
|
258
|
+
oldQuota: KEN_5H_BLOWN,
|
|
259
|
+
newLabel: 'pixsoul@x',
|
|
260
|
+
newQuota: PIXSOUL_HEALTHY,
|
|
261
|
+
triggerAgent: 'carrie',
|
|
262
|
+
now: NOW,
|
|
263
|
+
tz: 'UTC',
|
|
264
|
+
});
|
|
265
|
+
expect(out5).toContain('5-hour limit on ken@x');
|
|
266
|
+
expect(out5).not.toContain('quota exhausted');
|
|
267
|
+
|
|
268
|
+
const out7 = renderFallbackAnnouncement({
|
|
269
|
+
oldLabel: 'me@x',
|
|
270
|
+
oldQuota: quota({
|
|
271
|
+
sevenDayUtilizationPct: 100,
|
|
272
|
+
sevenDayResetAt: new Date('2026-05-17T10:00:00Z'),
|
|
273
|
+
representativeClaim: 'seven_day',
|
|
274
|
+
}),
|
|
275
|
+
newLabel: 'pixsoul@x',
|
|
276
|
+
newQuota: PIXSOUL_HEALTHY,
|
|
277
|
+
triggerAgent: 'clerk',
|
|
278
|
+
now: NOW,
|
|
279
|
+
tz: 'UTC',
|
|
280
|
+
});
|
|
281
|
+
expect(out7).toContain('7-day limit on me@x');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('names the triggering agent + recovery countdown for the old account', () => {
|
|
285
|
+
const out = renderFallbackAnnouncement({
|
|
286
|
+
oldLabel: 'ken@x',
|
|
287
|
+
oldQuota: KEN_5H_BLOWN,
|
|
288
|
+
newLabel: 'pixsoul@x',
|
|
289
|
+
newQuota: PIXSOUL_HEALTHY,
|
|
290
|
+
triggerAgent: 'carrie',
|
|
291
|
+
now: NOW,
|
|
292
|
+
tz: 'UTC',
|
|
293
|
+
});
|
|
294
|
+
expect(out).toContain('Triggered by: agent <b>carrie</b>');
|
|
295
|
+
expect(out).toMatch(/ken@x.*recovers.*in 4h 57m/);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('reports new-account headroom verdict', () => {
|
|
299
|
+
const happy = renderFallbackAnnouncement({
|
|
300
|
+
oldLabel: 'ken@x',
|
|
301
|
+
oldQuota: KEN_5H_BLOWN,
|
|
302
|
+
newLabel: 'pixsoul@x',
|
|
303
|
+
newQuota: PIXSOUL_HEALTHY,
|
|
304
|
+
triggerAgent: 'carrie',
|
|
305
|
+
now: NOW,
|
|
306
|
+
tz: 'UTC',
|
|
307
|
+
});
|
|
308
|
+
expect(happy).toContain('plenty of headroom');
|
|
309
|
+
|
|
310
|
+
const tight = renderFallbackAnnouncement({
|
|
311
|
+
oldLabel: 'ken@x',
|
|
312
|
+
oldQuota: KEN_5H_BLOWN,
|
|
313
|
+
newLabel: 'pixsoul@x',
|
|
314
|
+
newQuota: quota({ fiveHourUtilizationPct: 85 }),
|
|
315
|
+
triggerAgent: 'carrie',
|
|
316
|
+
now: NOW,
|
|
317
|
+
tz: 'UTC',
|
|
318
|
+
});
|
|
319
|
+
expect(tight).toContain('near limit — watch this');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('handles all-blocked: no swap, surface earliest reset + /auth add hint', () => {
|
|
323
|
+
const out = renderFallbackAnnouncement({
|
|
324
|
+
oldLabel: 'ken@x',
|
|
325
|
+
oldQuota: KEN_5H_BLOWN,
|
|
326
|
+
newLabel: null,
|
|
327
|
+
newQuota: null,
|
|
328
|
+
triggerAgent: 'carrie',
|
|
329
|
+
now: NOW,
|
|
330
|
+
tz: 'UTC',
|
|
331
|
+
});
|
|
332
|
+
expect(out).toContain('🔴 <b>All accounts blocked');
|
|
333
|
+
expect(out).toMatch(/ken@x recovers.*in 4h 57m/);
|
|
334
|
+
expect(out).toContain('/auth add');
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// ── buildSnapshotKeyboard ────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
describe('buildSnapshotKeyboard', () => {
|
|
341
|
+
it('hides switch buttons for BLOCKED accounts (no temptation to swap into a wall)', () => {
|
|
342
|
+
const snaps: AccountSnapshot[] = [
|
|
343
|
+
snap({ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 5 }) }),
|
|
344
|
+
snap({ label: 'b@x', quota: quota({ fiveHourUtilizationPct: 100 }) }), // blocked
|
|
345
|
+
snap({ label: 'c@x', quota: quota({ fiveHourUtilizationPct: 5 }) }), // healthy
|
|
346
|
+
];
|
|
347
|
+
const rows = buildSnapshotKeyboard(snaps);
|
|
348
|
+
const allText = rows.flat().map((b) => b.text);
|
|
349
|
+
expect(allText).toContain('Switch fleet → c@x');
|
|
350
|
+
expect(allText).not.toContain('Switch fleet → b@x');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('hides switch buttons for UNKNOWN-health accounts (probe failed = unsafe)', () => {
|
|
354
|
+
const snaps: AccountSnapshot[] = [
|
|
355
|
+
snap({ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 5 }) }),
|
|
356
|
+
snap({ label: 'broken@x', quota: null, quotaError: 'HTTP 401' }),
|
|
357
|
+
];
|
|
358
|
+
const rows = buildSnapshotKeyboard(snaps);
|
|
359
|
+
const allText = rows.flat().map((b) => b.text);
|
|
360
|
+
expect(allText).not.toContain('Switch fleet → broken@x');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('always includes ↻ Refresh, /usage, + Add in the bottom row', () => {
|
|
364
|
+
const rows = buildSnapshotKeyboard([
|
|
365
|
+
snap({ label: 'a@x', isActive: true, quota: quota({}) }),
|
|
366
|
+
]);
|
|
367
|
+
const last = rows[rows.length - 1]!.map((b) => b.text);
|
|
368
|
+
expect(last).toEqual(['↻ Refresh', '/usage', '+ Add']);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('caps switch buttons via maxSwitchButtons option', () => {
|
|
372
|
+
const snaps: AccountSnapshot[] = Array.from({ length: 10 }, (_, i) =>
|
|
373
|
+
snap({ label: `acc${i}@x`, isActive: i === 0, quota: quota({ fiveHourUtilizationPct: 5 }) }),
|
|
374
|
+
);
|
|
375
|
+
const rows = buildSnapshotKeyboard(snaps, { maxSwitchButtons: 2 });
|
|
376
|
+
const switchRows = rows.slice(0, -1);
|
|
377
|
+
expect(switchRows.length).toBe(2);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// ── buildSnapshotsFromState ──────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
describe('buildSnapshotsFromState', () => {
|
|
384
|
+
it('zips broker accounts with parallel quota results, marks the active', () => {
|
|
385
|
+
const state: ListStateData = {
|
|
386
|
+
active: 'b@x',
|
|
387
|
+
fallback_order: ['a@x', 'b@x', 'c@x'],
|
|
388
|
+
accounts: [
|
|
389
|
+
{ label: 'a@x', exhausted: false },
|
|
390
|
+
{ label: 'b@x', exhausted: false },
|
|
391
|
+
{ label: 'c@x', exhausted: false },
|
|
392
|
+
],
|
|
393
|
+
agents: [],
|
|
394
|
+
consumers: [],
|
|
395
|
+
};
|
|
396
|
+
const snaps = buildSnapshotsFromState(state, [
|
|
397
|
+
{ ok: true, data: quota({ fiveHourUtilizationPct: 5 }) },
|
|
398
|
+
{ ok: true, data: quota({ fiveHourUtilizationPct: 50 }) },
|
|
399
|
+
{ ok: false, reason: 'HTTP 401' },
|
|
400
|
+
]);
|
|
401
|
+
expect(snaps.map((s) => s.label)).toEqual(['a@x', 'b@x', 'c@x']);
|
|
402
|
+
expect(snaps.map((s) => s.isActive)).toEqual([false, true, false]);
|
|
403
|
+
expect(snaps[0]!.quota?.fiveHourUtilizationPct).toBe(5);
|
|
404
|
+
expect(snaps[2]!.quota).toBeNull();
|
|
405
|
+
expect(snaps[2]!.quotaError).toBe('HTTP 401');
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// ── recommendation logic edge cases ──────────────────────────────────
|
|
410
|
+
|
|
411
|
+
describe('recommendation', () => {
|
|
412
|
+
it('warns "all blocked" when no healthy alternative exists', () => {
|
|
413
|
+
const snaps: AccountSnapshot[] = [
|
|
414
|
+
snap({ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100 }) }),
|
|
415
|
+
snap({ label: 'b@x', quota: quota({ sevenDayUtilizationPct: 100, sevenDayResetAt: new Date('2026-05-17T00:00:00Z') }) }),
|
|
416
|
+
];
|
|
417
|
+
const out = recommendation(snaps, NOW);
|
|
418
|
+
expect(out).toMatch(/All accounts blocked\. Earliest recovery: b@x in 1d/);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('reports throttling-with-no-alt when active is throttling and others are too', () => {
|
|
422
|
+
const snaps: AccountSnapshot[] = [
|
|
423
|
+
snap({ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 90 }) }),
|
|
424
|
+
snap({ label: 'b@x', quota: quota({ fiveHourUtilizationPct: 85 }) }),
|
|
425
|
+
];
|
|
426
|
+
const out = recommendation(snaps, NOW);
|
|
427
|
+
expect(out).toContain('throttling; no healthy alternative');
|
|
428
|
+
});
|
|
429
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
type BrokerStateView,
|
|
4
|
+
type ClaudeJsonView,
|
|
5
|
+
buildAuthSummaryFromBroker,
|
|
6
|
+
formatExpiresInRelative,
|
|
7
|
+
} from '../gateway/auth-status-adapter.js'
|
|
8
|
+
|
|
9
|
+
const NOW = 1_700_000_000_000
|
|
10
|
+
|
|
11
|
+
function state(over: Partial<BrokerStateView> = {}): BrokerStateView {
|
|
12
|
+
return {
|
|
13
|
+
active: 'ken@example.com',
|
|
14
|
+
fallback_order: ['ken@example.com'],
|
|
15
|
+
accounts: [
|
|
16
|
+
{ label: 'ken@example.com', expiresAt: NOW + 29 * 86_400_000, exhausted: false },
|
|
17
|
+
],
|
|
18
|
+
agents: [{ name: 'clerk', account: 'ken@example.com', override: null }],
|
|
19
|
+
...over,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const claudeMax: ClaudeJsonView = { oauthAccount: { billingType: 'claude_max' } }
|
|
24
|
+
|
|
25
|
+
describe('formatExpiresInRelative', () => {
|
|
26
|
+
it('returns null for missing / non-finite', () => {
|
|
27
|
+
expect(formatExpiresInRelative(undefined, NOW)).toBeNull()
|
|
28
|
+
expect(formatExpiresInRelative(NaN, NOW)).toBeNull()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('returns "expired" when in the past', () => {
|
|
32
|
+
expect(formatExpiresInRelative(NOW - 1, NOW)).toBe('expired')
|
|
33
|
+
expect(formatExpiresInRelative(NOW - 86_400_000, NOW)).toBe('expired')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('formats days for ≥ 24h', () => {
|
|
37
|
+
expect(formatExpiresInRelative(NOW + 29 * 86_400_000, NOW)).toBe('in 29 days')
|
|
38
|
+
expect(formatExpiresInRelative(NOW + 86_400_000, NOW)).toBe('in 1 day')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('formats hours when < 24h but ≥ 1h', () => {
|
|
42
|
+
expect(formatExpiresInRelative(NOW + 5 * 3_600_000, NOW)).toBe('in 5 hours')
|
|
43
|
+
expect(formatExpiresInRelative(NOW + 3_600_000, NOW)).toBe('in 1 hour')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('formats minutes when < 1h', () => {
|
|
47
|
+
expect(formatExpiresInRelative(NOW + 30 * 60_000, NOW)).toBe('in 30 minutes')
|
|
48
|
+
expect(formatExpiresInRelative(NOW + 60_000, NOW)).toBe('in 1 minute')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('buildAuthSummaryFromBroker', () => {
|
|
53
|
+
it('returns null when state is null', () => {
|
|
54
|
+
expect(buildAuthSummaryFromBroker(null, 'clerk', claudeMax, NOW)).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('happy path — agent bound to a known account with future expiry', () => {
|
|
58
|
+
const summary = buildAuthSummaryFromBroker(state(), 'clerk', claudeMax, NOW)
|
|
59
|
+
expect(summary).toEqual({
|
|
60
|
+
authenticated: true,
|
|
61
|
+
subscription_type: 'Max',
|
|
62
|
+
expires_in: 'in 29 days',
|
|
63
|
+
auth_source: 'ken@example.com',
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('agent missing from broker.agents → not authenticated, no source', () => {
|
|
68
|
+
const summary = buildAuthSummaryFromBroker(state(), 'unknown-agent', claudeMax, NOW)
|
|
69
|
+
expect(summary).toEqual({
|
|
70
|
+
authenticated: false,
|
|
71
|
+
subscription_type: 'Max',
|
|
72
|
+
expires_in: null,
|
|
73
|
+
auth_source: null,
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('agent bound to an account broker has no record of → not authenticated', () => {
|
|
78
|
+
const s = state({
|
|
79
|
+
accounts: [], // no matching account
|
|
80
|
+
agents: [{ name: 'clerk', account: 'ghost@example.com', override: null }],
|
|
81
|
+
})
|
|
82
|
+
const summary = buildAuthSummaryFromBroker(s, 'clerk', claudeMax, NOW)
|
|
83
|
+
expect(summary?.authenticated).toBe(false)
|
|
84
|
+
expect(summary?.auth_source).toBe('ghost@example.com')
|
|
85
|
+
expect(summary?.expires_in).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('expired account → authenticated stays true; expires_in says "expired"', () => {
|
|
89
|
+
const s = state({
|
|
90
|
+
accounts: [{ label: 'ken@example.com', expiresAt: NOW - 1, exhausted: false }],
|
|
91
|
+
})
|
|
92
|
+
const summary = buildAuthSummaryFromBroker(s, 'clerk', claudeMax, NOW)
|
|
93
|
+
expect(summary?.authenticated).toBe(true)
|
|
94
|
+
expect(summary?.expires_in).toBe('expired')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('subscription_type pulled from claude.json — Pro tier', () => {
|
|
98
|
+
const summary = buildAuthSummaryFromBroker(
|
|
99
|
+
state(),
|
|
100
|
+
'clerk',
|
|
101
|
+
{ oauthAccount: { billingType: 'claude_pro' } },
|
|
102
|
+
NOW,
|
|
103
|
+
)
|
|
104
|
+
expect(summary?.subscription_type).toBe('Pro')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('subscription_type is null when claudeJson is missing', () => {
|
|
108
|
+
const summary = buildAuthSummaryFromBroker(state(), 'clerk', null, NOW)
|
|
109
|
+
expect(summary?.subscription_type).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('passes through unknown billingType verbatim', () => {
|
|
113
|
+
const summary = buildAuthSummaryFromBroker(
|
|
114
|
+
state(),
|
|
115
|
+
'clerk',
|
|
116
|
+
{ oauthAccount: { billingType: 'team' } },
|
|
117
|
+
NOW,
|
|
118
|
+
)
|
|
119
|
+
expect(summary?.subscription_type).toBe('team')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('exhausted account still authenticated (quota state separate from auth state)', () => {
|
|
123
|
+
const s = state({
|
|
124
|
+
accounts: [{ label: 'ken@example.com', expiresAt: NOW + 86_400_000, exhausted: true }],
|
|
125
|
+
})
|
|
126
|
+
const summary = buildAuthSummaryFromBroker(s, 'clerk', claudeMax, NOW)
|
|
127
|
+
expect(summary?.authenticated).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
})
|