switchroom 0.15.44 → 0.16.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +122 -88
- package/dist/auth-broker/index.js +463 -177
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +17 -14
- package/dist/cli/notion-write-pretool.mjs +117 -86
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +3249 -1241
- package/dist/cli/ui/index.html +1 -1
- package/dist/host-control/main.js +2833 -355
- package/dist/vault/approvals/kernel-server.js +7482 -7439
- package/dist/vault/broker/server.js +11315 -11272
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +88 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +3 -22
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +167 -124
- package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
- package/telegram-plugin/dist/server.js +215 -172
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1837 -291
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* auth-snapshot-format.test.ts).
|
|
21
21
|
*/
|
|
22
22
|
import { describe, it, expect, vi } from 'vitest';
|
|
23
|
+
import { __resetDemoMaskCachesForTest } from '../demo-mask.js';
|
|
23
24
|
import { renderShowText, handleAuthCommand } from '../gateway/auth-command.js';
|
|
24
25
|
import type { AuthBrokerClient, AuthCommandContext } from '../gateway/auth-command.js';
|
|
25
26
|
import type { ListStateData } from '../../src/auth/broker/client.js';
|
|
@@ -105,6 +106,31 @@ describe('renderShowText — Format 2 vs legacy', () => {
|
|
|
105
106
|
expect(out).not.toContain('🔋');
|
|
106
107
|
expect(out).toContain('ACCOUNT');
|
|
107
108
|
});
|
|
109
|
+
|
|
110
|
+
// demo mode (the `/auth demo` suffix) — masks email labels in BOTH shapes.
|
|
111
|
+
describe('demo mode masks account-email labels', () => {
|
|
112
|
+
it('WITHOUT demo, the real labels render (Format 2)', () => {
|
|
113
|
+
const out = renderShowText(FIXTURE_STATE, NOW_MS, { liveQuotas: FIXTURE_QUOTAS, tz: 'UTC' });
|
|
114
|
+
expect(out).toContain('ken@x');
|
|
115
|
+
expect(out).toContain('you@x');
|
|
116
|
+
});
|
|
117
|
+
it('WITH demo, no real label leaks (Format 2)', () => {
|
|
118
|
+
__resetDemoMaskCachesForTest();
|
|
119
|
+
const out = renderShowText(FIXTURE_STATE, NOW_MS, { liveQuotas: FIXTURE_QUOTAS, tz: 'UTC', demo: true });
|
|
120
|
+
expect(out).not.toContain('ken@x');
|
|
121
|
+
expect(out).not.toContain('me@x');
|
|
122
|
+
expect(out).not.toContain('you@x');
|
|
123
|
+
expect(out).toMatch(/@example\.com/);
|
|
124
|
+
});
|
|
125
|
+
it('WITH demo, the legacy ASCII table also masks labels', () => {
|
|
126
|
+
__resetDemoMaskCachesForTest();
|
|
127
|
+
const out = renderShowText(FIXTURE_STATE, NOW_MS, { demo: true });
|
|
128
|
+
expect(out).toContain('ACCOUNT'); // still the legacy table
|
|
129
|
+
expect(out).not.toContain('ken@x');
|
|
130
|
+
expect(out).not.toContain('you@x');
|
|
131
|
+
expect(out).toMatch(/@example\.com/);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
108
134
|
});
|
|
109
135
|
|
|
110
136
|
describe('handleAuthCommand — keyboard attachment', () => {
|
|
@@ -127,7 +153,8 @@ describe('handleAuthCommand — keyboard attachment', () => {
|
|
|
127
153
|
it('attaches a smart keyboard when liveQuotas yields one result per account', async () => {
|
|
128
154
|
const reply = await handleAuthCommand(
|
|
129
155
|
{ kind: 'show' },
|
|
130
|
-
|
|
156
|
+
// #2495 Change 2 — the enricher now returns { quotas, staleCachedAtMs? }.
|
|
157
|
+
makeCtx({ liveQuotas: async () => ({ quotas: FIXTURE_QUOTAS }), tz: 'UTC' }),
|
|
131
158
|
);
|
|
132
159
|
expect(reply.keyboard).toBeDefined();
|
|
133
160
|
const allButtonText = reply.keyboard!.flat().map((b) => b.text);
|
|
@@ -153,4 +180,47 @@ describe('handleAuthCommand — keyboard attachment', () => {
|
|
|
153
180
|
expect(reply.keyboard).toBeUndefined();
|
|
154
181
|
expect(reply.text).toContain('ACCOUNT'); // legacy table fallback
|
|
155
182
|
});
|
|
183
|
+
|
|
184
|
+
it('#2495 Change 2 — stamps "⚠ cached Nm ago" when the enricher reports a cache fallback', async () => {
|
|
185
|
+
const reply = await handleAuthCommand(
|
|
186
|
+
{ kind: 'show' },
|
|
187
|
+
makeCtx({
|
|
188
|
+
liveQuotas: async () => ({
|
|
189
|
+
quotas: FIXTURE_QUOTAS,
|
|
190
|
+
staleCachedAtMs: Date.now() - 5 * 60_000, // 5 min old cache
|
|
191
|
+
}),
|
|
192
|
+
tz: 'UTC',
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
expect(reply.text).toContain('⚠ cached');
|
|
196
|
+
expect(reply.text).not.toContain('Live · refreshed');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('#2495 Change 2 — stamps a live refresh when the enricher reports no cache fallback', async () => {
|
|
200
|
+
const reply = await handleAuthCommand(
|
|
201
|
+
{ kind: 'show' },
|
|
202
|
+
makeCtx({ liveQuotas: async () => ({ quotas: FIXTURE_QUOTAS }), tz: 'UTC' }),
|
|
203
|
+
);
|
|
204
|
+
expect(reply.text).toContain('Live · refreshed');
|
|
205
|
+
expect(reply.text).not.toContain('⚠ cached');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('ctx.demo masks the account labels in the dashboard reply', async () => {
|
|
209
|
+
__resetDemoMaskCachesForTest();
|
|
210
|
+
const reply = await handleAuthCommand(
|
|
211
|
+
{ kind: 'show' },
|
|
212
|
+
makeCtx({ liveQuotas: async () => ({ quotas: FIXTURE_QUOTAS }), tz: 'UTC', demo: true }),
|
|
213
|
+
);
|
|
214
|
+
expect(reply.text).not.toContain('ken@x');
|
|
215
|
+
expect(reply.text).not.toContain('you@x');
|
|
216
|
+
expect(reply.text).toMatch(/@example\.com/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('WITHOUT ctx.demo the real labels still render in the dashboard reply', async () => {
|
|
220
|
+
const reply = await handleAuthCommand(
|
|
221
|
+
{ kind: 'show' },
|
|
222
|
+
makeCtx({ liveQuotas: async () => ({ quotas: FIXTURE_QUOTAS }), tz: 'UTC' }),
|
|
223
|
+
);
|
|
224
|
+
expect(reply.text).toContain('you@x');
|
|
225
|
+
});
|
|
156
226
|
});
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* with hand-crafted QuotaUtilization fixtures.
|
|
5
5
|
*/
|
|
6
6
|
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import { __resetDemoMaskCachesForTest } from '../demo-mask.js';
|
|
7
8
|
import {
|
|
8
9
|
classifyHealth,
|
|
10
|
+
blockedReason,
|
|
9
11
|
bindingWindow,
|
|
10
12
|
formatRelative,
|
|
11
13
|
formatAbsolute,
|
|
@@ -67,6 +69,24 @@ describe('classifyHealth', () => {
|
|
|
67
69
|
it('returns unknown when quota probe failed', () => {
|
|
68
70
|
expect(classifyHealth(snap({ quota: null, quotaError: 'HTTP 401' }))).toBe('unknown');
|
|
69
71
|
});
|
|
72
|
+
it('out_of_credits overage at 0% util → healthy (demoted to informational — serves fine from quota)', () => {
|
|
73
|
+
// THE KEY CHANGE: out_of_credits is no longer serve-blocking. An account at
|
|
74
|
+
// 0% util with out_of_credits is a valid failover target (carol scenario).
|
|
75
|
+
expect(
|
|
76
|
+
classifyHealth(snap({ quota: quota({ fiveHourUtilizationPct: 0, sevenDayUtilizationPct: 0, overageStatus: 'rejected', overageDisabledReason: 'out_of_credits' }) })),
|
|
77
|
+
).toBe('healthy');
|
|
78
|
+
});
|
|
79
|
+
it('org_level_disabled at 75% util → unchanged/healthy (MANDATORY non-regression: live active account)', () => {
|
|
80
|
+
// overageStatus:"rejected" + the benign reason must NOT flip it to blocked.
|
|
81
|
+
expect(
|
|
82
|
+
classifyHealth(snap({ quota: quota({ fiveHourUtilizationPct: 75, sevenDayUtilizationPct: 40, overageStatus: 'rejected', overageDisabledReason: 'org_level_disabled' }) })),
|
|
83
|
+
).toBe('healthy');
|
|
84
|
+
});
|
|
85
|
+
it('unknown overage reason (payment_failed) at 0% util → healthy (deny-by-omission)', () => {
|
|
86
|
+
expect(
|
|
87
|
+
classifyHealth(snap({ quota: quota({ fiveHourUtilizationPct: 0, sevenDayUtilizationPct: 0, overageDisabledReason: 'payment_failed' }) })),
|
|
88
|
+
).toBe('healthy');
|
|
89
|
+
});
|
|
70
90
|
it('THROTTLING_THRESHOLD_PCT is 80 (regression — design choice, see jtbd)', () => {
|
|
71
91
|
// If this number changes, the recommendation footer + button visibility
|
|
72
92
|
// shift; bump it deliberately.
|
|
@@ -195,9 +215,18 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
195
215
|
it('puts the imminent window first on healthy/throttling rows', () => {
|
|
196
216
|
const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
|
|
197
217
|
// you: 5h reset is in 7m, 7d reset is in 2d. 5h should come first.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
218
|
+
// The two reset segments now render on SEPARATE lines (so the 7d
|
|
219
|
+
// segment doesn't wrap mid-line on a narrow phone) — the imminent 5h
|
|
220
|
+
// line must precede the 7d line, each on its own line.
|
|
221
|
+
const lines = out.split('\n');
|
|
222
|
+
const fiveLine = lines.findIndex((l) => l.includes('5h refills'));
|
|
223
|
+
const sevenLine = lines.findIndex((l) => l.includes('7d resets'));
|
|
224
|
+
expect(fiveLine).toBeGreaterThanOrEqual(0);
|
|
225
|
+
expect(sevenLine).toBeGreaterThanOrEqual(0);
|
|
226
|
+
// Distinct lines, 5h before 7d.
|
|
227
|
+
expect(fiveLine).toBeLessThan(sevenLine);
|
|
228
|
+
expect(lines[fiveLine]).not.toContain('7d resets');
|
|
229
|
+
expect(lines[sevenLine]).not.toContain('5h refills');
|
|
201
230
|
});
|
|
202
231
|
|
|
203
232
|
it('emits a recommendation footer that names a healthy alternative when active is throttling', () => {
|
|
@@ -230,10 +259,69 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
230
259
|
it('renders refresh stamp when liveProbedAtMs given', () => {
|
|
231
260
|
const out = renderAuthSnapshotFormat2(fixtureSnaps.slice(0, 1), {
|
|
232
261
|
now: NOW,
|
|
233
|
-
liveProbedAtMs:
|
|
262
|
+
liveProbedAtMs: NOW.getTime() - 12_000,
|
|
234
263
|
});
|
|
235
264
|
expect(out).toMatch(/<i>Live · refreshed \d+s ago<\/i>/);
|
|
236
265
|
});
|
|
266
|
+
|
|
267
|
+
it('#2495 Change 2 — renders "⚠ cached Nm ago" (NOT a live stamp) on staleCachedAtMs', () => {
|
|
268
|
+
const out = renderAuthSnapshotFormat2(fixtureSnaps.slice(0, 1), {
|
|
269
|
+
now: NOW,
|
|
270
|
+
staleCachedAtMs: NOW.getTime() - 3 * 60_000, // 3 min old cache
|
|
271
|
+
});
|
|
272
|
+
expect(out).toMatch(/<i>⚠ cached 3m ago<\/i>/);
|
|
273
|
+
// Crucially: no false live stamp.
|
|
274
|
+
expect(out).not.toContain('Live · refreshed');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('#2495 Change 2 — staleCachedAtMs takes precedence over liveProbedAtMs', () => {
|
|
278
|
+
const out = renderAuthSnapshotFormat2(fixtureSnaps.slice(0, 1), {
|
|
279
|
+
now: NOW,
|
|
280
|
+
liveProbedAtMs: NOW.getTime(),
|
|
281
|
+
staleCachedAtMs: NOW.getTime() - 90_000,
|
|
282
|
+
});
|
|
283
|
+
expect(out).toContain('⚠ cached');
|
|
284
|
+
expect(out).not.toContain('Live · refreshed');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ── demo mode (the `/usage demo` / `/auth demo` suffix) ──────────────
|
|
288
|
+
describe('demo mode masks email labels', () => {
|
|
289
|
+
it('WITHOUT demo, the real account emails still render', () => {
|
|
290
|
+
const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
|
|
291
|
+
expect(out).toContain('alice@example.com');
|
|
292
|
+
expect(out).toContain('bob@example.com');
|
|
293
|
+
expect(out).toContain('you@example.com');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('WITH demo, no real account label leaks and rows render masked emails', () => {
|
|
297
|
+
__resetDemoMaskCachesForTest();
|
|
298
|
+
const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC', demo: true });
|
|
299
|
+
// Real labels gone.
|
|
300
|
+
expect(out).not.toContain('alice@example.com');
|
|
301
|
+
expect(out).not.toContain('bob@example.com');
|
|
302
|
+
expect(out).not.toContain('you@example.com');
|
|
303
|
+
// Masked fakes present (pool order from a clean cache: blocked-first
|
|
304
|
+
// render order means bob is masked first, then alice/you in healthy).
|
|
305
|
+
expect(out).toMatch(/<code>[^@<]+@example\.com<\/code>/);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('WITH demo, the recommendation footer masks the active label', () => {
|
|
309
|
+
__resetDemoMaskCachesForTest();
|
|
310
|
+
const happy: AccountSnapshot[] = [
|
|
311
|
+
snap({ label: 'real-active@corp.com', isActive: true, quota: quota({ fiveHourUtilizationPct: 5 }) }),
|
|
312
|
+
];
|
|
313
|
+
const out = renderAuthSnapshotFormat2(happy, { now: NOW, demo: true });
|
|
314
|
+
expect(out).not.toContain('real-active@corp.com');
|
|
315
|
+
expect(out).toMatch(/Recommendation: stay on [^@\s]+@example\.com\./);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('demo masking is deterministic across two renders in the same process', () => {
|
|
319
|
+
__resetDemoMaskCachesForTest();
|
|
320
|
+
const a = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC', demo: true });
|
|
321
|
+
const b = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC', demo: true });
|
|
322
|
+
expect(a).toBe(b);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
237
325
|
});
|
|
238
326
|
|
|
239
327
|
// ── renderFallbackAnnouncement ───────────────────────────────────────
|
|
@@ -335,6 +423,75 @@ describe('renderFallbackAnnouncement', () => {
|
|
|
335
423
|
expect(out).toMatch(/ken@x recovers.*in 4h 57m/);
|
|
336
424
|
expect(out).toContain('/auth add');
|
|
337
425
|
});
|
|
426
|
+
|
|
427
|
+
it('Bug 3 — all-blocked card ENUMERATES every account (5h%/7d% + recovery ETA)', () => {
|
|
428
|
+
// Three walled accounts with different recovery times. The card must list
|
|
429
|
+
// ALL of them so the user can verify true fleet-wide exhaustion, not just
|
|
430
|
+
// the one triggering account.
|
|
431
|
+
const ken = quota({
|
|
432
|
+
fiveHourUtilizationPct: 100,
|
|
433
|
+
sevenDayUtilizationPct: 23,
|
|
434
|
+
fiveHourResetAt: new Date('2026-05-15T05:50:00Z'),
|
|
435
|
+
sevenDayResetAt: new Date('2026-05-18T19:00:00Z'),
|
|
436
|
+
representativeClaim: 'five_hour',
|
|
437
|
+
});
|
|
438
|
+
const you = quota({
|
|
439
|
+
fiveHourUtilizationPct: 30,
|
|
440
|
+
sevenDayUtilizationPct: 100,
|
|
441
|
+
sevenDayResetAt: new Date('2026-05-16T10:00:00Z'),
|
|
442
|
+
representativeClaim: 'seven_day',
|
|
443
|
+
});
|
|
444
|
+
const carol = quota({
|
|
445
|
+
fiveHourUtilizationPct: 100,
|
|
446
|
+
sevenDayUtilizationPct: 60,
|
|
447
|
+
fiveHourResetAt: new Date('2026-05-15T03:00:00Z'),
|
|
448
|
+
representativeClaim: 'five_hour',
|
|
449
|
+
});
|
|
450
|
+
const out = renderFallbackAnnouncement({
|
|
451
|
+
oldLabel: 'ken@x',
|
|
452
|
+
oldQuota: ken,
|
|
453
|
+
newLabel: null,
|
|
454
|
+
newQuota: null,
|
|
455
|
+
triggerAgent: 'carrie',
|
|
456
|
+
fleetSnapshots: [
|
|
457
|
+
snap({ label: 'ken@x', isActive: true, quota: ken }),
|
|
458
|
+
snap({ label: 'you@x', quota: you }),
|
|
459
|
+
snap({ label: 'carol@x', quota: carol }),
|
|
460
|
+
],
|
|
461
|
+
now: NOW,
|
|
462
|
+
tz: 'UTC',
|
|
463
|
+
});
|
|
464
|
+
expect(out).toContain('🔴 <b>All accounts blocked');
|
|
465
|
+
// Every account is listed (not just the trigger).
|
|
466
|
+
expect(out).toContain('ken@x');
|
|
467
|
+
expect(out).toContain('you@x');
|
|
468
|
+
expect(out).toContain('carol@x');
|
|
469
|
+
// Per-account utilization rows are rendered (the renderAccountRow shape).
|
|
470
|
+
expect(out).toMatch(/100%\s*\/\s*23%/); // ken
|
|
471
|
+
expect(out).toMatch(/30%\s*\/\s*100%/); // you
|
|
472
|
+
expect(out).toMatch(/100%\s*\/\s*60%/); // carol
|
|
473
|
+
// Each account's recovery countdown is surfaced.
|
|
474
|
+
expect(out).toMatch(/quota exhausted/);
|
|
475
|
+
// The earliest recovery across the fleet (carol, 5h reset at 03:00Z = ~2h)
|
|
476
|
+
// is called out explicitly.
|
|
477
|
+
expect(out).toMatch(/Earliest recovery:\s*<code>carol@x<\/code>/);
|
|
478
|
+
expect(out).toContain('/auth add');
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('Bug 3 back-compat — no fleetSnapshots falls back to the single-account shape', () => {
|
|
482
|
+
const out = renderFallbackAnnouncement({
|
|
483
|
+
oldLabel: 'ken@x',
|
|
484
|
+
oldQuota: KEN_5H_BLOWN,
|
|
485
|
+
newLabel: null,
|
|
486
|
+
newQuota: null,
|
|
487
|
+
triggerAgent: 'carrie',
|
|
488
|
+
now: NOW,
|
|
489
|
+
tz: 'UTC',
|
|
490
|
+
});
|
|
491
|
+
expect(out).toContain('🔴 <b>All accounts blocked');
|
|
492
|
+
expect(out).toMatch(/ken@x recovers.*in 4h 57m/);
|
|
493
|
+
expect(out).not.toContain('Earliest recovery:');
|
|
494
|
+
});
|
|
338
495
|
});
|
|
339
496
|
|
|
340
497
|
// ── buildSnapshotKeyboard ────────────────────────────────────────────
|
|
@@ -378,6 +535,31 @@ describe('buildSnapshotKeyboard', () => {
|
|
|
378
535
|
const switchRows = rows.slice(0, -1);
|
|
379
536
|
expect(switchRows.length).toBe(2);
|
|
380
537
|
});
|
|
538
|
+
|
|
539
|
+
it('#2495 nit A — threads `now` so a refilled-since-snapshot account is offered as a switch target', () => {
|
|
540
|
+
// refilled@x reads 100% on 5h, but its reset is in the PAST relative to the
|
|
541
|
+
// threaded `now` → refill-normalized to 0% → healthy → a valid switch
|
|
542
|
+
// target. With a default `new Date()` (well after the fixture epoch) the
|
|
543
|
+
// normalization still treats it as refilled, so to prove the THREADING we
|
|
544
|
+
// compare two explicit clocks.
|
|
545
|
+
const resetAt = new Date('2026-05-15T00:00:00Z'); // before `now`
|
|
546
|
+
const snaps: AccountSnapshot[] = [
|
|
547
|
+
snap({ label: 'active@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 5 }) }),
|
|
548
|
+
snap({
|
|
549
|
+
label: 'refilled@x',
|
|
550
|
+
quota: quota({ fiveHourUtilizationPct: 100, fiveHourResetAt: resetAt }),
|
|
551
|
+
}),
|
|
552
|
+
];
|
|
553
|
+
// `now` AFTER the reset → refilled@x normalizes to healthy → offered.
|
|
554
|
+
const after = buildSnapshotKeyboard(snaps, { now: new Date('2026-05-15T00:53:00Z') })
|
|
555
|
+
.flat().map((b) => b.text);
|
|
556
|
+
expect(after).toContain('Switch fleet → refilled@x');
|
|
557
|
+
// `now` BEFORE the reset → still walled → NOT offered. Proves the threaded
|
|
558
|
+
// clock actually drives classification (a default-`now` impl would ignore it).
|
|
559
|
+
const before = buildSnapshotKeyboard(snaps, { now: new Date('2026-05-14T23:00:00Z') })
|
|
560
|
+
.flat().map((b) => b.text);
|
|
561
|
+
expect(before).not.toContain('Switch fleet → refilled@x');
|
|
562
|
+
});
|
|
381
563
|
});
|
|
382
564
|
|
|
383
565
|
// ── buildSnapshotsFromState ──────────────────────────────────────────
|
|
@@ -541,13 +723,26 @@ describe('buildSnapshotsFromCachedState', () => {
|
|
|
541
723
|
// ── recommendation logic edge cases ──────────────────────────────────
|
|
542
724
|
|
|
543
725
|
describe('recommendation', () => {
|
|
544
|
-
it('
|
|
726
|
+
it('#2494 Bug B: does NOT say "all blocked" when an alternative is refilling', () => {
|
|
727
|
+
// a@x is maxed with no reset (truly blocked); b@x is maxed but its weekly
|
|
728
|
+
// window resets in ~2d (refilling). The honest summary must surface the
|
|
729
|
+
// refill, not collapse to a false "All accounts blocked".
|
|
545
730
|
const snaps: AccountSnapshot[] = [
|
|
546
731
|
snap({ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100 }) }),
|
|
547
732
|
snap({ label: 'b@x', quota: quota({ sevenDayUtilizationPct: 100, sevenDayResetAt: new Date('2026-05-17T00:00:00Z') }) }),
|
|
548
733
|
];
|
|
549
734
|
const out = recommendation(snaps, NOW);
|
|
550
|
-
expect(out).
|
|
735
|
+
expect(out).not.toContain('All accounts blocked');
|
|
736
|
+
expect(out).toMatch(/soonest refill: b@x in 1d/);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('#2494 Bug B: says "all blocked" only when EVERY account is truly walled (no reset)', () => {
|
|
740
|
+
const snaps: AccountSnapshot[] = [
|
|
741
|
+
snap({ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100 }) }),
|
|
742
|
+
snap({ label: 'b@x', quota: quota({ sevenDayUtilizationPct: 100 }) }),
|
|
743
|
+
];
|
|
744
|
+
const out = recommendation(snaps, NOW);
|
|
745
|
+
expect(out).toContain('All accounts blocked');
|
|
551
746
|
});
|
|
552
747
|
|
|
553
748
|
it('reports throttling-with-no-alt when active is throttling and others are too', () => {
|
|
@@ -558,4 +753,179 @@ describe('recommendation', () => {
|
|
|
558
753
|
const out = recommendation(snaps, NOW);
|
|
559
754
|
expect(out).toContain('throttling; no healthy alternative');
|
|
560
755
|
});
|
|
756
|
+
|
|
757
|
+
it('#2494 Bug B: mixed blocked-active + throttling-other → recommends the throttling slot, never "all blocked"', () => {
|
|
758
|
+
const snaps: AccountSnapshot[] = [
|
|
759
|
+
snap({ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100 }) }),
|
|
760
|
+
snap({ label: 'b@x', quota: quota({ fiveHourUtilizationPct: 85 }) }), // throttling, usable
|
|
761
|
+
];
|
|
762
|
+
const out = recommendation(snaps, NOW);
|
|
763
|
+
expect(out).not.toContain('All accounts blocked');
|
|
764
|
+
expect(out).toContain('b@x is throttling but still usable');
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// ── #2494 Bug A — refill-aware classification ───────────────────────────
|
|
769
|
+
|
|
770
|
+
describe('#2494 Bug A — refill-aware classifyHealth', () => {
|
|
771
|
+
it('a pre-refill snapshot read AFTER its 5h reset classifies healthy', () => {
|
|
772
|
+
// Captured at 100% on a 5h window whose reset is 3 min in the PAST → rolled.
|
|
773
|
+
const pastReset = new Date(NOW.getTime() - 3 * 60_000);
|
|
774
|
+
const s = snap({
|
|
775
|
+
isActive: true,
|
|
776
|
+
quota: quota({ fiveHourUtilizationPct: 100, fiveHourResetAt: pastReset, sevenDayUtilizationPct: 1 }),
|
|
777
|
+
});
|
|
778
|
+
expect(classifyHealth(s, NOW)).toBe('healthy');
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it('a maxed weekly whose reset has PASSED classifies healthy', () => {
|
|
782
|
+
const pastWeekly = new Date(NOW.getTime() - 60_000);
|
|
783
|
+
const s = snap({ quota: quota({ sevenDayUtilizationPct: 100, sevenDayResetAt: pastWeekly, fiveHourUtilizationPct: 2 }) });
|
|
784
|
+
expect(classifyHealth(s, NOW)).toBe('healthy');
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it('a maxed window with a FUTURE reset still classifies blocked (not yet refilled)', () => {
|
|
788
|
+
const futureReset = new Date(NOW.getTime() + 60 * 60_000);
|
|
789
|
+
const s = snap({ quota: quota({ fiveHourUtilizationPct: 100, fiveHourResetAt: futureReset }) });
|
|
790
|
+
expect(classifyHealth(s, NOW)).toBe('blocked');
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it('the just-refilled lone active account is not falsely "all blocked" in the summary', () => {
|
|
794
|
+
const pastReset = new Date(NOW.getTime() - 3 * 60_000);
|
|
795
|
+
const snaps: AccountSnapshot[] = [
|
|
796
|
+
snap({ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100, fiveHourResetAt: pastReset, sevenDayUtilizationPct: 1 }) }),
|
|
797
|
+
];
|
|
798
|
+
expect(recommendation(snaps, NOW)).toContain('stay on a@x');
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
// ── #2494 Bug C — quota-exhausted vs thin probe (billing-dead demoted) ────────
|
|
803
|
+
|
|
804
|
+
describe('#2494 Bug C — blockedReason + thin probe (out_of_credits now informational)', () => {
|
|
805
|
+
it('out_of_credits at 0% util → healthy (demoted from billing-dead to informational)', () => {
|
|
806
|
+
// THE KEY CHANGE: out_of_credits is no longer a blocked state.
|
|
807
|
+
const s = snap({ quota: quota({ fiveHourUtilizationPct: 0, overageDisabledReason: 'out_of_credits' }) });
|
|
808
|
+
expect(classifyHealth(s, NOW)).toBe('healthy');
|
|
809
|
+
expect(blockedReason(s, NOW)).toBeNull(); // not blocked → no blocked reason
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it('out_of_credits at HIGH util is still blocked (via util wall, not credits)', () => {
|
|
813
|
+
// blocked because 5h>=99.5%, NOT because of out_of_credits
|
|
814
|
+
const futureReset = new Date(NOW.getTime() + 30 * 60_000);
|
|
815
|
+
const s = snap({ quota: quota({ fiveHourUtilizationPct: 100, fiveHourResetAt: futureReset, overageDisabledReason: 'out_of_credits' }) });
|
|
816
|
+
expect(classifyHealth(s, NOW)).toBe('blocked');
|
|
817
|
+
expect(blockedReason(s, NOW)).toBe('quota-exhausted'); // blocked by quota, not billing
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('a maxed window (no overage reason) → quota-exhausted (recoverable)', () => {
|
|
821
|
+
const futureReset = new Date(NOW.getTime() + 30 * 60_000);
|
|
822
|
+
const s = snap({ quota: quota({ fiveHourUtilizationPct: 100, fiveHourResetAt: futureReset }) });
|
|
823
|
+
expect(blockedReason(s, NOW)).toBe('quota-exhausted');
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it('blockedReason is null for a non-blocked account', () => {
|
|
827
|
+
expect(blockedReason(snap({ quota: quota({ fiveHourUtilizationPct: 10 }) }), NOW)).toBeNull();
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('org_level_disabled behavior is unchanged — still healthy', () => {
|
|
831
|
+
const s = snap({ quota: quota({ fiveHourUtilizationPct: 75, sevenDayUtilizationPct: 40, overageStatus: 'rejected', overageDisabledReason: 'org_level_disabled' }) });
|
|
832
|
+
expect(classifyHealth(s, NOW)).toBe('healthy');
|
|
833
|
+
expect(blockedReason(s, NOW)).toBeNull();
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('a thin/headerless probe classifies unknown, never a confident 0%/healthy', () => {
|
|
837
|
+
const s = snap({
|
|
838
|
+
quota: quota({ fiveHourUtilizationPct: 0, sevenDayUtilizationPct: 0, fiveHourUtilPresent: false, sevenDayUtilPresent: false }),
|
|
839
|
+
});
|
|
840
|
+
expect(classifyHealth(s, NOW)).toBe('unknown');
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it('a real 0%/0% probe (both windows present) stays healthy', () => {
|
|
844
|
+
const s = snap({
|
|
845
|
+
quota: quota({ fiveHourUtilizationPct: 0, sevenDayUtilizationPct: 0, fiveHourUtilPresent: true, sevenDayUtilPresent: true }),
|
|
846
|
+
});
|
|
847
|
+
expect(classifyHealth(s, NOW)).toBe('healthy');
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it('a single-window probe (7d header missing) is NOT thin → governed by util', () => {
|
|
851
|
+
const s = snap({
|
|
852
|
+
quota: quota({ fiveHourUtilizationPct: 50, sevenDayUtilizationPct: 0, fiveHourUtilPresent: true, sevenDayUtilPresent: false }),
|
|
853
|
+
});
|
|
854
|
+
expect(classifyHealth(s, NOW)).toBe('healthy');
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
// ── #2494 — row rendering: out_of_credits as informational annotation ─────────
|
|
859
|
+
|
|
860
|
+
describe('#2494 — renderAuthSnapshotFormat2 row rendering (out_of_credits demoted)', () => {
|
|
861
|
+
it('out_of_credits at 0% util → healthy row with informational overage annotation, NOT blocked', () => {
|
|
862
|
+
// THE KEY CHANGE: carol scenario — 0% util, out_of_credits → healthy group
|
|
863
|
+
// with an informational sub-line "overage off (out_of_credits) — serving from quota"
|
|
864
|
+
const out = renderAuthSnapshotFormat2(
|
|
865
|
+
[snap({ label: 'dead@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 0, overageDisabledReason: 'out_of_credits' }) })],
|
|
866
|
+
{ now: NOW },
|
|
867
|
+
);
|
|
868
|
+
// Must be in HEALTHY group, not BLOCKED
|
|
869
|
+
expect(out).toContain('🟢 <b>HEALTHY</b>');
|
|
870
|
+
expect(out).not.toContain('🔴 <b>BLOCKED</b>');
|
|
871
|
+
// Must have informational annotation
|
|
872
|
+
expect(out).toContain('overage off (out_of_credits) — serving from quota');
|
|
873
|
+
// Must NOT have old blocked framing
|
|
874
|
+
expect(out).not.toContain('billing disabled');
|
|
875
|
+
expect(out).not.toContain("won't recover until billing is fixed");
|
|
876
|
+
expect(out).not.toContain('quota exhausted');
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it('out_of_credits account is a valid failover target (appears in buildSnapshotKeyboard switch buttons)', () => {
|
|
880
|
+
// A 0%-util out_of_credits account must be offerable as a switch target
|
|
881
|
+
const snaps: AccountSnapshot[] = [
|
|
882
|
+
snap({ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100 }) }), // blocked, active
|
|
883
|
+
snap({ label: 'carol@example.com', quota: quota({ fiveHourUtilizationPct: 0, overageDisabledReason: 'out_of_credits' }) }), // healthy
|
|
884
|
+
];
|
|
885
|
+
const rows = buildSnapshotKeyboard(snaps);
|
|
886
|
+
const allText = rows.flat().map((b) => b.text);
|
|
887
|
+
expect(allText).toContain('Switch fleet → carol@example.com');
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it('quota-exhausted row shows a recovery countdown, not "billing disabled" or overage annotation', () => {
|
|
891
|
+
const futureReset = new Date(NOW.getTime() + 45 * 60_000);
|
|
892
|
+
const out = renderAuthSnapshotFormat2(
|
|
893
|
+
[snap({ label: 'ex@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100, fiveHourResetAt: futureReset }) })],
|
|
894
|
+
{ now: NOW },
|
|
895
|
+
);
|
|
896
|
+
expect(out).toContain('quota exhausted');
|
|
897
|
+
expect(out).not.toContain('billing disabled');
|
|
898
|
+
expect(out).not.toContain('overage off');
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
it('thin probe row renders "quota unknown", not "0% / 0%"', () => {
|
|
902
|
+
const out = renderAuthSnapshotFormat2(
|
|
903
|
+
[snap({ label: 'thin@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 0, sevenDayUtilizationPct: 0, fiveHourUtilPresent: false, sevenDayUtilPresent: false }) })],
|
|
904
|
+
{ now: NOW },
|
|
905
|
+
);
|
|
906
|
+
expect(out).toContain('quota unknown');
|
|
907
|
+
expect(out).not.toContain('0% / 0%');
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it('org_level_disabled at 85% util has no overage annotation (only in OVERAGE_EXHAUSTED_REASONS)', () => {
|
|
911
|
+
// org_level_disabled is NOT on the OVERAGE_EXHAUSTED_REASONS list → no annotation
|
|
912
|
+
const out = renderAuthSnapshotFormat2(
|
|
913
|
+
[snap({ label: 'org@x', isActive: false, quota: quota({ fiveHourUtilizationPct: 85, overageDisabledReason: 'org_level_disabled' }) })],
|
|
914
|
+
{ now: NOW },
|
|
915
|
+
);
|
|
916
|
+
expect(out).toContain('🟡 <b>THROTTLING</b>');
|
|
917
|
+
expect(out).not.toContain('overage off');
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it('recommendation treats out_of_credits 0%-util account as healthy alternative', () => {
|
|
921
|
+
// The active account is blocked (quota), the out_of_credits 0%-util account
|
|
922
|
+
// is the only alternative — it must be recommended as the switch target.
|
|
923
|
+
const snaps: AccountSnapshot[] = [
|
|
924
|
+
snap({ label: 'main@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100 }) }), // blocked
|
|
925
|
+
snap({ label: 'carol@example.com', quota: quota({ fiveHourUtilizationPct: 0, overageDisabledReason: 'out_of_credits' }) }), // healthy
|
|
926
|
+
];
|
|
927
|
+
const out = recommendation(snaps, NOW);
|
|
928
|
+
expect(out).toContain('switch to carol@example.com');
|
|
929
|
+
expect(out).not.toContain('All accounts blocked');
|
|
930
|
+
});
|
|
561
931
|
});
|
|
@@ -112,6 +112,9 @@ describe('runFleetAutoFallback', () => {
|
|
|
112
112
|
if (out.kind === 'all-blocked') {
|
|
113
113
|
expect(out.announcement).toContain('All accounts blocked');
|
|
114
114
|
expect(out.announcement).toContain('/auth add');
|
|
115
|
+
// Bug 3 — the announcement enumerates EVERY account, not just the trigger.
|
|
116
|
+
expect(out.announcement).toContain('ken@x');
|
|
117
|
+
expect(out.announcement).toContain('me@x');
|
|
115
118
|
}
|
|
116
119
|
});
|
|
117
120
|
|
|
@@ -135,6 +138,71 @@ describe('runFleetAutoFallback', () => {
|
|
|
135
138
|
expect(out.announcement).toContain('Stale event?');
|
|
136
139
|
});
|
|
137
140
|
|
|
141
|
+
it('out_of_credits active account ⇒ NO swap (informational, not a serve-block)', async () => {
|
|
142
|
+
// NEW CONTRACT (fix/out-of-credits-serve-block): out_of_credits is
|
|
143
|
+
// INFORMATIONAL. An active account at 0% util with out_of_credits is
|
|
144
|
+
// classified HEALTHY by classifyHealth(), so the idempotency guard fires
|
|
145
|
+
// and the swap is skipped. out_of_credits must NEVER on its own cause a
|
|
146
|
+
// fleet auto-fallback swap.
|
|
147
|
+
const failover = vi.fn(async () => ({ rolledTo: 'bob@example.com', rolled: ['alice@example.com'] }));
|
|
148
|
+
const out = await runFleetAutoFallback({
|
|
149
|
+
state: state('alice@example.com', ['alice@example.com', 'bob@example.com']),
|
|
150
|
+
quotas: [
|
|
151
|
+
qOk({ fiveHourUtilizationPct: 0, sevenDayUtilizationPct: 0, overageStatus: 'rejected', overageDisabledReason: 'out_of_credits' }),
|
|
152
|
+
qOk({ fiveHourUtilizationPct: 8, sevenDayUtilizationPct: 20 }),
|
|
153
|
+
],
|
|
154
|
+
failover,
|
|
155
|
+
triggerAgent: 'carrie',
|
|
156
|
+
now: NOW,
|
|
157
|
+
tz: 'UTC',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// out_of_credits at 0% util → classifyHealth='healthy' → idempotency guard
|
|
161
|
+
// returns no-eligible-target WITHOUT calling failover.
|
|
162
|
+
expect(out.kind).toBe('no-eligible-target');
|
|
163
|
+
expect(failover).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('genuine quota wall (100% 5h util) ⇒ swap fires (failover safety preserved)', async () => {
|
|
167
|
+
// Failover on a REAL quota wall must still work. This anchors the safety
|
|
168
|
+
// contract: only out_of_credits is demoted, genuine exhaustion still swaps.
|
|
169
|
+
const failover = vi.fn(async () => ({ rolledTo: 'bob@example.com', rolled: ['alice@example.com'] }));
|
|
170
|
+
const out = await runFleetAutoFallback({
|
|
171
|
+
state: state('alice@example.com', ['alice@example.com', 'bob@example.com']),
|
|
172
|
+
quotas: [
|
|
173
|
+
qOk({ fiveHourUtilizationPct: 100, fiveHourResetAt: new Date('2026-05-15T05:50:00Z'), representativeClaim: 'five_hour' }),
|
|
174
|
+
qOk({ fiveHourUtilizationPct: 8, sevenDayUtilizationPct: 20 }),
|
|
175
|
+
],
|
|
176
|
+
failover,
|
|
177
|
+
triggerAgent: 'carrie',
|
|
178
|
+
now: NOW,
|
|
179
|
+
tz: 'UTC',
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(out.kind).toBe('switched');
|
|
183
|
+
expect(failover).toHaveBeenCalledTimes(1);
|
|
184
|
+
if (out.kind === 'switched') expect(out.newLabel).toBe('bob@example.com');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('org_level_disabled active @75% ⇒ NO swap (idempotency: classifyHealth=healthy)', async () => {
|
|
188
|
+
// The benign reason on the live active account must NOT trigger a swap.
|
|
189
|
+
const failover = vi.fn();
|
|
190
|
+
const out = await runFleetAutoFallback({
|
|
191
|
+
state: state('alice@example.com', ['alice@example.com', 'bob@example.com']),
|
|
192
|
+
quotas: [
|
|
193
|
+
qOk({ fiveHourUtilizationPct: 75, sevenDayUtilizationPct: 40, overageStatus: 'rejected', overageDisabledReason: 'org_level_disabled' }),
|
|
194
|
+
qOk({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 10 }),
|
|
195
|
+
],
|
|
196
|
+
failover,
|
|
197
|
+
triggerAgent: 'carrie',
|
|
198
|
+
now: NOW,
|
|
199
|
+
tz: 'UTC',
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(out.kind).toBe('no-eligible-target');
|
|
203
|
+
expect(failover).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
138
206
|
it('returns no-old-active (no failover) when broker has no active account', async () => {
|
|
139
207
|
const failover = vi.fn();
|
|
140
208
|
const out = await runFleetAutoFallback({
|
|
@@ -244,3 +312,55 @@ describe("evaluateFallbackFailureNotice", () => {
|
|
|
244
312
|
expect(sent).toBeGreaterThanOrEqual(1);
|
|
245
313
|
});
|
|
246
314
|
});
|
|
315
|
+
|
|
316
|
+
// ── Bug 2: the all-blocked card must not re-emit every ~60s ───────────────────
|
|
317
|
+
|
|
318
|
+
import {
|
|
319
|
+
evaluateAllBlockedNotice,
|
|
320
|
+
FALLBACK_ALL_BLOCKED_NOTICE_COOLDOWN_MS,
|
|
321
|
+
} from "../auto-fallback-fleet.js";
|
|
322
|
+
|
|
323
|
+
describe("evaluateAllBlockedNotice", () => {
|
|
324
|
+
const T0 = 1_780_000_000_000;
|
|
325
|
+
|
|
326
|
+
it("the first all-blocked card sends and arms the cooldown", () => {
|
|
327
|
+
const r = evaluateAllBlockedNotice({ lastSentAtMs: 0 }, T0);
|
|
328
|
+
expect(r.send).toBe(true);
|
|
329
|
+
expect(r.next.lastSentAtMs).toBe(T0);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("a second all-blocked signal within the cooldown does NOT re-emit (the Bug-2 fix)", () => {
|
|
333
|
+
const armed = { lastSentAtMs: T0 };
|
|
334
|
+
const r = evaluateAllBlockedNotice(armed, T0 + 60_000);
|
|
335
|
+
expect(r.send).toBe(false);
|
|
336
|
+
expect(r.next).toBe(armed); // window not extended by suppressed attempts
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("sends again once the cooldown elapses (still-walled, but the user re-hears once)", () => {
|
|
340
|
+
const r = evaluateAllBlockedNotice(
|
|
341
|
+
{ lastSentAtMs: T0 },
|
|
342
|
+
T0 + FALLBACK_ALL_BLOCKED_NOTICE_COOLDOWN_MS,
|
|
343
|
+
);
|
|
344
|
+
expect(r.send).toBe(true);
|
|
345
|
+
expect(r.next.lastSentAtMs).toBe(T0 + FALLBACK_ALL_BLOCKED_NOTICE_COOLDOWN_MS);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("collapses the ~60s quota_wall_detected re-fire storm to ≤2 cards/hour", () => {
|
|
349
|
+
let state = { lastSentAtMs: 0 };
|
|
350
|
+
let sent = 0;
|
|
351
|
+
for (let t = T0; t < T0 + 3_600_000; t += 60_000) {
|
|
352
|
+
const r = evaluateAllBlockedNotice(state, t);
|
|
353
|
+
if (r.send) sent++;
|
|
354
|
+
state = r.next;
|
|
355
|
+
}
|
|
356
|
+
expect(sent).toBeLessThanOrEqual(2);
|
|
357
|
+
expect(sent).toBeGreaterThanOrEqual(1);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("a NEW transition emits promptly: reset (lastSentAtMs=0) after a swap sends immediately", () => {
|
|
361
|
+
// The gateway resets the window on a successful swap, so a fresh all-blocked
|
|
362
|
+
// after a recovery is not stale-suppressed.
|
|
363
|
+
const r = evaluateAllBlockedNotice({ lastSentAtMs: 0 }, T0 + 5 * 60_000);
|
|
364
|
+
expect(r.send).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
});
|