switchroom 0.15.45 → 0.16.5

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +56 -15
  2. package/dist/auth-broker/index.js +383 -97
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +7 -4
  5. package/dist/cli/notion-write-pretool.mjs +35 -4
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
  7. package/dist/cli/self-improve-stop.mjs +428 -0
  8. package/dist/cli/switchroom.js +2894 -841
  9. package/dist/host-control/main.js +2685 -207
  10. package/dist/vault/approvals/kernel-server.js +7453 -7413
  11. package/dist/vault/broker/server.js +11428 -11388
  12. package/examples/minimal.yaml +1 -0
  13. package/examples/switchroom.yaml +1 -0
  14. package/package.json +3 -3
  15. package/profiles/_base/start.sh.hbs +97 -1
  16. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  17. package/profiles/default/CLAUDE.md.hbs +0 -19
  18. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  19. package/telegram-plugin/answer-stream-flag.ts +12 -49
  20. package/telegram-plugin/answer-stream.ts +5 -150
  21. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  22. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  23. package/telegram-plugin/context-exhaustion.ts +12 -0
  24. package/telegram-plugin/demo-mask.ts +154 -0
  25. package/telegram-plugin/dist/bridge/bridge.js +55 -12
  26. package/telegram-plugin/dist/gateway/gateway.js +2938 -977
  27. package/telegram-plugin/dist/server.js +55 -12
  28. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  29. package/telegram-plugin/draft-stream.ts +47 -410
  30. package/telegram-plugin/final-answer-detect.ts +17 -12
  31. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  32. package/telegram-plugin/format.ts +56 -19
  33. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  34. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  35. package/telegram-plugin/gateway/auth-command.ts +70 -14
  36. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  37. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  38. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  39. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  40. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  41. package/telegram-plugin/gateway/effort-command.ts +8 -3
  42. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  43. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  44. package/telegram-plugin/gateway/gateway.ts +1857 -292
  45. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  46. package/telegram-plugin/gateway/model-command.ts +115 -4
  47. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  48. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  49. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  50. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  51. package/telegram-plugin/history.ts +33 -11
  52. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  53. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  54. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  55. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  56. package/telegram-plugin/issues-card.ts +4 -0
  57. package/telegram-plugin/model-unavailable.ts +124 -0
  58. package/telegram-plugin/narrative-dedup.ts +69 -0
  59. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  60. package/telegram-plugin/package.json +3 -3
  61. package/telegram-plugin/pending-work-progress.ts +12 -0
  62. package/telegram-plugin/permission-rule.ts +32 -5
  63. package/telegram-plugin/permission-title.ts +152 -9
  64. package/telegram-plugin/quota-check.ts +13 -0
  65. package/telegram-plugin/quota-watch.ts +135 -7
  66. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  67. package/telegram-plugin/registry/turns-schema.ts +9 -0
  68. package/telegram-plugin/runtime-metrics.ts +13 -0
  69. package/telegram-plugin/session-tail.ts +96 -11
  70. package/telegram-plugin/silence-poke.ts +170 -24
  71. package/telegram-plugin/slot-banner-driver.ts +3 -0
  72. package/telegram-plugin/status-no-truncate.ts +44 -0
  73. package/telegram-plugin/status-reactions.ts +20 -3
  74. package/telegram-plugin/stream-controller.ts +4 -23
  75. package/telegram-plugin/stream-reply-handler.ts +6 -24
  76. package/telegram-plugin/streaming-metrics.ts +91 -0
  77. package/telegram-plugin/subagent-watcher.ts +212 -66
  78. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  79. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  80. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  82. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  83. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  84. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  87. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  88. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  89. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  90. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  91. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  92. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  93. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  94. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  95. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  96. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  97. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  98. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  99. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  100. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  101. package/telegram-plugin/tests/history.test.ts +60 -0
  102. package/telegram-plugin/tests/model-command.test.ts +134 -0
  103. package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
  104. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  105. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  106. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  107. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  108. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  109. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  110. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  112. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  113. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  114. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  115. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  116. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  117. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  118. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  119. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  120. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  121. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  122. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  123. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  124. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  125. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  126. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  127. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  128. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  129. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  130. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  131. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  132. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  133. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  134. package/telegram-plugin/tool-activity-summary.ts +375 -58
  135. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  136. package/telegram-plugin/uat/assertions.ts +115 -0
  137. package/telegram-plugin/uat/driver.ts +68 -0
  138. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  139. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  145. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  146. package/telegram-plugin/welcome-text.ts +13 -1
  147. package/telegram-plugin/worker-activity-feed.ts +157 -82
  148. package/telegram-plugin/draft-transport.ts +0 -122
  149. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  150. 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
- makeCtx({ liveQuotas: async () => FIXTURE_QUOTAS, tz: 'UTC' }),
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
- const pixRow = out.split('\n').find((l) => l.includes('5h refills') && l.includes('7d resets'));
199
- expect(pixRow).toBeDefined();
200
- expect(pixRow!.indexOf('5h refills')).toBeLessThan(pixRow!.indexOf('7d resets'));
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: Date.now() - 12_000,
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('warns "all blocked" when no healthy alternative exists', () => {
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).toMatch(/All accounts blocked\. Earliest recovery: b@x in 1d/);
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
+ });