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.
- package/dist/agent-scheduler/index.js +13 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/notion-write-pretool.mjs +13 -0
- package/dist/cli/switchroom.js +605 -479
- package/dist/host-control/main.js +17 -1
- package/dist/vault/approvals/kernel-server.js +13 -0
- package/dist/vault/broker/server.js +13 -0
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -1
- package/telegram-plugin/dist/gateway/gateway.js +1401 -431
- package/telegram-plugin/dist/server.js +26 -1
- package/telegram-plugin/fleet-fallback-resume.ts +26 -3
- package/telegram-plugin/gateway/approval-hold.ts +49 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
- package/telegram-plugin/gateway/gateway.ts +362 -71
- package/telegram-plugin/gateway/linear-activity.ts +20 -4
- package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
- package/telegram-plugin/gateway/session-model-file.ts +103 -0
- package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
- package/telegram-plugin/llm-error-present.ts +436 -0
- package/telegram-plugin/operator-events.ts +7 -1
- package/telegram-plugin/permission-title.ts +172 -10
- package/telegram-plugin/premium-recovery.ts +101 -0
- package/telegram-plugin/raw-error-scrub.ts +73 -0
- package/telegram-plugin/retry-api-call.ts +8 -2
- package/telegram-plugin/send-gate-degraded.test.ts +152 -1
- package/telegram-plugin/send-gate-observability.test.ts +140 -0
- package/telegram-plugin/send-gate-observability.ts +65 -20
- package/telegram-plugin/send-gate.test.ts +143 -1
- package/telegram-plugin/send-gate.ts +212 -19
- package/telegram-plugin/session-tail.ts +16 -0
- package/telegram-plugin/shared/local-time.ts +69 -0
- package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
- package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
- package/telegram-plugin/tests/llm-error-present.test.ts +380 -0
- package/telegram-plugin/tests/permission-title.test.ts +167 -4
- package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
- package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
- package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
- package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
- package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
- package/telegram-plugin/tier-downgrade.ts +198 -0
- package/telegram-plugin/tool-activity-summary.ts +99 -0
- 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
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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', () => {
|