switchroom 0.18.17 → 0.18.18

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 (54) hide show
  1. package/dist/agent-scheduler/index.js +13 -0
  2. package/dist/auth-broker/index.js +13 -0
  3. package/dist/cli/notion-write-pretool.mjs +13 -0
  4. package/dist/cli/switchroom.js +605 -479
  5. package/dist/host-control/main.js +17 -1
  6. package/dist/vault/approvals/kernel-server.js +13 -0
  7. package/dist/vault/broker/server.js +13 -0
  8. package/package.json +1 -1
  9. package/telegram-plugin/bridge/bridge.ts +7 -1
  10. package/telegram-plugin/dist/bridge/bridge.js +26 -1
  11. package/telegram-plugin/dist/gateway/gateway.js +1401 -431
  12. package/telegram-plugin/dist/server.js +26 -1
  13. package/telegram-plugin/fleet-fallback-resume.ts +26 -3
  14. package/telegram-plugin/gateway/approval-hold.ts +49 -0
  15. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
  16. package/telegram-plugin/gateway/gateway.ts +362 -71
  17. package/telegram-plugin/gateway/linear-activity.ts +20 -4
  18. package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
  19. package/telegram-plugin/gateway/session-model-file.ts +103 -0
  20. package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
  21. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
  22. package/telegram-plugin/llm-error-present.ts +436 -0
  23. package/telegram-plugin/operator-events.ts +7 -1
  24. package/telegram-plugin/permission-title.ts +172 -10
  25. package/telegram-plugin/premium-recovery.ts +101 -0
  26. package/telegram-plugin/raw-error-scrub.ts +73 -0
  27. package/telegram-plugin/retry-api-call.ts +8 -2
  28. package/telegram-plugin/send-gate-degraded.test.ts +152 -1
  29. package/telegram-plugin/send-gate-observability.test.ts +140 -0
  30. package/telegram-plugin/send-gate-observability.ts +65 -20
  31. package/telegram-plugin/send-gate.test.ts +143 -1
  32. package/telegram-plugin/send-gate.ts +212 -19
  33. package/telegram-plugin/session-tail.ts +16 -0
  34. package/telegram-plugin/shared/local-time.ts +69 -0
  35. package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
  36. package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
  37. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
  38. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
  39. package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
  40. package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
  41. package/telegram-plugin/tests/llm-error-present.test.ts +380 -0
  42. package/telegram-plugin/tests/permission-title.test.ts +167 -4
  43. package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
  44. package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
  45. package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
  46. package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
  47. package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
  48. package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
  49. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
  50. package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
  51. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
  52. package/telegram-plugin/tier-downgrade.ts +198 -0
  53. package/telegram-plugin/tool-activity-summary.ts +99 -0
  54. package/telegram-plugin/worker-activity-feed.ts +509 -409
@@ -156,25 +156,188 @@ describe('formatPermissionCardBody', () => {
156
156
  expect(body).toContain('why: _listing temp files_')
157
157
  })
158
158
 
159
- test('shows "not provided" when no caller reason is present (never the description)', () => {
159
+ // #3167: no caller reason → an honest synthesized `context:` line built
160
+ // from the tool's salient input, NEVER a bare "why: not provided" and never
161
+ // the static schema description. The distinct `context:` label keeps the
162
+ // agent's omission of a rationale visible.
163
+ test('synthesizes a context line when no caller reason is present (never "not provided" / the description)', () => {
160
164
  const body = formatPermissionCardBody({
161
165
  toolName: 'Bash',
162
166
  inputPreview: JSON.stringify({ command: 'ls /tmp' }),
163
167
  description: 'Run a shell command on the host.',
164
168
  agentName: 'gymbro',
165
169
  })
166
- expect(body).toContain('why: _not provided_')
170
+ expect(body).not.toContain('not provided')
167
171
  expect(body).not.toContain('Run a shell command')
172
+ expect(body).toContain('context: _command: ls /tmp_')
168
173
  })
169
174
 
170
- test('shows "not provided" when caller reason is whitespace only', () => {
175
+ test('synthesizes context when the caller reason is whitespace only', () => {
171
176
  const body = formatPermissionCardBody({
172
177
  toolName: 'Bash',
173
178
  inputPreview: JSON.stringify({ command: 'ls /tmp', reason: ' \n ' }),
174
179
  description: 'Run a shell command.',
175
180
  agentName: 'gymbro',
176
181
  })
177
- expect(body).toContain('why: _not provided_')
182
+ expect(body).not.toContain('not provided')
183
+ expect(body).toContain('context: _command: ls /tmp_')
184
+ })
185
+
186
+ // #3167 root case: the `reply` tool (and react/edit_message/…) carries NO
187
+ // `reason` argument, so its cards used to render a contentless
188
+ // "🔐 Clerk wants to reply / why: not provided". Now the reply text is
189
+ // synthesized onto a `context:` line so the operator has something to judge.
190
+ test('reply (no reason arg) synthesizes the reply text as context, not "not provided"', () => {
191
+ const body = formatPermissionCardBody({
192
+ toolName: 'mcp__switchroom-telegram__reply',
193
+ inputPreview: JSON.stringify({
194
+ chat_id: '12345',
195
+ text: "On it — pulling yesterday's GitHub activity now.",
196
+ format: 'html',
197
+ disable_notification: true,
198
+ }),
199
+ description: 'Reply on Telegram.',
200
+ agentName: 'clerk',
201
+ })
202
+ const lines = body.split('\n')
203
+ expect(lines[0]).toBe('🔐 **Clerk** wants to reply')
204
+ expect(body).not.toContain('not provided')
205
+ // Salient field surfaced (truncated); id/routing/formatting noise stripped.
206
+ expect(body).toContain('context: _text: On it — pulling')
207
+ expect(body).toMatch(/context: _text: On it — pulling[^_]*…_/)
208
+ expect(body).not.toContain('chat_id')
209
+ expect(body).not.toContain('disable_notification')
210
+ expect(body).not.toContain('format')
211
+ })
212
+
213
+ test('a reason ON a reply card still renders as why:, not context: (#3167)', () => {
214
+ const body = formatPermissionCardBody({
215
+ toolName: 'mcp__switchroom-telegram__reply',
216
+ inputPreview: JSON.stringify({
217
+ chat_id: '12345',
218
+ text: 'done',
219
+ reason: 'answering the operator’s status question',
220
+ }),
221
+ description: 'Reply on Telegram.',
222
+ agentName: 'clerk',
223
+ })
224
+ expect(body).toContain('why: _answering the operator’s status question_')
225
+ expect(body).not.toContain('context:')
226
+ })
227
+
228
+ test('synthesized context redacts secrets in the salient input (#3167)', () => {
229
+ const fakeToken = 'sk-ant-' + 'api03-' + 'B'.repeat(48)
230
+ const body = formatPermissionCardBody({
231
+ toolName: 'mcp__switchroom-telegram__reply',
232
+ inputPreview: JSON.stringify({ chat_id: '1', text: `here is the key: ${fakeToken}` }),
233
+ description: 'Reply on Telegram.',
234
+ agentName: 'clerk',
235
+ })
236
+ expect(body).toContain('context:')
237
+ expect(body).not.toContain(fakeToken)
238
+ })
239
+
240
+ test('falls back to the natural action when the input exposes nothing salient (#3167)', () => {
241
+ const body = formatPermissionCardBody({
242
+ toolName: 'ExitPlanMode',
243
+ inputPreview: undefined,
244
+ description: 'Exit plan mode.',
245
+ agentName: 'clerk',
246
+ })
247
+ expect(body).not.toContain('not provided')
248
+ expect(body).toContain('context: _exit plan mode_')
249
+ })
250
+
251
+ // #3167 review (HIGH/MEDIUM): the salient-value redactor must catch secrets
252
+ // the bare-value path missed. redact() excludes generic_high_entropy and the
253
+ // contextual kv detectors need a `key=value` shape — so a prefixless token
254
+ // under a credential-shaped key leaked verbatim. These FAIL pre-fix.
255
+ test('hard-masks a prefixless high-entropy value under a `token` key (#3167)', () => {
256
+ // No sk-/ghp-/JWT prefix — shape detection alone cannot catch it; only the
257
+ // key-name signal ("token") does.
258
+ const bare = 'Zx8Kq2Lm9Rn4Tv6Wb1Yc3Hd5Jf7Ug0Pe'
259
+ const body = formatPermissionCardBody({
260
+ toolName: 'mcp__acme__do',
261
+ inputPreview: JSON.stringify({ token: bare, note: 'ping' }),
262
+ description: 'do a thing',
263
+ agentName: 'clerk',
264
+ })
265
+ expect(body).not.toContain(bare)
266
+ // `token` value is hard-masked; the benign `note` still surfaces.
267
+ expect(body).toContain('token:')
268
+ expect(body).toContain('REDACTED')
269
+ expect(body).toContain('note: ping')
270
+ })
271
+
272
+ test('hard-masks a 40-hex secret under a `secret`-shaped key (#3167)', () => {
273
+ const hex = 'a3f1'.repeat(10) // 40 hex chars, no prefix
274
+ const body = formatPermissionCardBody({
275
+ toolName: 'mcp__acme__do',
276
+ inputPreview: JSON.stringify({ webhook_secret: hex }),
277
+ description: 'do a thing',
278
+ agentName: 'clerk',
279
+ })
280
+ expect(body).not.toContain(hex)
281
+ expect(body).toContain('REDACTED')
282
+ })
283
+
284
+ test('masks a non-http DSN credential embedded in free text (redactUrls misses it) (#3167)', () => {
285
+ // Under a benign key (`text`) so the hard-mask key path does NOT fire —
286
+ // this exercises the NON_HTTP_DSN_RE scheme coverage specifically.
287
+ const dsn = 'postgres://user:S3cretPass99@db.host:5432/app'
288
+ const body = formatPermissionCardBody({
289
+ toolName: 'mcp__switchroom-telegram__reply',
290
+ inputPreview: JSON.stringify({ chat_id: '1', text: `connect via ${dsn} then run` }),
291
+ description: 'Reply on Telegram.',
292
+ agentName: 'clerk',
293
+ })
294
+ expect(body).not.toContain('S3cretPass99')
295
+ expect(body).not.toContain(dsn)
296
+ expect(body).toContain('REDACTED')
297
+ })
298
+
299
+ test('hard-masks a DATABASE_URL under a url-shaped key (#3167)', () => {
300
+ const dsn = 'postgres://admin:hunter2pass@10.0.0.5/prod'
301
+ const body = formatPermissionCardBody({
302
+ toolName: 'mcp__acme__migrate',
303
+ inputPreview: JSON.stringify({ database_url: dsn }),
304
+ description: 'run a migration',
305
+ agentName: 'clerk',
306
+ })
307
+ expect(body).not.toContain('hunter2pass')
308
+ expect(body).not.toContain(dsn)
309
+ expect(body).toContain('REDACTED')
310
+ })
311
+
312
+ // #3167 review (LOW-2): acceptance asks for "tool + summarized input +
313
+ // originating turn" — surface a compact origin-turn reference when present.
314
+ test('appends a compact originating-turn reference when origin_turn_id is present (#3167)', () => {
315
+ const body = formatPermissionCardBody({
316
+ toolName: 'mcp__switchroom-telegram__reply',
317
+ inputPreview: JSON.stringify({ chat_id: '1', text: 'hi', origin_turn_id: 'turn-abcdef123456' }),
318
+ description: 'Reply on Telegram.',
319
+ agentName: 'clerk',
320
+ })
321
+ expect(body).toContain('· turn …ef123456')
322
+ // The raw routing id is NOT dumped as its own kv pair.
323
+ expect(body).not.toContain('origin_turn_id:')
324
+ })
325
+
326
+ // #3167 review (LOW-3): raw commands/text now reach the context line, so a
327
+ // markdown metachar in the value must be escaped or it can spoof/break the
328
+ // card's own `_italic_` / `code` formatting.
329
+ test('escapes markdown metachars in the synthesized context value (#3167)', () => {
330
+ const body = formatPermissionCardBody({
331
+ toolName: 'Bash',
332
+ inputPreview: JSON.stringify({ command: 'echo _a_ *b* `c`' }),
333
+ description: 'run a shell command',
334
+ agentName: 'clerk',
335
+ })
336
+ // The metachars are backslash-escaped so they can't open emphasis/code.
337
+ expect(body).toContain('\\_a\\_')
338
+ expect(body).toContain('\\*b\\*')
339
+ expect(body).toContain('\\`c\\`')
340
+ expect(body).not.toContain('echo _a_ *b* `c`')
178
341
  })
179
342
 
180
343
  test('drops the agent prefix when agentName is null (early-boot edge)', () => {
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Integration test for the premium-recovery ping GLUE
3
+ * (`runPremiumRecoveryPing`, premium-recovery-wiring.ts) — the never-storm
4
+ * orchestration the pure `decidePremiumRecovery` / `renderPremiumRecoveryPing`
5
+ * tests do NOT cover. This is the P0 at-most-once guarantee (claim-gate → clear-
6
+ * marker-before-send → per-chat fan-out) that was inline in the gateway and so
7
+ * had ZERO coverage; the same untested-glue gap the tier-downgrade extraction
8
+ * closed for the downgrade path.
9
+ *
10
+ * It drives the real runner with injected seams and PINS:
11
+ * - clear-BEFORE-send ordering (never-storm) + exactly one send per chat;
12
+ * - claim !granted → marker cleared, NO send;
13
+ * - decision.fire === false → no clear, no claim, no send;
14
+ * - no marker / no agent dir → no-op;
15
+ * - the button `callback_data` is EXACTLY `mdl:alias:<premium>` (a future edit
16
+ * can't silently break the session-scoped switch-back path).
17
+ * These are outcome assertions that FAIL if the guard logic is reordered/dropped.
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest'
21
+ import {
22
+ runPremiumRecoveryPing,
23
+ type PremiumRecoveryPingDeps,
24
+ type PremiumRecoveryMarkerView,
25
+ type PremiumRecoveryKeyboard,
26
+ } from '../gateway/premium-recovery-wiring.js'
27
+ import type { PremiumRecoveryPing } from '../premium-recovery.js'
28
+
29
+ interface Recorder {
30
+ /** Ordered event log — pins clear-before-send. */
31
+ events: string[]
32
+ clears: number
33
+ claims: string[]
34
+ sends: Array<{ chatId: string; ping: PremiumRecoveryPing; keyboard: PremiumRecoveryKeyboard }>
35
+ decides: number
36
+ logs: string[]
37
+ }
38
+
39
+ function makeDeps(
40
+ over: {
41
+ agentDir?: string | null
42
+ marker?: PremiumRecoveryMarkerView | null
43
+ fire?: boolean
44
+ granted?: boolean
45
+ fallbackChats?: string[]
46
+ } = {},
47
+ ): { deps: PremiumRecoveryPingDeps; rec: Recorder } {
48
+ const rec: Recorder = { events: [], clears: 0, claims: [], sends: [], decides: 0, logs: [] }
49
+ const deps: PremiumRecoveryPingDeps = {
50
+ getAgentDir: () => (over.agentDir === undefined ? '/tmp/agent' : over.agentDir),
51
+ readMarker: () =>
52
+ over.marker === undefined
53
+ ? { premiumModel: 'fable', chats: ['111', '222'] }
54
+ : over.marker,
55
+ clearMarker: () => {
56
+ rec.clears += 1
57
+ rec.events.push('clear')
58
+ },
59
+ getAgent: () => 'klanker',
60
+ decide: () => {
61
+ rec.decides += 1
62
+ return over.fire === undefined ? true : over.fire
63
+ },
64
+ claimNotification: async (key) => {
65
+ rec.claims.push(key)
66
+ return over.granted === undefined ? true : over.granted
67
+ },
68
+ fallbackChats: () => over.fallbackChats ?? ['999'],
69
+ sendToChat: (chatId, ping, keyboard) => {
70
+ rec.sends.push({ chatId, ping, keyboard })
71
+ rec.events.push(`send:${chatId}`)
72
+ },
73
+ log: (msg) => rec.logs.push(msg),
74
+ }
75
+ return { deps, rec }
76
+ }
77
+
78
+ describe('runPremiumRecoveryPing — never-storm at-most-once ordering', () => {
79
+ it('fire + granted: marker cleared BEFORE any send, exactly one send per chat', async () => {
80
+ const { deps, rec } = makeDeps({ marker: { premiumModel: 'fable', chats: ['111', '222'] } })
81
+ await runPremiumRecoveryPing(deps)
82
+ expect(rec.clears).toBe(1)
83
+ // One send per recorded chat, in order, no duplicates.
84
+ expect(rec.sends.map((s) => s.chatId)).toEqual(['111', '222'])
85
+ // The clear MUST precede the first send (a double-send is unrecoverable).
86
+ const clearIdx = rec.events.indexOf('clear')
87
+ const firstSendIdx = rec.events.findIndex((e) => e.startsWith('send:'))
88
+ expect(clearIdx).toBeGreaterThanOrEqual(0)
89
+ expect(firstSendIdx).toBeGreaterThanOrEqual(0)
90
+ expect(clearIdx).toBeLessThan(firstSendIdx)
91
+ // The fleet claim was keyed on agent + the dropped premium token.
92
+ expect(rec.claims).toEqual(['premium-recovery:klanker:fable'])
93
+ expect(rec.logs).toHaveLength(1)
94
+ })
95
+
96
+ it('claim NOT granted: marker cleared (no lingering re-attempt), NO send', async () => {
97
+ const { deps, rec } = makeDeps({ granted: false })
98
+ await runPremiumRecoveryPing(deps)
99
+ expect(rec.claims).toEqual(['premium-recovery:klanker:fable'])
100
+ expect(rec.clears).toBe(1)
101
+ expect(rec.sends).toHaveLength(0)
102
+ })
103
+
104
+ it('decision.fire === false: NO clear, NO claim, NO send (marker survives for a later tick)', async () => {
105
+ const { deps, rec } = makeDeps({ fire: false })
106
+ await runPremiumRecoveryPing(deps)
107
+ expect(rec.decides).toBe(1)
108
+ expect(rec.clears).toBe(0)
109
+ expect(rec.claims).toHaveLength(0)
110
+ expect(rec.sends).toHaveLength(0)
111
+ })
112
+
113
+ it('no marker present: no-op — no decide, no claim, no clear, no send', async () => {
114
+ const { deps, rec } = makeDeps({ marker: null })
115
+ await runPremiumRecoveryPing(deps)
116
+ expect(rec.decides).toBe(0)
117
+ expect(rec.clears).toBe(0)
118
+ expect(rec.claims).toHaveLength(0)
119
+ expect(rec.sends).toHaveLength(0)
120
+ })
121
+
122
+ it('no agent dir: immediate no-op (marker is never even read)', async () => {
123
+ const { deps, rec } = makeDeps({ agentDir: null })
124
+ await runPremiumRecoveryPing(deps)
125
+ expect(rec.decides).toBe(0)
126
+ expect(rec.clears).toBe(0)
127
+ expect(rec.sends).toHaveLength(0)
128
+ })
129
+
130
+ it('marker with no chats falls back to the access allowFrom chats', async () => {
131
+ const { deps, rec } = makeDeps({
132
+ marker: { premiumModel: 'fable', chats: [] },
133
+ fallbackChats: ['999'],
134
+ })
135
+ await runPremiumRecoveryPing(deps)
136
+ expect(rec.sends.map((s) => s.chatId)).toEqual(['999'])
137
+ })
138
+ })
139
+
140
+ describe('runPremiumRecoveryPing — session-scoped switch-back button', () => {
141
+ it('the button callback_data is EXACTLY mdl:alias:<premium> (routes the model-menu apply path)', async () => {
142
+ const { deps, rec } = makeDeps({ marker: { premiumModel: 'fable', chats: ['111'] } })
143
+ await runPremiumRecoveryPing(deps)
144
+ const button = rec.sends[0].keyboard.inline_keyboard[0][0]
145
+ // Pin the exact wire format — a change to MODEL_CALLBACK_ALIAS or the button
146
+ // construction that broke the session-scoped `/model` apply path must fail here.
147
+ expect(button.callback_data).toBe('mdl:alias:fable')
148
+ expect(button.text).toBe('Switch to fable')
149
+ })
150
+ })
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Unit tests for the "premium model recovered" ping.
3
+ *
4
+ * - `decidePremiumRecovery` is the PURE recovery predicate: fires iff a marker
5
+ * is pending AND the broker shows the premium tier servable again (at least
6
+ * one account neither `exhausted` nor `premium_walled`) — the exact
7
+ * complement of the `all-blocked` condition that fired the downgrade. This
8
+ * IS the deterministic recovery signal: both fields are the broker's live-
9
+ * authoritative verdicts, so the ping never depends on model judgement.
10
+ * - `renderPremiumRecoveryPing` pins the honest, session-scoped wording +
11
+ * button label.
12
+ * - `premiumRecoveryClaimKey` keys the fleet-wide dedup on agent + token.
13
+ * - the `.premium-recovery` carrier round-trips + is shape-gated (parity with
14
+ * the `.session-model` / `.session-effort` carriers).
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
18
+ import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
19
+ import { tmpdir } from 'node:os'
20
+ import { join } from 'node:path'
21
+ import {
22
+ decidePremiumRecovery,
23
+ renderPremiumRecoveryPing,
24
+ premiumRecoveryClaimKey,
25
+ } from '../premium-recovery.js'
26
+ import {
27
+ writePremiumRecoveryFile,
28
+ readPremiumRecoveryFile,
29
+ clearPremiumRecoveryFile,
30
+ parsePremiumRecovery,
31
+ PREMIUM_RECOVERY_FILE,
32
+ } from '../gateway/session-model-file.js'
33
+
34
+ describe('decidePremiumRecovery — deterministic recovery predicate', () => {
35
+ it('no marker → never fires', () => {
36
+ expect(
37
+ decidePremiumRecovery({ hasMarker: false, accounts: [{ exhausted: false, premiumWalled: false }] }),
38
+ ).toEqual({ fire: false, reason: 'no-marker' })
39
+ })
40
+
41
+ it('marker pending but EVERY account still walled/exhausted → no fire (still-walled)', () => {
42
+ const d = decidePremiumRecovery({
43
+ hasMarker: true,
44
+ accounts: [
45
+ { exhausted: true, premiumWalled: false },
46
+ { exhausted: false, premiumWalled: true },
47
+ { exhausted: true, premiumWalled: true },
48
+ ],
49
+ })
50
+ expect(d).toEqual({ fire: false, reason: 'still-walled' })
51
+ })
52
+
53
+ it('marker pending AND one account can serve the premium tier again → fire', () => {
54
+ const d = decidePremiumRecovery({
55
+ hasMarker: true,
56
+ accounts: [
57
+ { exhausted: true, premiumWalled: true },
58
+ { exhausted: false, premiumWalled: false }, // recovered
59
+ ],
60
+ })
61
+ expect(d).toEqual({ fire: true, reason: 'recovered' })
62
+ })
63
+
64
+ it('an account that is exhausted-but-not-premium-walled does NOT count as recovered', () => {
65
+ // The premium tier is only servable on an account that is BOTH not
66
+ // exhausted AND not premium-walled.
67
+ const d = decidePremiumRecovery({
68
+ hasMarker: true,
69
+ accounts: [{ exhausted: true, premiumWalled: false }],
70
+ })
71
+ expect(d.fire).toBe(false)
72
+ })
73
+
74
+ it('an account that is premium-walled-but-not-exhausted does NOT count as recovered', () => {
75
+ const d = decidePremiumRecovery({
76
+ hasMarker: true,
77
+ accounts: [{ exhausted: false, premiumWalled: true }],
78
+ })
79
+ expect(d.fire).toBe(false)
80
+ })
81
+
82
+ it('empty account list with a pending marker → no fire (nothing servable)', () => {
83
+ expect(decidePremiumRecovery({ hasMarker: true, accounts: [] }).fire).toBe(false)
84
+ })
85
+ })
86
+
87
+ describe('renderPremiumRecoveryPing — honest wording', () => {
88
+ it('names the model, says available again, and offers a session-scoped switch', () => {
89
+ const p = renderPremiumRecoveryPing('fable')
90
+ expect(p.text).toContain('`fable`')
91
+ expect(p.text.toLowerCase()).toContain('available again')
92
+ expect(p.text.toLowerCase()).toContain('this session')
93
+ expect(p.buttonText).toBe('Switch to fable')
94
+ // Must NOT over-promise a durable pin.
95
+ expect(p.text.toLowerCase()).not.toContain('permanently')
96
+ expect(p.text.toLowerCase()).not.toContain('forever')
97
+ })
98
+ })
99
+
100
+ describe('premiumRecoveryClaimKey', () => {
101
+ it('keys the dedup on agent + token so different drops never collide', () => {
102
+ expect(premiumRecoveryClaimKey('klanker', 'fable')).toBe('premium-recovery:klanker:fable')
103
+ expect(premiumRecoveryClaimKey('klanker', 'fable')).not.toBe(
104
+ premiumRecoveryClaimKey('klanker', 'opus'),
105
+ )
106
+ })
107
+ })
108
+
109
+ describe('.premium-recovery carrier — round-trip + shape gate', () => {
110
+ let dir: string
111
+ beforeEach(() => {
112
+ dir = mkdtempSync(join(tmpdir(), 'premium-recovery-'))
113
+ })
114
+ afterEach(() => {
115
+ rmSync(dir, { recursive: true, force: true })
116
+ })
117
+
118
+ it('write → read round-trips model + chats (a VALID marker is NOT swept)', () => {
119
+ writePremiumRecoveryFile(dir, 'fable', ['12345', '67890'])
120
+ const rec = readPremiumRecoveryFile(dir)
121
+ expect(rec?.premiumModel).toBe('fable')
122
+ expect(rec?.chats).toEqual(['12345', '67890'])
123
+ expect(typeof rec?.ts).toBe('number')
124
+ // A well-formed marker must survive a read — only corrupt files are swept.
125
+ expect(existsSync(join(dir, PREMIUM_RECOVERY_FILE))).toBe(true)
126
+ })
127
+
128
+ it('clear removes the marker', () => {
129
+ writePremiumRecoveryFile(dir, 'fable', ['12345'])
130
+ expect(existsSync(join(dir, PREMIUM_RECOVERY_FILE))).toBe(true)
131
+ clearPremiumRecoveryFile(dir)
132
+ expect(existsSync(join(dir, PREMIUM_RECOVERY_FILE))).toBe(false)
133
+ expect(readPremiumRecoveryFile(dir)).toBeNull()
134
+ })
135
+
136
+ it('refuses a non-canonical model token (shell-injection shape)', () => {
137
+ expect(() => writePremiumRecoveryFile(dir, 'bad name; rm -rf /', ['12345'])).toThrow()
138
+ })
139
+
140
+ it('refuses an empty chat list (a marker with nowhere to ping is a bug)', () => {
141
+ expect(() => writePremiumRecoveryFile(dir, 'fable', [])).toThrow()
142
+ })
143
+
144
+ it('parse rejects corrupt JSON / bad shape / bad token / empty chats', () => {
145
+ expect(parsePremiumRecovery('{broken')).toBeNull()
146
+ expect(parsePremiumRecovery(JSON.stringify({ premiumModel: 'fable', chats: [], ts: 1 }))).toBeNull()
147
+ expect(
148
+ parsePremiumRecovery(JSON.stringify({ premiumModel: 'bad;name', chats: ['1'], ts: 1 })),
149
+ ).toBeNull()
150
+ expect(
151
+ parsePremiumRecovery(JSON.stringify({ premiumModel: 'fable', chats: [1, 2], ts: 1 })),
152
+ ).toBeNull()
153
+ expect(parsePremiumRecovery(JSON.stringify({ premiumModel: 'fable', chats: ['1'] }))).toBeNull()
154
+ })
155
+
156
+ it('read returns null on a corrupt on-disk marker AND sweeps the garbled file', () => {
157
+ writeFileSync(join(dir, PREMIUM_RECOVERY_FILE), '{not json\n')
158
+ expect(existsSync(join(dir, PREMIUM_RECOVERY_FILE))).toBe(true)
159
+ expect(readPremiumRecoveryFile(dir)).toBeNull()
160
+ // LOW-1: a corrupt marker is garbage-collected on read so it can't linger
161
+ // forever (neither the ping path nor the manual-switch clear could ever
162
+ // delete it — both read null first).
163
+ expect(existsSync(join(dir, PREMIUM_RECOVERY_FILE))).toBe(false)
164
+ })
165
+ })
@@ -130,7 +130,12 @@ describe('#3155 reactions route through the send gate (cosmetic)', () => {
130
130
  // The reaction actually reached the API (was admitted, not shed)...
131
131
  expect(setMessageReaction).toHaveBeenCalled()
132
132
  // ...and its 429 was recorded by the breaker — the whole gap this closes.
133
- expect(onFloodWait).toHaveBeenCalledWith(7)
133
+ // #3111: the retry policy now also passes the call's opts so the gateway can
134
+ // open a scope-precise window (a reaction 429 implies only that chat).
135
+ expect(onFloodWait).toHaveBeenCalledWith(
136
+ 7,
137
+ expect.objectContaining({ chat_id: 'chatA', priorityClass: 'cosmetic' }),
138
+ )
134
139
  })
135
140
  })
136
141
 
@@ -576,6 +576,27 @@ describe('#2923 — LOCAL resource exhaustion is NOT retried (avoids flood ban)'
576
576
  expect(out).toBe('ok')
577
577
  expect(seen).toEqual([42])
578
578
  })
579
+
580
+ it('passes the call opts to onFloodWait so the gateway can open a scope-precise window (#3111)', async () => {
581
+ const seen: { sec: number; chat_id?: string; chatType?: string }[] = []
582
+ const sleep = vi.fn(async () => {})
583
+ let n = 0
584
+ const retry = createRetryApiCall({
585
+ maxRetries: 3,
586
+ sleep,
587
+ onFloodWait: (sec, opts) => seen.push({ sec, chat_id: opts?.chat_id, chatType: opts?.chatType }),
588
+ })
589
+ const out = await retry(
590
+ async () => {
591
+ if (n++ === 0) throw errors.floodWait(7)
592
+ return 'ok'
593
+ },
594
+ { chat_id: '42', chatType: 'supergroup' },
595
+ )
596
+ expect(out).toBe('ok')
597
+ // The hook received the same chat scope the send carried — not a bare number.
598
+ expect(seen).toEqual([{ sec: 7, chat_id: '42', chatType: 'supergroup' }])
599
+ })
579
600
  })
580
601
 
581
602
  describe('#3084 — a long flood ban fails FAST instead of sleeping for hours', () => {