switchroom 0.18.13 → 0.18.15

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 (47) hide show
  1. package/dist/agent-scheduler/index.js +49 -9
  2. package/dist/auth-broker/index.js +152 -46
  3. package/dist/cli/autoaccept-poll.js +23 -0
  4. package/dist/cli/drive-write-pretool.mjs +24 -1
  5. package/dist/cli/foreground-hog-pretool.mjs +264 -0
  6. package/dist/cli/notion-write-pretool.mjs +0 -1
  7. package/dist/cli/switchroom.js +1185 -1072
  8. package/dist/host-control/main.js +53 -52
  9. package/dist/vault/approvals/kernel-server.js +16 -13
  10. package/dist/vault/broker/server.js +672 -669
  11. package/package.json +1 -1
  12. package/profiles/coding/CLAUDE.md.hbs +2 -0
  13. package/profiles/default/CLAUDE.md.hbs +2 -0
  14. package/skills/switchroom-architecture/telegram.md +0 -1
  15. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  16. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  17. package/telegram-plugin/bridge/bridge.ts +2 -0
  18. package/telegram-plugin/dist/bridge/bridge.js +23 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +765 -67
  20. package/telegram-plugin/dist/server.js +24 -1
  21. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  22. package/telegram-plugin/gateway/auth-command.ts +14 -0
  23. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  24. package/telegram-plugin/gateway/gateway.ts +270 -10
  25. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  26. package/telegram-plugin/history.ts +55 -6
  27. package/telegram-plugin/model-unavailable.ts +234 -2
  28. package/telegram-plugin/render/rich-render.ts +40 -32
  29. package/telegram-plugin/runtime-metrics.ts +31 -0
  30. package/telegram-plugin/session-tail.ts +14 -2
  31. package/telegram-plugin/stream-controller.ts +3 -2
  32. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  34. package/telegram-plugin/tests/history.test.ts +157 -0
  35. package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
  36. package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
  37. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  38. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  39. package/telegram-plugin/tests/runtime-metrics.test.ts +24 -0
  40. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  41. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  42. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  43. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  44. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  45. package/telegram-plugin/tests/throttle-tier.test.ts +454 -0
  46. package/telegram-plugin/throttle-tier.ts +323 -0
  47. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Unit tests for telegram-plugin/throttle-tier.ts — the 429 throttle tier.
3
+ *
4
+ * Pins the operator-approved decision matrix ("retry in place under 5 min,
5
+ * else mark + failover, honest reset messaging"):
6
+ * - transient wording + reset ≤ threshold → throttle (stay put)
7
+ * - transient wording + reset > threshold → failover (escalate)
8
+ * - transient wording + unparseable reset → throttle, now+60s default
9
+ * - non-transient (wall) wording → none (caller's existing path)
10
+ * plus the per-account notice cooldown and the notice text itself.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import {
15
+ build429ClassifiedMetric,
16
+ classify429Detail,
17
+ decideThrottleTier,
18
+ evaluateThrottleNotice,
19
+ isAccountScopedThrottle,
20
+ renderThrottleEscalationNotice,
21
+ renderThrottleNotice,
22
+ throttleRetryInPlaceMaxMs,
23
+ THROTTLE_DEFAULT_WAIT_MS,
24
+ THROTTLE_NOTICE_COOLDOWN_MS,
25
+ THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT,
26
+ type ThrottleNoticeState,
27
+ } from '../throttle-tier.js'
28
+ import { formatModelUnavailableCard, parseResetTime } from '../model-unavailable.js'
29
+
30
+ const NOW = Date.UTC(2026, 6, 12, 8, 0, 0) // 2026-07-12T08:00:00Z
31
+ const THRESHOLD = THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT // 5 min
32
+
33
+ // The canonical transient-negation wording (Anthropic burst-429 shape).
34
+ const TRANSIENT = (suffix: string): string =>
35
+ `This request would exceed your account's rate limit (not your usage limit). ${suffix}`
36
+
37
+ describe('decideThrottleTier — decision matrix', () => {
38
+ it('transient wording + reset within threshold → throttle at the parsed reset', () => {
39
+ const d = decideThrottleTier({
40
+ detail: TRANSIENT('Please retry after 90 seconds.'),
41
+ now: NOW,
42
+ thresholdMs: THRESHOLD,
43
+ })
44
+ expect(d).toEqual({
45
+ action: 'throttle',
46
+ throttledUntilMs: NOW + 90_000,
47
+ resetParsed: true,
48
+ })
49
+ })
50
+
51
+ it('transient wording + "resets in 3m" → throttle (≤ 5 min)', () => {
52
+ const d = decideThrottleTier({
53
+ detail: TRANSIENT('resets in 3m'),
54
+ now: NOW,
55
+ thresholdMs: THRESHOLD,
56
+ })
57
+ expect(d).toEqual({
58
+ action: 'throttle',
59
+ throttledUntilMs: NOW + 3 * 60_000,
60
+ resetParsed: true,
61
+ })
62
+ })
63
+
64
+ it('transient wording + reset beyond threshold → failover with the parsed reset', () => {
65
+ const d = decideThrottleTier({
66
+ detail: TRANSIENT('resets in 2h 15m'),
67
+ now: NOW,
68
+ thresholdMs: THRESHOLD,
69
+ })
70
+ expect(d).toEqual({
71
+ action: 'failover',
72
+ resetAtMs: NOW + (2 * 60 + 15) * 60_000,
73
+ })
74
+ })
75
+
76
+ it('transient wording + NO parseable reset → throttle for the 60s default', () => {
77
+ const d = decideThrottleTier({
78
+ detail: TRANSIENT('try again later.'),
79
+ now: NOW,
80
+ thresholdMs: THRESHOLD,
81
+ })
82
+ expect(d).toEqual({
83
+ action: 'throttle',
84
+ throttledUntilMs: NOW + THROTTLE_DEFAULT_WAIT_MS,
85
+ resetParsed: false,
86
+ })
87
+ })
88
+
89
+ it('transient wording + reset in the PAST → throttle for the 60s default', () => {
90
+ const past = new Date(NOW - 60_000).toISOString()
91
+ const d = decideThrottleTier({
92
+ detail: TRANSIENT(`resets ${past}`),
93
+ now: NOW,
94
+ thresholdMs: THRESHOLD,
95
+ })
96
+ expect(d).toEqual({
97
+ action: 'throttle',
98
+ throttledUntilMs: NOW + THROTTLE_DEFAULT_WAIT_MS,
99
+ resetParsed: false,
100
+ })
101
+ })
102
+
103
+ it('genuine wall wording (no transient negation) → none (existing quota path owns it)', () => {
104
+ const d = decideThrottleTier({
105
+ detail: "You've hit your limit · resets 8:50am (Australia/Melbourne)",
106
+ now: NOW,
107
+ thresholdMs: THRESHOLD,
108
+ })
109
+ expect(d).toEqual({ action: 'none' })
110
+ })
111
+
112
+ it('SERVER-side transient wording (529 shape) → none — never account-throttled', () => {
113
+ // This carries the transient NEGATION ("not your usage limit") but does
114
+ // NOT affirm the account's own rate limit — it is a server-wide
115
+ // condition; an account-scoped throttle + restart nudge would be the
116
+ // wrong action. It stays on the existing calm rate-limited path.
117
+ const d = decideThrottleTier({
118
+ detail: 'Server is temporarily limiting requests (not your usage limit). retry after 60 seconds',
119
+ now: NOW,
120
+ thresholdMs: THRESHOLD,
121
+ })
122
+ expect(d).toEqual({ action: 'none' })
123
+ })
124
+
125
+ it('honours a custom threshold (boundary: exactly at threshold stays in place)', () => {
126
+ const atThreshold = decideThrottleTier({
127
+ detail: TRANSIENT('retry after 300 seconds'),
128
+ now: NOW,
129
+ thresholdMs: 5 * 60_000,
130
+ })
131
+ expect(atThreshold.action).toBe('throttle')
132
+ const beyond = decideThrottleTier({
133
+ detail: TRANSIENT('retry after 301 seconds'),
134
+ now: NOW,
135
+ thresholdMs: 5 * 60_000,
136
+ })
137
+ expect(beyond.action).toBe('failover')
138
+ })
139
+ })
140
+
141
+ // Verbatim LiteLLM proxy-local 429 shapes (provenance on
142
+ // `litellmProxyLocal429Signals`, model-unavailable.ts).
143
+ const LITELLM_TPM_CAP =
144
+ 'Deployment over user-defined ratelimit. tpm limit=8000. current usage=8241. ' +
145
+ 'id=abc123def, model_group=claude-fable-5'
146
+ const LITELLM_KEY_LIMIT =
147
+ 'Rate limit exceeded for api_key: hashed-key-1a2b3c. Limit type: tokens. ' +
148
+ 'Current limit: 8000, Remaining: 0. Limit resets at: 2026-07-12 08:05:00 UTC'
149
+ const LITELLM_ROUTER_COOLDOWN =
150
+ 'No deployments available for selected model, Try again in 27.5 seconds. ' +
151
+ "Passed model=claude-fable-5. pre-call-checks=False, cooldown_list=['abc123def']"
152
+ // v3 limiter, descriptor NOT in the enumerated signal list — matched by the
153
+ // litellmV3LimiterSignalPair co-occurrence rule (model-unavailable.ts).
154
+ const LITELLM_TEAM_MODEL_LIMIT =
155
+ 'Rate limit exceeded for model_per_team: team-1a2b3c:claude-fable-5. ' +
156
+ 'Limit type: tokens. Current limit: 50000, Remaining: 0. ' +
157
+ 'Limit resets at: 2026-07-12 08:05:00 UTC'
158
+
159
+ describe('decideThrottleTier — LiteLLM-proxy-local 429s never enter the tier', () => {
160
+ // OUTCOME pin: `none` is what keeps a proxy-local cap trip off the
161
+ // account-scoped machinery — no broker mark-throttled, no throttle-tier
162
+ // runner fire, no failover escalation. The gateway additionally gates on
163
+ // classify429Detail, but the decision module must agree.
164
+ it.each([
165
+ ['deployment tpm cap', LITELLM_TPM_CAP],
166
+ ['virtual-key tpm_limit', LITELLM_KEY_LIMIT],
167
+ ['router cooldown', LITELLM_ROUTER_COOLDOWN],
168
+ ['team model cap (non-enumerated v3 descriptor)', LITELLM_TEAM_MODEL_LIMIT],
169
+ ])('%s → none (calm path owns it)', (_name, detail) => {
170
+ expect(decideThrottleTier({ detail, now: NOW, thresholdMs: THRESHOLD })).toEqual({
171
+ action: 'none',
172
+ })
173
+ })
174
+ })
175
+
176
+ describe('classify429Detail — three-way origin classification', () => {
177
+ it('classifies LiteLLM limiter wordings as litellm-local', () => {
178
+ expect(classify429Detail(LITELLM_TPM_CAP)).toBe('litellm-local')
179
+ expect(classify429Detail(LITELLM_KEY_LIMIT)).toBe('litellm-local')
180
+ expect(classify429Detail(LITELLM_ROUTER_COOLDOWN)).toBe('litellm-local')
181
+ // Non-enumerated v3 descriptor — the co-occurrence pair, not a list
182
+ // entry, carries this one.
183
+ expect(classify429Detail(LITELLM_TEAM_MODEL_LIMIT)).toBe('litellm-local')
184
+ })
185
+
186
+ it('classifies Anthropic account-affirming wording as account-scoped', () => {
187
+ expect(classify429Detail(TRANSIENT('resets in 3m'))).toBe('account-scoped')
188
+ expect(
189
+ classify429Detail('would exceed your account’s rate limit'),
190
+ ).toBe('account-scoped')
191
+ })
192
+
193
+ it('classifies server-side / bare rate-limit wording as generic-transient', () => {
194
+ expect(
195
+ classify429Detail('Server is temporarily limiting requests (not your usage limit)'),
196
+ ).toBe('generic-transient')
197
+ expect(classify429Detail('overloaded_error 529')).toBe('generic-transient')
198
+ expect(classify429Detail('rate_limit_error: try again later')).toBe('generic-transient')
199
+ })
200
+
201
+ it('TIE-BREAK: account-affirming wording wins over LiteLLM wording when both appear', () => {
202
+ // The pass-through shape: LiteLLM wraps a FORWARDED upstream Anthropic
203
+ // account 429 (exception mapping / limiter prose in the same body).
204
+ // LiteLLM never emits the account wording itself, so its presence means
205
+ // Anthropic really throttled the account — the broker throttle mark and
206
+ // the throttle tier must still run.
207
+ const mixed =
208
+ `litellm.RateLimitError: ${LITELLM_TPM_CAP} — upstream said: ` +
209
+ "This request would exceed your account's rate limit. Please try again later."
210
+ expect(classify429Detail(mixed)).toBe('account-scoped')
211
+ // And the tier decision still engages (not 'none').
212
+ expect(
213
+ decideThrottleTier({ detail: mixed, now: NOW, thresholdMs: THRESHOLD }).action,
214
+ ).not.toBe('none')
215
+ })
216
+
217
+ it('a bare litellm.RateLimitError wrapper WITHOUT limiter wording stays generic-transient', () => {
218
+ // The exception-mapping prefix alone is not proxy-local evidence.
219
+ expect(
220
+ classify429Detail('litellm.RateLimitError: RateLimitError: 429 try again later'),
221
+ ).toBe('generic-transient')
222
+ })
223
+
224
+ it('never throws on weird input', () => {
225
+ expect(classify429Detail('')).toBe('generic-transient')
226
+ expect(classify429Detail(undefined as unknown as string)).toBe('generic-transient')
227
+ expect(classify429Detail(9000 as unknown as string)).toBe('generic-transient')
228
+ expect(classify429Detail('A'.repeat(200_000))).toBe('generic-transient')
229
+ })
230
+ })
231
+
232
+ describe('build429ClassifiedMetric — instrumentation payload', () => {
233
+ it('litellm-local deployment cap → limit fields + calm action', () => {
234
+ const m = build429ClassifiedMetric({
235
+ agent: 'carrie',
236
+ detail: LITELLM_TPM_CAP,
237
+ classification: 'litellm-local',
238
+ action: 'calm',
239
+ now: NOW,
240
+ })
241
+ expect(m).toEqual({
242
+ kind: 'rate_limit_429_classified',
243
+ agent: 'carrie',
244
+ classification: 'litellm-local',
245
+ action: 'calm',
246
+ reset_at_ms: null,
247
+ reset_in_ms: null,
248
+ limit_type: 'tpm',
249
+ limit: 8000,
250
+ current_usage: 8241,
251
+ })
252
+ })
253
+
254
+ it('litellm-local NON-enumerated v3 descriptor (model_per_team) → full metric payload', () => {
255
+ // Finding-pin: a team-level model cap must produce the metric (it used
256
+ // to miss every signal → quota-exhausted → no metric at all).
257
+ const m = build429ClassifiedMetric({
258
+ agent: 'carrie',
259
+ detail: LITELLM_TEAM_MODEL_LIMIT,
260
+ classification: 'litellm-local',
261
+ action: 'calm',
262
+ now: NOW,
263
+ })
264
+ expect(m.kind).toBe('rate_limit_429_classified')
265
+ expect(m.classification).toBe('litellm-local')
266
+ expect(m.limit_type).toBe('tokens')
267
+ expect(m.limit).toBe(50_000)
268
+ expect(m.reset_at_ms).toBe(Date.UTC(2026, 6, 12, 8, 5, 0))
269
+ })
270
+
271
+ it('litellm-local v3 key limit → tokens limit type + parsed "Limit resets at" UTC reset', () => {
272
+ const m = build429ClassifiedMetric({
273
+ agent: 'carrie',
274
+ detail: LITELLM_KEY_LIMIT,
275
+ classification: 'litellm-local',
276
+ action: 'calm',
277
+ now: NOW,
278
+ })
279
+ expect(m.limit_type).toBe('tokens')
280
+ expect(m.limit).toBe(8000)
281
+ expect(m.reset_at_ms).toBe(Date.UTC(2026, 6, 12, 8, 5, 0))
282
+ expect(m.reset_in_ms).toBe(Date.UTC(2026, 6, 12, 8, 5, 0) - NOW)
283
+ })
284
+
285
+ it('account-scoped 429 → Anthropic-parsed reset, no limit fields', () => {
286
+ const m = build429ClassifiedMetric({
287
+ agent: 'carrie',
288
+ detail: TRANSIENT('resets in 3m'),
289
+ classification: 'account-scoped',
290
+ action: 'throttle',
291
+ now: NOW,
292
+ })
293
+ expect(m.classification).toBe('account-scoped')
294
+ expect(m.action).toBe('throttle')
295
+ expect(m.reset_at_ms).toBe(NOW + 3 * 60_000)
296
+ expect(m.reset_in_ms).toBe(3 * 60_000)
297
+ expect(m.limit_type).toBeNull()
298
+ expect(m.limit).toBeNull()
299
+ expect(m.current_usage).toBeNull()
300
+ })
301
+
302
+ it('never throws on weird detail; nulls out unparseable resets', () => {
303
+ const m = build429ClassifiedMetric({
304
+ agent: 'carrie',
305
+ detail: undefined as unknown as string,
306
+ classification: 'generic-transient',
307
+ action: 'calm',
308
+ now: NOW,
309
+ })
310
+ expect(m.reset_at_ms).toBeNull()
311
+ expect(m.reset_in_ms).toBeNull()
312
+ })
313
+ })
314
+
315
+ describe('isAccountScopedThrottle — the tier gate', () => {
316
+ it("matches account-affirming wording (both apostrophe variants + 'not your account')", () => {
317
+ expect(isAccountScopedThrottle("would exceed your account's rate limit")).toBe(true)
318
+ expect(isAccountScopedThrottle('would exceed your account’s rate limit')).toBe(true)
319
+ expect(isAccountScopedThrottle("this is not your account's limit")).toBe(true)
320
+ })
321
+
322
+ it('rejects server-side transient / 529 wordings and walls', () => {
323
+ expect(isAccountScopedThrottle('Server is temporarily limiting requests (not your usage limit)')).toBe(false)
324
+ expect(isAccountScopedThrottle('temporarily rate limited, overloaded_error 529')).toBe(false)
325
+ expect(isAccountScopedThrottle("You've hit your limit · resets 8:50am")).toBe(false)
326
+ expect(isAccountScopedThrottle('')).toBe(false)
327
+ })
328
+ })
329
+
330
+ describe('throttleRetryInPlaceMaxMs — env resolution', () => {
331
+ it('defaults to 5 minutes', () => {
332
+ expect(throttleRetryInPlaceMaxMs({})).toBe(5 * 60_000)
333
+ })
334
+
335
+ it('reads SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS', () => {
336
+ expect(
337
+ throttleRetryInPlaceMaxMs({ SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS: '120000' }),
338
+ ).toBe(120_000)
339
+ })
340
+
341
+ it('falls back to the default on junk / non-positive values', () => {
342
+ expect(
343
+ throttleRetryInPlaceMaxMs({ SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS: 'soon' }),
344
+ ).toBe(THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT)
345
+ expect(
346
+ throttleRetryInPlaceMaxMs({ SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS: '-5' }),
347
+ ).toBe(THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT)
348
+ })
349
+ })
350
+
351
+ describe('evaluateThrottleNotice — per-account cooldown', () => {
352
+ it('sends the first notice and suppresses a repeat inside the window', () => {
353
+ let state: ThrottleNoticeState = { lastSentAtMsByAccount: {} }
354
+ const first = evaluateThrottleNotice(state, 'acct-a', NOW)
355
+ expect(first.send).toBe(true)
356
+ state = first.next
357
+ const repeat = evaluateThrottleNotice(state, 'acct-a', NOW + 60_000)
358
+ expect(repeat.send).toBe(false)
359
+ })
360
+
361
+ it('a DIFFERENT account is not suppressed by the first account\'s window', () => {
362
+ const first = evaluateThrottleNotice({ lastSentAtMsByAccount: {} }, 'acct-a', NOW)
363
+ const other = evaluateThrottleNotice(first.next, 'acct-b', NOW + 1)
364
+ expect(other.send).toBe(true)
365
+ })
366
+
367
+ it('sends again once the cooldown window has elapsed', () => {
368
+ const first = evaluateThrottleNotice({ lastSentAtMsByAccount: {} }, 'acct-a', NOW)
369
+ const later = evaluateThrottleNotice(
370
+ first.next,
371
+ 'acct-a',
372
+ NOW + THROTTLE_NOTICE_COOLDOWN_MS,
373
+ )
374
+ expect(later.send).toBe(true)
375
+ })
376
+ })
377
+
378
+ describe('renderThrottleNotice — honest reset messaging', () => {
379
+ it('names the account, the agent, the reset, and that it is NOT a quota wall', () => {
380
+ const text = renderThrottleNotice({
381
+ account: 'alice',
382
+ agent: 'carrie',
383
+ throttledUntilMs: NOW + 3 * 60_000,
384
+ resetParsed: true,
385
+ now: new Date(NOW),
386
+ })
387
+ expect(text).toContain('alice')
388
+ expect(text).toContain('carrie')
389
+ expect(text).toContain('resets in 3m')
390
+ expect(text).toContain('not a quota wall')
391
+ expect(text).toContain('Staying on')
392
+ expect(text).toContain('no failover')
393
+ })
394
+
395
+ it('degrades honestly when the account is unknown (broker unreachable)', () => {
396
+ const text = renderThrottleNotice({
397
+ account: null,
398
+ agent: 'carrie',
399
+ throttledUntilMs: NOW + THROTTLE_DEFAULT_WAIT_MS,
400
+ resetParsed: false,
401
+ now: new Date(NOW),
402
+ })
403
+ expect(text).toContain('the active account')
404
+ expect(text).toContain('retrying in ~60s')
405
+ })
406
+ })
407
+
408
+ describe('renderThrottleEscalationNotice — corroborated-wall announcement', () => {
409
+ it('names the account, the trigger agent, and the roll target', () => {
410
+ const text = renderThrottleEscalationNotice({
411
+ account: 'alice',
412
+ agent: 'carrie',
413
+ rolledTo: 'bob',
414
+ })
415
+ expect(text).toContain('alice')
416
+ expect(text).toContain('carrie')
417
+ expect(text).toContain('bob')
418
+ expect(text).toContain('actually a wall')
419
+ })
420
+
421
+ it('renders the all-blocked variant when no fallback had quota', () => {
422
+ const text = renderThrottleEscalationNotice({
423
+ account: 'alice',
424
+ agent: 'carrie',
425
+ rolledTo: null,
426
+ })
427
+ expect(text).toContain('all blocked')
428
+ expect(text).toContain('/auth add')
429
+ })
430
+ })
431
+
432
+ describe('rate_limited escalation card wording (model-unavailable integration)', () => {
433
+ it('formatModelUnavailableCard names the rate limit, not quota exhaustion', () => {
434
+ const card = formatModelUnavailableCard(
435
+ { kind: 'rate_limited', resetAt: new Date(NOW + 30 * 60_000), raw: 'x' },
436
+ 'carrie',
437
+ { now: new Date(NOW), autoFallbackInFlight: true },
438
+ )
439
+ expect(card).toContain('account rate-limited')
440
+ expect(card).toContain('resets in 30m')
441
+ expect(card).not.toContain('quota exhausted')
442
+ })
443
+ })
444
+
445
+ describe('parseResetTime (exported for the throttle tier)', () => {
446
+ it('parses "retry after N seconds" relative to the injected clock', () => {
447
+ const d = parseResetTime('retry after 45 seconds', new Date(NOW))
448
+ expect(d?.getTime()).toBe(NOW + 45_000)
449
+ })
450
+
451
+ it('returns undefined for prose with no reset hint', () => {
452
+ expect(parseResetTime('temporarily limiting requests', new Date(NOW))).toBeUndefined()
453
+ })
454
+ })