switchroom 0.18.15 → 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 +16 -0
- package/dist/auth-broker/index.js +445 -10
- package/dist/cli/notion-write-pretool.mjs +16 -0
- package/dist/cli/switchroom.js +654 -479
- package/dist/host-control/main.js +20 -1
- package/dist/vault/approvals/kernel-server.js +16 -0
- package/dist/vault/broker/server.js +16 -0
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +81 -139
- package/telegram-plugin/bridge/bridge.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -1
- package/telegram-plugin/dist/gateway/gateway.js +1758 -661
- package/telegram-plugin/dist/server.js +26 -1
- package/telegram-plugin/draft-stream.ts +78 -3
- 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 +64 -22
- package/telegram-plugin/gateway/effort-command.ts +9 -7
- package/telegram-plugin/gateway/gateway.ts +627 -291
- package/telegram-plugin/gateway/linear-activity.ts +20 -4
- package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
- package/telegram-plugin/gateway/model-command.ts +96 -18
- package/telegram-plugin/gateway/pending-session-command.ts +10 -8
- package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
- package/telegram-plugin/gateway/session-model-file.ts +141 -172
- package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
- package/telegram-plugin/litellm-local-notice.ts +189 -0
- 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/quota-watch.ts +16 -4
- package/telegram-plugin/raw-error-scrub.ts +73 -0
- package/telegram-plugin/retry-api-call.ts +8 -2
- package/telegram-plugin/runtime-metrics.ts +16 -0
- package/telegram-plugin/send-gate-degraded.test.ts +161 -8
- 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 +246 -23
- package/telegram-plugin/session-tail.ts +16 -0
- package/telegram-plugin/shared/local-time.ts +69 -0
- package/telegram-plugin/stream-controller.ts +143 -20
- package/telegram-plugin/stream-reply-handler.ts +12 -2
- 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/bot-api.harness.ts +7 -2
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
- package/telegram-plugin/tests/draft-stream.test.ts +110 -1
- package/telegram-plugin/tests/effort-command.test.ts +4 -4
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +5 -4
- package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
- package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
- package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
- package/telegram-plugin/tests/llm-error-present.test.ts +380 -0
- package/telegram-plugin/tests/model-command.test.ts +84 -1
- 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/quota-watch.test.ts +21 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +8 -3
- package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
- package/telegram-plugin/tests/session-model-file.test.ts +7 -155
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -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 +212 -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 +543 -368
|
@@ -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
|
+
})
|
|
@@ -1170,4 +1170,25 @@ describe("buildFleetRollMessage — reason attribution (#3031 PR 2 reason field)
|
|
|
1170
1170
|
expect(msg).toContain("7-day window at 96% on");
|
|
1171
1171
|
}
|
|
1172
1172
|
});
|
|
1173
|
+
|
|
1174
|
+
it("model-tier-wall (#3176) names the flagship tier and reassures opus/haiku are unaffected — not a generic quota window", () => {
|
|
1175
|
+
// A tier wall binds on the 7d_oi bucket; window/pct are absent (5h/7d read
|
|
1176
|
+
// healthy). exhausted_until carries the tier reset.
|
|
1177
|
+
const roll: FleetRollInfo = {
|
|
1178
|
+
from: "alice@example.com",
|
|
1179
|
+
to: "bob@example.com",
|
|
1180
|
+
at: NOW - 60_000,
|
|
1181
|
+
reason: "model-tier-wall",
|
|
1182
|
+
bucket: "seven_day_overage_included",
|
|
1183
|
+
exhausted_until: NOW + 2.8 * 60 * 60 * 1000,
|
|
1184
|
+
};
|
|
1185
|
+
const msg = buildFleetRollMessage(roll, NOW);
|
|
1186
|
+
expect(msg).toContain("Flagship (premium) tier weekly limit reached");
|
|
1187
|
+
expect(msg).toContain("opus/haiku on that account are unaffected");
|
|
1188
|
+
// Must NOT mislead as a generic 5h/7d window roll.
|
|
1189
|
+
expect(msg).not.toContain("quota window");
|
|
1190
|
+
expect(msg).not.toContain("Proactive switch");
|
|
1191
|
+
// The tier reset is surfaced.
|
|
1192
|
+
expect(msg).toContain("resets");
|
|
1193
|
+
});
|
|
1173
1194
|
});
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
import { describe, it, expect, vi } from 'vitest'
|
|
30
30
|
import { readFileSync } from 'node:fs'
|
|
31
31
|
import { fileURLToPath } from 'node:url'
|
|
32
|
-
import { createSendGate, type Clock } from '../send-gate.js'
|
|
32
|
+
import { createSendGate, SEND_GATE_SHED, type Clock } from '../send-gate.js'
|
|
33
33
|
import { createRetryApiCall } from '../retry-api-call.js'
|
|
34
34
|
import { errors } from './fake-bot-api.js'
|
|
35
35
|
|
|
@@ -95,7 +95,7 @@ describe('#3155 reactions route through the send gate (cosmetic)', () => {
|
|
|
95
95
|
// resolves undefined (fire-and-forget callers .catch nothing), and the gate
|
|
96
96
|
// counts the shed.
|
|
97
97
|
expect(setMessageReaction).not.toHaveBeenCalled()
|
|
98
|
-
expect(result).
|
|
98
|
+
expect(result).toBe(SEND_GATE_SHED) // shed sentinel (#3110 F1)
|
|
99
99
|
expect(sendGate.stats().global.shed).toBe(1)
|
|
100
100
|
})
|
|
101
101
|
|
|
@@ -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', () => {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Session-model file helpers (session-model-file.ts) — the gateway side of the
|
|
3
|
+
* session-scoped /model contract (reference/rfcs/session-model-stickiness.md
|
|
4
|
+
* §0.1, rev 4 — consume-once). The `.relaunch-model-intent` subsystem and the
|
|
5
|
+
* crashloop counter were retired with rev 4; their tests are gone.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
7
|
-
import { mkdtempSync, rmSync,
|
|
9
|
+
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
|
8
10
|
import { join } from 'node:path'
|
|
9
11
|
import { tmpdir } from 'node:os'
|
|
10
12
|
import {
|
|
@@ -15,17 +17,8 @@ import {
|
|
|
15
17
|
readSessionModelFileRaw,
|
|
16
18
|
restoreSessionModelFileRaw,
|
|
17
19
|
clearSessionModelFile,
|
|
18
|
-
clearSessionModelBootAttempts,
|
|
19
|
-
SESSION_MODEL_BOOT_ATTEMPTS_FILE,
|
|
20
|
-
writeRelaunchModelIntent,
|
|
21
|
-
clearRelaunchModelIntent,
|
|
22
20
|
readConfiguredDefaultModel,
|
|
23
|
-
intentForRestartReason,
|
|
24
|
-
readRelaunchModelIntent,
|
|
25
|
-
clearStaleGatewayShutdownIntent,
|
|
26
|
-
GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX,
|
|
27
21
|
SESSION_MODEL_FILE,
|
|
28
|
-
RELAUNCH_MODEL_INTENT_FILE,
|
|
29
22
|
CONFIGURED_DEFAULT_MODEL_FILE,
|
|
30
23
|
parseSessionEffort,
|
|
31
24
|
writeSessionEffortFile,
|
|
@@ -87,86 +80,6 @@ describe('rollback snapshot (scheduleModelRelaunch dispatch failure)', () => {
|
|
|
87
80
|
})
|
|
88
81
|
})
|
|
89
82
|
|
|
90
|
-
describe('relaunch intent', () => {
|
|
91
|
-
it('writes one-line JSON with intent, reason, and embedded ts (the freshness clock)', () => {
|
|
92
|
-
writeRelaunchModelIntent(dir, 'keep', 'user: /new from chat')
|
|
93
|
-
const raw = readFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'utf8')
|
|
94
|
-
const parsed = JSON.parse(raw)
|
|
95
|
-
expect(parsed.intent).toBe('keep')
|
|
96
|
-
expect(parsed.reason).toBe('user: /new from chat')
|
|
97
|
-
expect(Math.abs(Date.now() - parsed.ts)).toBeLessThan(5000)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('last-writer-wins and clearable', () => {
|
|
101
|
-
writeRelaunchModelIntent(dir, 'keep', 'a')
|
|
102
|
-
writeRelaunchModelIntent(dir, 'revert', 'b')
|
|
103
|
-
expect(JSON.parse(readFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'utf8')).intent).toBe('revert')
|
|
104
|
-
clearRelaunchModelIntent(dir)
|
|
105
|
-
expect(existsSync(join(dir, RELAUNCH_MODEL_INTENT_FILE))).toBe(false)
|
|
106
|
-
})
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
describe('intentForRestartReason — the triggerSelfRestart per-reason table (RFC §3)', () => {
|
|
110
|
-
it.each([
|
|
111
|
-
'schedule-restart-immediate',
|
|
112
|
-
'restart-drain-cap-forced',
|
|
113
|
-
'turn-complete-pending-restart',
|
|
114
|
-
'fleet-fallback-resume',
|
|
115
|
-
'sr-to-claude-model-switch',
|
|
116
|
-
])('switchroom-managed relaunch %s → keep', (reason) => {
|
|
117
|
-
expect(intentForRestartReason(reason)).toBe('keep')
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
it('inline-button-restart → keep (#3039: a restart is not "clear my model")', () => {
|
|
121
|
-
expect(intentForRestartReason('inline-button-restart')).toBe('keep')
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
it('unknown gateway reasons default to keep (only gateway code calls triggerSelfRestart; crashes never do)', () => {
|
|
125
|
-
expect(intentForRestartReason('some-future-recovery-path')).toBe('keep')
|
|
126
|
-
})
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
describe('readRelaunchModelIntent', () => {
|
|
130
|
-
it('round-trips a stamped intent; null when absent / corrupt / malformed', () => {
|
|
131
|
-
expect(readRelaunchModelIntent(dir)).toBeNull()
|
|
132
|
-
writeRelaunchModelIntent(dir, 'keep', 'watchdog recovery')
|
|
133
|
-
const rec = readRelaunchModelIntent(dir)!
|
|
134
|
-
expect(rec.intent).toBe('keep')
|
|
135
|
-
expect(rec.reason).toBe('watchdog recovery')
|
|
136
|
-
expect(Math.abs(Date.now() - rec.ts)).toBeLessThan(5000)
|
|
137
|
-
writeFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), '{broken')
|
|
138
|
-
expect(readRelaunchModelIntent(dir)).toBeNull()
|
|
139
|
-
writeFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), '{"intent":"maybe","reason":"x","ts":1}\n')
|
|
140
|
-
expect(readRelaunchModelIntent(dir)).toBeNull()
|
|
141
|
-
})
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
describe('clearStaleGatewayShutdownIntent (#3018 finding 4 — a gateway-only bounce must not leave a keep stamp)', () => {
|
|
145
|
-
it('clears ONLY a gateway-shutdown-stamped intent and reports it', () => {
|
|
146
|
-
writeRelaunchModelIntent(
|
|
147
|
-
dir,
|
|
148
|
-
'keep',
|
|
149
|
-
`${GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX} graceful SIGTERM shutdown (deploy/rolling restart) — preserving user-chosen session model`,
|
|
150
|
-
)
|
|
151
|
-
expect(clearStaleGatewayShutdownIntent(dir)).toBe(true)
|
|
152
|
-
expect(existsSync(join(dir, RELAUNCH_MODEL_INTENT_FILE))).toBe(false)
|
|
153
|
-
// Idempotent: a second call finds nothing.
|
|
154
|
-
expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
it('never touches a triggerSelfRestart / user-slash stamp (un-prefixed reason)', () => {
|
|
158
|
-
writeRelaunchModelIntent(dir, 'keep', 'sr-to-claude-model-switch')
|
|
159
|
-
expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
|
|
160
|
-
expect(readRelaunchModelIntent(dir)!.reason).toBe('sr-to-claude-model-switch')
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
it('is a safe no-op on an absent or corrupt intent file', () => {
|
|
164
|
-
expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
|
|
165
|
-
writeFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'not json')
|
|
166
|
-
expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
|
|
167
|
-
})
|
|
168
|
-
})
|
|
169
|
-
|
|
170
83
|
describe('readConfiguredDefaultModel', () => {
|
|
171
84
|
it('reads the trimmed value; null when absent or empty', () => {
|
|
172
85
|
expect(readConfiguredDefaultModel(dir)).toBeNull()
|
|
@@ -181,9 +94,9 @@ describe('readConfiguredDefaultModel', () => {
|
|
|
181
94
|
})
|
|
182
95
|
})
|
|
183
96
|
|
|
184
|
-
// ─── #
|
|
97
|
+
// ─── #3186: consume-once session-effort carrier (queued-command boot apply) ──
|
|
185
98
|
|
|
186
|
-
describe('session-effort file helpers (#
|
|
99
|
+
describe('session-effort file helpers (#3186)', () => {
|
|
187
100
|
let dir: string
|
|
188
101
|
beforeEach(() => {
|
|
189
102
|
dir = mkdtempSync(join(tmpdir(), 'sr-session-effort-'))
|
|
@@ -218,64 +131,3 @@ describe('session-effort file helpers (#3039)', () => {
|
|
|
218
131
|
expect(readSessionEffortFile(dir)).toBeNull()
|
|
219
132
|
})
|
|
220
133
|
})
|
|
221
|
-
|
|
222
|
-
describe('intentForRestartReason is keep-for-everything (#3039)', () => {
|
|
223
|
-
it('every reason keeps — restarts never clear a user model choice', () => {
|
|
224
|
-
for (const reason of [
|
|
225
|
-
'inline-button-restart',
|
|
226
|
-
'user: /restart from chat',
|
|
227
|
-
'schedule-restart-immediate',
|
|
228
|
-
'anything-else',
|
|
229
|
-
]) {
|
|
230
|
-
expect(intentForRestartReason(reason)).toBe('keep')
|
|
231
|
-
}
|
|
232
|
-
})
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
describe('clearSessionModelBootAttempts (#3043 item 2: bridge-register clears the crashloop counter)', () => {
|
|
236
|
-
const bootAttemptsPath = () => join(dir, SESSION_MODEL_BOOT_ATTEMPTS_FILE)
|
|
237
|
-
|
|
238
|
-
// Faithful model of start.sh.hbs "Override crashloop self-heal": each boot
|
|
239
|
-
// within 150s of the previous stamp bumps the count; a stale stamp resets to
|
|
240
|
-
// 1. Reproduced here so the accumulate-vs-reset OUTCOME is asserted, not just
|
|
241
|
-
// the delete call.
|
|
242
|
-
function simulateBootStamp(nowSec: number): number {
|
|
243
|
-
let count = 0
|
|
244
|
-
let prev = 0
|
|
245
|
-
if (existsSync(bootAttemptsPath())) {
|
|
246
|
-
const [c, p] = readFileSync(bootAttemptsPath(), 'utf8').trim().split(/\s+/)
|
|
247
|
-
count = Number(c) || 0
|
|
248
|
-
prev = Number(p) || 0
|
|
249
|
-
}
|
|
250
|
-
count = nowSec - prev < 150 ? count + 1 : 1
|
|
251
|
-
writeFileSync(bootAttemptsPath(), `${count} ${nowSec}\n`)
|
|
252
|
-
return count
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
it('WITHOUT a bridge register, three fast boots accumulate toward the 3-strike clear', () => {
|
|
256
|
-
// Three operator hand-bounces, each <150s apart, with no register between.
|
|
257
|
-
expect(simulateBootStamp(1000)).toBe(1)
|
|
258
|
-
expect(simulateBootStamp(1010)).toBe(2)
|
|
259
|
-
expect(simulateBootStamp(1020)).toBe(3) // start.sh would now clear a HEALTHY override — the false positive
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
it('a bridge register between boots resets the counter, so a healthy agent never reaches 3', () => {
|
|
263
|
-
expect(simulateBootStamp(1000)).toBe(1)
|
|
264
|
-
// Boot 1 came all the way up and the bridge registered → gateway clears it.
|
|
265
|
-
clearSessionModelBootAttempts(dir)
|
|
266
|
-
expect(existsSync(bootAttemptsPath())).toBe(false)
|
|
267
|
-
|
|
268
|
-
// Next fast boot starts fresh at 1 (no accumulation), and register clears again.
|
|
269
|
-
expect(simulateBootStamp(1010)).toBe(1)
|
|
270
|
-
clearSessionModelBootAttempts(dir)
|
|
271
|
-
expect(simulateBootStamp(1020)).toBe(1)
|
|
272
|
-
// The healthy agent's counter never climbs to the 3-strike clear.
|
|
273
|
-
const [count] = readFileSync(bootAttemptsPath(), 'utf8').trim().split(/\s+/)
|
|
274
|
-
expect(Number(count)).toBeLessThan(3)
|
|
275
|
-
})
|
|
276
|
-
|
|
277
|
-
it('is best-effort — no throw when the counter file is absent', () => {
|
|
278
|
-
expect(existsSync(bootAttemptsPath())).toBe(false)
|
|
279
|
-
expect(() => clearSessionModelBootAttempts(dir)).not.toThrow()
|
|
280
|
-
})
|
|
281
|
-
})
|