switchroom 0.18.8 → 0.18.10
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/README.md +2 -2
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +198 -13
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +28 -4
- package/dist/host-control/main.js +3 -2
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +119 -37
- package/profiles/_shared/dev-protocol.md.hbs +42 -0
- package/skills/dev-protocol/SKILL.md +131 -0
- package/telegram-plugin/README.md +2 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
- package/telegram-plugin/admin-commands/index.ts +6 -1
- package/telegram-plugin/bridge/bridge.ts +23 -1
- package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
- package/telegram-plugin/chat-lock.ts +13 -0
- package/telegram-plugin/dist/bridge/bridge.js +24 -1
- package/telegram-plugin/dist/gateway/gateway.js +80236 -77465
- package/telegram-plugin/dist/server.js +29 -2
- package/telegram-plugin/fallback-card-collapse.ts +131 -0
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/effort-command.ts +47 -3
- package/telegram-plugin/gateway/gateway.ts +2051 -3180
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/model-command.ts +94 -8
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-session-command.ts +365 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
- package/telegram-plugin/gateway/session-model-file.ts +166 -23
- package/telegram-plugin/gateway/stop-command.ts +56 -0
- package/telegram-plugin/photo-precheck.ts +201 -0
- package/telegram-plugin/quota-watch.ts +141 -2
- package/telegram-plugin/registry/subagents-schema.ts +26 -3
- package/telegram-plugin/registry/subagents.test.ts +67 -0
- package/telegram-plugin/retry-api-call.ts +31 -0
- package/telegram-plugin/subagent-watcher.ts +392 -1
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
- package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
- package/telegram-plugin/tests/effort-command.test.ts +59 -2
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +46 -3
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
- package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
- package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
- package/telegram-plugin/tests/quota-watch.test.ts +225 -0
- package/telegram-plugin/tests/session-model-file.test.ts +101 -2
- package/telegram-plugin/tests/stop-command.test.ts +234 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
- package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
- package/telegram-plugin/welcome-text.ts +4 -3
- package/telegram-plugin/worker-activity-feed.ts +27 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine→dispatcher coverage + self-consistency harness for the
|
|
3
|
+
* inbound-delivery state-machine cutover (#2794 / #2996 Phase 1).
|
|
4
|
+
*
|
|
5
|
+
* SCOPE — what this file proves, and what it deliberately does NOT:
|
|
6
|
+
*
|
|
7
|
+
* It drives realistic event schedules (the same shapes the gateway's
|
|
8
|
+
* `shadowEmit` call sites feed the machine — bridge flap, fresh turn,
|
|
9
|
+
* mid-turn buffering, steering, turn-end drain, permission verdicts
|
|
10
|
+
* across a bridge outage, stale-turn fallback) through the PURE
|
|
11
|
+
* `transition()` function, collects the returned effect sequence, then
|
|
12
|
+
* replays that sequence through the REAL `dispatchEffects()` against a
|
|
13
|
+
* recording fake-deps object. It asserts:
|
|
14
|
+
*
|
|
15
|
+
* 1. Every effect kind the machine can emit is handled by the
|
|
16
|
+
* dispatcher (no silent no-op, no `unwired`/`not-yet-cutover` gap).
|
|
17
|
+
* 2. The observable side-effects fire in the machine's returned order.
|
|
18
|
+
* 3. Invariant #1 at the dispatch layer: an inbound is delivered XOR
|
|
19
|
+
* (buffered AND persisted) — never both, never neither.
|
|
20
|
+
*
|
|
21
|
+
* It does NOT invoke the imperative twins (`markClaudeBusyForInbound`,
|
|
22
|
+
* the legacy `turnInFlightForGate` claudeBusyKeys branch,
|
|
23
|
+
* `dispatchPermissionVerdict`, the silence-poke ladder), so it is NOT a
|
|
24
|
+
* proof that machine path and imperative path produce identical I/O.
|
|
25
|
+
* Twin-equivalence evidence for PR4 (twin deletion) must come from the
|
|
26
|
+
* live shadow trace + gate-parity-probe bake per the RFC's cutover
|
|
27
|
+
* protocol — do NOT cite this file as that proof.
|
|
28
|
+
*
|
|
29
|
+
* What it DOES gate: if a future edit adds an effect kind the
|
|
30
|
+
* dispatcher doesn't execute, the "no unhandled effect" assertion fails
|
|
31
|
+
* loudly — keeping PR3c's live call-site flip a one-liner.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, expect, it } from 'vitest'
|
|
35
|
+
import {
|
|
36
|
+
transition,
|
|
37
|
+
initialState,
|
|
38
|
+
type Effect,
|
|
39
|
+
type Event,
|
|
40
|
+
type State,
|
|
41
|
+
type ChatKey,
|
|
42
|
+
type InboundMessage as MachineInbound,
|
|
43
|
+
} from '../gateway/inbound-delivery-machine'
|
|
44
|
+
import {
|
|
45
|
+
dispatchEffects,
|
|
46
|
+
type DispatchCtx,
|
|
47
|
+
} from '../gateway/inbound-delivery-machine-dispatch'
|
|
48
|
+
import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer'
|
|
49
|
+
import { createPendingPermissionBuffer } from '../gateway/pending-permission-decisions'
|
|
50
|
+
|
|
51
|
+
// ── Recording fake deps ──────────────────────────────────────────────
|
|
52
|
+
// Records every observable dispatcher action in call order so the test
|
|
53
|
+
// can assert the effect→I/O mapping and its ordering.
|
|
54
|
+
|
|
55
|
+
interface Recorder {
|
|
56
|
+
ctx: DispatchCtx
|
|
57
|
+
ops: string[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function makeRecorder(): Recorder {
|
|
61
|
+
const ops: string[] = []
|
|
62
|
+
const inbound = createPendingInboundBuffer()
|
|
63
|
+
const perm = createPendingPermissionBuffer()
|
|
64
|
+
const client = {
|
|
65
|
+
id: 'c1',
|
|
66
|
+
agentName: 'test-agent',
|
|
67
|
+
send: (m: unknown) => {
|
|
68
|
+
const msg = m as { type?: string; id?: string; requestId?: string }
|
|
69
|
+
if (msg.type === 'permission_decision') ops.push(`send:perm:${msg.requestId}`)
|
|
70
|
+
else ops.push(`send:inbound:${msg.id}`)
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
const spoolPuts: string[] = []
|
|
74
|
+
const ctx: DispatchCtx = {
|
|
75
|
+
selfAgent: 'test-agent',
|
|
76
|
+
ipcServer: { sendToAgent: () => true } as never,
|
|
77
|
+
pendingInboundBuffer: inbound,
|
|
78
|
+
inboundSpool: {
|
|
79
|
+
put: (_agent: string, m: { id?: string }) => {
|
|
80
|
+
spoolPuts.push(m.id ?? '?')
|
|
81
|
+
ops.push(`persist:${m.id}`)
|
|
82
|
+
return true
|
|
83
|
+
},
|
|
84
|
+
ack: () => {},
|
|
85
|
+
liveEntries: () => [],
|
|
86
|
+
} as never,
|
|
87
|
+
pendingPermissionBuffer: perm,
|
|
88
|
+
client: client as never,
|
|
89
|
+
log: () => {},
|
|
90
|
+
onUserInboundDelivered: (m) => ops.push(`enrol:${(m as { id?: string }).id}`),
|
|
91
|
+
onSetTurnStarted: (key, at) => ops.push(`setTurn:${key}:${at}`),
|
|
92
|
+
onClearTurnStarted: (key) => ops.push(`clearTurn:${key}`),
|
|
93
|
+
onNoteOutbound: (key) => ops.push(`noteOutbound:${key}`),
|
|
94
|
+
onFirePoke: (key, level) => ops.push(`poke:${key}:${level}`),
|
|
95
|
+
}
|
|
96
|
+
// bufferInbound goes through the real buffer; record via a wrapper.
|
|
97
|
+
const origPush = inbound.push.bind(inbound)
|
|
98
|
+
;(inbound as { push: typeof inbound.push }).push = (agent, m) => {
|
|
99
|
+
ops.push(`buffer:${(m as { id?: string }).id}`)
|
|
100
|
+
return origPush(agent, m)
|
|
101
|
+
}
|
|
102
|
+
return { ctx, ops }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A logging deps object that records the raw effect kinds a dispatch
|
|
106
|
+
// pass would execute, so we can assert "no unhandled effect kind".
|
|
107
|
+
function makeEffectKindProbe(): { ctx: DispatchCtx; seen: string[] } {
|
|
108
|
+
const seen: string[] = []
|
|
109
|
+
const ctx: DispatchCtx = {
|
|
110
|
+
selfAgent: 'test-agent',
|
|
111
|
+
ipcServer: { sendToAgent: () => true } as never,
|
|
112
|
+
pendingInboundBuffer: createPendingInboundBuffer(),
|
|
113
|
+
inboundSpool: { put: () => true, ack: () => {}, liveEntries: () => [] } as never,
|
|
114
|
+
pendingPermissionBuffer: createPendingPermissionBuffer(),
|
|
115
|
+
client: { id: 'c1', agentName: 'test-agent', send: () => {} } as never,
|
|
116
|
+
log: (line: string) => {
|
|
117
|
+
// The dispatcher must NEVER leave an effect unhandled. Two gap
|
|
118
|
+
// markers exist in the code; both are failures for a live cutover.
|
|
119
|
+
if (line.includes('not-yet-cutover') || line.includes('unwired')) {
|
|
120
|
+
seen.push(`GAP:${line.trim()}`)
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
onSetTurnStarted: () => {},
|
|
124
|
+
onClearTurnStarted: () => {},
|
|
125
|
+
onNoteOutbound: () => {},
|
|
126
|
+
onFirePoke: () => {},
|
|
127
|
+
}
|
|
128
|
+
return { ctx, seen }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Event-schedule fixtures (mirror gateway shadowEmit call sites) ────
|
|
132
|
+
|
|
133
|
+
const K = (chat: string, thread?: string): ChatKey => `${chat}:${thread ?? '_'}` as ChatKey
|
|
134
|
+
const inMsg = (id: number, steer = false): MachineInbound => ({
|
|
135
|
+
msgId: id,
|
|
136
|
+
isSteering: steer,
|
|
137
|
+
payload: { type: 'inbound', id: `m${id}`, chat_id: '1', text: `t${id}`, meta: { source: 'telegram' } },
|
|
138
|
+
})
|
|
139
|
+
// Real user inbound — NO meta.source. This is the primary live case:
|
|
140
|
+
// shouldTrackDelivery (inbound-delivery-confirm.ts) only tracks
|
|
141
|
+
// no-source messages, so fixtures that all carry a source would mask a
|
|
142
|
+
// broken enrolment guard (the exact HIGH found in review of PR #3006).
|
|
143
|
+
const userMsg = (id: number): MachineInbound => ({
|
|
144
|
+
msgId: id,
|
|
145
|
+
isSteering: false,
|
|
146
|
+
payload: { type: 'inbound', id: `m${id}`, chat_id: '1', text: `t${id}`, meta: {} },
|
|
147
|
+
})
|
|
148
|
+
const verdict = (r: string) => ({
|
|
149
|
+
requestId: r,
|
|
150
|
+
behavior: 'allow' as const,
|
|
151
|
+
payload: { type: 'permission_decision', requestId: r, behavior: 'allow', updatedInput: {}, timestamp: 0 },
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
/** Named schedules that exercise every transition branch. */
|
|
155
|
+
const SCHEDULES: Record<string, Event[]> = {
|
|
156
|
+
fresh_turn_then_end: [
|
|
157
|
+
{ kind: 'bridgeUp', at: 0 },
|
|
158
|
+
{ kind: 'inbound', key: K('1'), msg: inMsg(1), at: 10 },
|
|
159
|
+
{ kind: 'turnEnd', key: K('1'), at: 20, outboundEmitted: true },
|
|
160
|
+
],
|
|
161
|
+
mid_turn_buffer: [
|
|
162
|
+
{ kind: 'bridgeUp', at: 0 },
|
|
163
|
+
{ kind: 'inbound', key: K('1'), msg: inMsg(1), at: 10 },
|
|
164
|
+
{ kind: 'inbound', key: K('1'), msg: inMsg(2), at: 15 }, // buffered
|
|
165
|
+
{ kind: 'turnEnd', key: K('1'), at: 20, outboundEmitted: true },
|
|
166
|
+
],
|
|
167
|
+
steering_mid_turn: [
|
|
168
|
+
{ kind: 'bridgeUp', at: 0 },
|
|
169
|
+
{ kind: 'inbound', key: K('1'), msg: inMsg(1), at: 10 },
|
|
170
|
+
{ kind: 'inbound', key: K('1'), msg: inMsg(2, true), at: 15 }, // steer → deliver
|
|
171
|
+
],
|
|
172
|
+
bridge_dead_buffer_then_recover: [
|
|
173
|
+
{ kind: 'inbound', key: K('1'), msg: inMsg(1), at: 10 }, // bridge dead → buffer+persist
|
|
174
|
+
{ kind: 'bridgeUp', at: 20 }, // drain
|
|
175
|
+
],
|
|
176
|
+
perm_verdict_alive: [
|
|
177
|
+
{ kind: 'bridgeUp', at: 0 },
|
|
178
|
+
{ kind: 'permVerdict', verdict: verdict('rA'), at: 5 },
|
|
179
|
+
],
|
|
180
|
+
perm_verdict_dead_then_recover: [
|
|
181
|
+
{ kind: 'permVerdict', verdict: verdict('rB'), at: 5 }, // dead → persist
|
|
182
|
+
{ kind: 'bridgeUp', at: 10 }, // redeliver
|
|
183
|
+
],
|
|
184
|
+
stale_turn_fallback: [
|
|
185
|
+
{ kind: 'bridgeUp', at: 0 },
|
|
186
|
+
{ kind: 'inbound', key: K('1'), msg: inMsg(1), at: 10 },
|
|
187
|
+
{ kind: 'tick', now: 10 + 300_001 }, // past TURN_TTL, no outbound → firePoke
|
|
188
|
+
],
|
|
189
|
+
fresh_turn_user_no_source: [
|
|
190
|
+
{ kind: 'bridgeUp', at: 0 },
|
|
191
|
+
{ kind: 'inbound', key: K('1'), msg: userMsg(1), at: 10 }, // real user inbound, no meta.source
|
|
192
|
+
],
|
|
193
|
+
overlapping_turn_no_spurious_fallback: [
|
|
194
|
+
{ kind: 'bridgeUp', at: 0 },
|
|
195
|
+
{ kind: 'inbound', key: K('1'), msg: inMsg(1), at: 10 },
|
|
196
|
+
{ kind: 'modelOutbound', key: K('1'), at: 10 + 300_000 }, // recent outbound
|
|
197
|
+
{ kind: 'tick', now: 10 + 300_001 }, // suppressed by invariant #5
|
|
198
|
+
],
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Run a schedule through the machine, returning the concatenated effect
|
|
202
|
+
// stream in machine order.
|
|
203
|
+
function runMachine(events: Event[]): { effects: Effect[]; finalState: State } {
|
|
204
|
+
let state = initialState()
|
|
205
|
+
const effects: Effect[] = []
|
|
206
|
+
for (const ev of events) {
|
|
207
|
+
const r = transition(state, ev)
|
|
208
|
+
state = r.state
|
|
209
|
+
effects.push(...r.effects)
|
|
210
|
+
}
|
|
211
|
+
return { effects, finalState: state }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
describe('dispatch-equivalence: machine effects → dispatcher I/O', () => {
|
|
215
|
+
for (const [name, events] of Object.entries(SCHEDULES)) {
|
|
216
|
+
it(`'${name}': every emitted effect is handled by the dispatcher (no gap)`, () => {
|
|
217
|
+
const { effects } = runMachine(events)
|
|
218
|
+
const { ctx, seen } = makeEffectKindProbe()
|
|
219
|
+
dispatchEffects(effects, ctx)
|
|
220
|
+
expect(seen).toEqual([]) // no not-yet-cutover / unwired markers
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
it('fresh_turn_then_end replays in machine order: setTurn → send → noteOutbound/clearTurn', () => {
|
|
225
|
+
const { effects } = runMachine(SCHEDULES.fresh_turn_then_end)
|
|
226
|
+
const { ctx, ops } = makeRecorder()
|
|
227
|
+
dispatchEffects(effects, ctx)
|
|
228
|
+
// setTurnStarted precedes the deliver (downstream observer must see
|
|
229
|
+
// the turn marker up first), and turn-end clears + notes outbound.
|
|
230
|
+
expect(ops[0]).toBe('setTurn:1:_:10')
|
|
231
|
+
expect(ops[1]).toBe('send:inbound:m1')
|
|
232
|
+
expect(ops).toContain('clearTurn:1:_')
|
|
233
|
+
expect(ops).toContain('noteOutbound:1:_')
|
|
234
|
+
expect(ops.indexOf('setTurn:1:_:10')).toBeLessThan(ops.indexOf('send:inbound:m1'))
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('mid_turn_buffer: the second inbound is buffered+persisted, never delivered', () => {
|
|
238
|
+
const { effects } = runMachine(SCHEDULES.mid_turn_buffer)
|
|
239
|
+
const { ctx, ops } = makeRecorder()
|
|
240
|
+
dispatchEffects(effects, ctx)
|
|
241
|
+
// m1 delivered (fresh turn), m2 buffered+persisted (mid-turn).
|
|
242
|
+
expect(ops).toContain('send:inbound:m1')
|
|
243
|
+
expect(ops).toContain('buffer:m2')
|
|
244
|
+
expect(ops).toContain('persist:m2')
|
|
245
|
+
// Invariant #1 at the dispatch layer: buffer implies persist, same msg.
|
|
246
|
+
expect(ops.filter((o) => o === 'buffer:m2')).toHaveLength(1)
|
|
247
|
+
expect(ops.filter((o) => o === 'persist:m2')).toHaveLength(1)
|
|
248
|
+
// m2 is NOT delivered at receipt-time; if it is redelivered at all
|
|
249
|
+
// it happens via the turn-end drainBuffer, i.e. strictly AFTER the
|
|
250
|
+
// turn was cleared — never as an in-turn delivery.
|
|
251
|
+
const m2SendIdx = ops.indexOf('send:inbound:m2')
|
|
252
|
+
if (m2SendIdx !== -1) {
|
|
253
|
+
expect(m2SendIdx).toBeGreaterThan(ops.indexOf('clearTurn:1:_'))
|
|
254
|
+
}
|
|
255
|
+
expect(ops.indexOf('buffer:m2')).toBeLessThan(ops.indexOf('clearTurn:1:_'))
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('steering_mid_turn: a steer message is delivered mid-turn, not buffered', () => {
|
|
259
|
+
const { effects } = runMachine(SCHEDULES.steering_mid_turn)
|
|
260
|
+
const { ctx, ops } = makeRecorder()
|
|
261
|
+
dispatchEffects(effects, ctx)
|
|
262
|
+
expect(ops).toContain('send:inbound:m1')
|
|
263
|
+
expect(ops).toContain('send:inbound:m2')
|
|
264
|
+
expect(ops).not.toContain('buffer:m2')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('bridge_dead_buffer_then_recover: buffer+persist while dead, drain on recover', () => {
|
|
268
|
+
const { effects } = runMachine(SCHEDULES.bridge_dead_buffer_then_recover)
|
|
269
|
+
const { ctx, ops } = makeRecorder()
|
|
270
|
+
dispatchEffects(effects, ctx)
|
|
271
|
+
// Buffered+persisted while dead; the drainBuffer effect at bridgeUp
|
|
272
|
+
// redelivers from the buffer → the recorded buffer push then a send.
|
|
273
|
+
expect(ops).toContain('buffer:m1')
|
|
274
|
+
expect(ops).toContain('persist:m1')
|
|
275
|
+
expect(ops).toContain('send:inbound:m1') // redelivered by drainBuffer
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('perm_verdict_alive: verdict delivered straight to the bridge', () => {
|
|
279
|
+
const { effects } = runMachine(SCHEDULES.perm_verdict_alive)
|
|
280
|
+
const { ctx, ops } = makeRecorder()
|
|
281
|
+
dispatchEffects(effects, ctx)
|
|
282
|
+
expect(ops).toContain('send:perm:rA')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('perm_verdict_dead_then_recover: persisted while dead, redelivered on bridgeUp', () => {
|
|
286
|
+
const { effects } = runMachine(SCHEDULES.perm_verdict_dead_then_recover)
|
|
287
|
+
const { ctx, ops } = makeRecorder()
|
|
288
|
+
dispatchEffects(effects, ctx)
|
|
289
|
+
// Persisted while dead (no direct send at persist time), then the
|
|
290
|
+
// redeliverPersistedPermVerdicts effect at bridgeUp sends it.
|
|
291
|
+
expect(ops).toContain('send:perm:rB')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('stale_turn_fallback: fires the fallback poke + clears the turn', () => {
|
|
295
|
+
const { effects } = runMachine(SCHEDULES.stale_turn_fallback)
|
|
296
|
+
const { ctx, ops } = makeRecorder()
|
|
297
|
+
dispatchEffects(effects, ctx)
|
|
298
|
+
expect(ops).toContain('poke:1:_:fallback')
|
|
299
|
+
expect(ops).toContain('clearTurn:1:_')
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('fresh_turn_user_no_source: a real user inbound (no meta.source) is delivered AND enrolled in the deliver-until-acked sweep', () => {
|
|
303
|
+
const { effects } = runMachine(SCHEDULES.fresh_turn_user_no_source)
|
|
304
|
+
const { ctx, ops } = makeRecorder()
|
|
305
|
+
dispatchEffects(effects, ctx)
|
|
306
|
+
expect(ops).toContain('send:inbound:m1')
|
|
307
|
+
// The enrolment callback MUST fire for no-source user inbounds —
|
|
308
|
+
// shouldTrackDelivery inside the gateway callback is the gate, not
|
|
309
|
+
// a dispatcher-side meta.source pre-filter (review HIGH, PR #3006).
|
|
310
|
+
expect(ops).toContain('enrol:m1')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('overlapping_turn_no_spurious_fallback: recent outbound suppresses the poke', () => {
|
|
314
|
+
const { effects } = runMachine(SCHEDULES.overlapping_turn_no_spurious_fallback)
|
|
315
|
+
const { ctx, ops } = makeRecorder()
|
|
316
|
+
dispatchEffects(effects, ctx)
|
|
317
|
+
// Invariant #5: the model broke silence within OUTBOUND_RECENT_MS,
|
|
318
|
+
// so NO fallback poke fires.
|
|
319
|
+
expect(ops.some((o) => o.startsWith('poke:'))).toBe(false)
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
describe('dispatch-equivalence: effect-kind coverage', () => {
|
|
324
|
+
it('the union of all schedule effects covers every machine effect kind that maps to I/O', () => {
|
|
325
|
+
const allKinds = new Set<string>()
|
|
326
|
+
for (const events of Object.values(SCHEDULES)) {
|
|
327
|
+
for (const e of runMachine(events).effects) allKinds.add(e.kind)
|
|
328
|
+
}
|
|
329
|
+
// Every kind produced by the fixtures must be one the dispatcher
|
|
330
|
+
// executes. logTrace is pure telemetry; the rest are I/O.
|
|
331
|
+
const expected = [
|
|
332
|
+
'deliverToBridge',
|
|
333
|
+
'bufferInbound',
|
|
334
|
+
'persistInbound',
|
|
335
|
+
'drainBuffer',
|
|
336
|
+
'setTurnStarted',
|
|
337
|
+
'clearTurnStarted',
|
|
338
|
+
'noteOutbound',
|
|
339
|
+
'firePoke',
|
|
340
|
+
'deliverPermVerdict',
|
|
341
|
+
'persistPermVerdict',
|
|
342
|
+
'redeliverPersistedPermVerdicts',
|
|
343
|
+
]
|
|
344
|
+
for (const kind of expected) {
|
|
345
|
+
expect(allKinds.has(kind)).toBe(true)
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
})
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
import type { DispatchCtx } from '../gateway/inbound-delivery-machine-dispatch'
|
|
23
23
|
import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer'
|
|
24
24
|
import { createPendingPermissionBuffer } from '../gateway/pending-permission-decisions'
|
|
25
|
-
import
|
|
25
|
+
import { createInboundSpool } from '../gateway/inbound-spool'
|
|
26
26
|
|
|
27
27
|
function makeCtx(overrides?: Partial<DispatchCtx>): {
|
|
28
28
|
ctx: DispatchCtx
|
|
@@ -184,57 +184,146 @@ describe('dispatchEffects — kill switch', () => {
|
|
|
184
184
|
})
|
|
185
185
|
})
|
|
186
186
|
|
|
187
|
-
describe('
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
'
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
187
|
+
describe('dispatchEffects — inbound-routing effects (PR3c wiring)', () => {
|
|
188
|
+
const k = 'c1:_' as never
|
|
189
|
+
const ipcMsg = { type: 'inbound' as const, id: 'm1', chat_id: '1', text: 'hi', meta: { source: 'telegram' } }
|
|
190
|
+
|
|
191
|
+
it('deliverToBridge sends the payload to the client', () => {
|
|
192
|
+
const { ctx, clientSend } = makeCtx()
|
|
193
|
+
__dispatchOneForTests({ kind: 'deliverToBridge', key: k, msg: { msgId: 1, isSteering: false, payload: ipcMsg } }, ctx)
|
|
194
|
+
expect(clientSend).toHaveBeenCalledTimes(1)
|
|
195
|
+
expect(clientSend).toHaveBeenCalledWith(ipcMsg)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('deliverToBridge falls back to ipcServer.sendToAgent with no client', () => {
|
|
199
|
+
const { ctx, sendToAgent } = makeCtx({ client: undefined })
|
|
200
|
+
__dispatchOneForTests({ kind: 'deliverToBridge', key: k, msg: { msgId: 1, isSteering: false, payload: ipcMsg } }, ctx)
|
|
201
|
+
expect(sendToAgent).toHaveBeenCalledWith('test-agent', ipcMsg)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('deliverToBridge enrols in the deliver-until-acked sweep', () => {
|
|
205
|
+
const onUserInboundDelivered = vi.fn()
|
|
206
|
+
const { ctx } = makeCtx({ onUserInboundDelivered })
|
|
207
|
+
__dispatchOneForTests({ kind: 'deliverToBridge', key: k, msg: { msgId: 1, isSteering: false, payload: ipcMsg } }, ctx)
|
|
208
|
+
expect(onUserInboundDelivered).toHaveBeenCalledWith(ipcMsg)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('deliverToBridge DOES pass the enrolment callback for a real user inbound (NO meta.source)', () => {
|
|
212
|
+
// The primary live case: real user messages carry NO meta.source,
|
|
213
|
+
// and they are exactly the messages shouldTrackDelivery tracks. A
|
|
214
|
+
// dispatcher-side pre-filter on meta.source would skip them (the
|
|
215
|
+
// review HIGH on PR #3006) — the callback must fire unconditionally
|
|
216
|
+
// and self-gate inside the gateway.
|
|
217
|
+
const noSourceMsg = { type: 'inbound' as const, id: 'u1', chat_id: '1', text: 'hi', meta: {} }
|
|
218
|
+
const onUserInboundDelivered = vi.fn()
|
|
219
|
+
const { ctx, clientSend } = makeCtx({ onUserInboundDelivered })
|
|
220
|
+
__dispatchOneForTests({ kind: 'deliverToBridge', key: k, msg: { msgId: 2, isSteering: false, payload: noSourceMsg } }, ctx)
|
|
221
|
+
expect(clientSend).toHaveBeenCalledWith(noSourceMsg)
|
|
222
|
+
expect(onUserInboundDelivered).toHaveBeenCalledWith(noSourceMsg)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('bufferInbound pushes onto the pending inbound buffer', () => {
|
|
226
|
+
const { ctx, inbound, clientSend } = makeCtx()
|
|
227
|
+
__dispatchOneForTests({ kind: 'bufferInbound', key: k, msg: { msgId: 1, isSteering: false, payload: ipcMsg } }, ctx)
|
|
228
|
+
expect(inbound.depth('test-agent')).toBe(1)
|
|
229
|
+
expect(clientSend).not.toHaveBeenCalled()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('persistInbound puts to the spool when one is attached', () => {
|
|
233
|
+
const put = vi.fn(() => true)
|
|
234
|
+
const { ctx } = makeCtx({ inboundSpool: { put } as never })
|
|
235
|
+
__dispatchOneForTests({ kind: 'persistInbound', key: k, msg: { msgId: 1, isSteering: false, payload: ipcMsg } }, ctx)
|
|
236
|
+
expect(put).toHaveBeenCalledWith('test-agent', ipcMsg)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('persistInbound is a safe no-op when no spool is attached', () => {
|
|
240
|
+
const { ctx } = makeCtx({ inboundSpool: null })
|
|
241
|
+
expect(() =>
|
|
242
|
+
__dispatchOneForTests({ kind: 'persistInbound', key: k, msg: { msgId: 1, isSteering: false, payload: ipcMsg } }, ctx),
|
|
243
|
+
).not.toThrow()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('bufferInbound + persistInbound with a spool attached to the buffer does NOT double-spool (idempotent by spoolId)', () => {
|
|
247
|
+
// The machine emits bufferInbound AND persistInbound as a pair for a
|
|
248
|
+
// mid-turn / bridge-dead inbound. When the ctx's buffer is
|
|
249
|
+
// constructed with the SAME durable spool (the production wiring),
|
|
250
|
+
// buffer.push already spools — so the paired persistInbound's
|
|
251
|
+
// spool.put must dedupe by spoolId, leaving exactly ONE live entry.
|
|
252
|
+
const spoolPath = '/state/agent/telegram/inbound-spool.jsonl'
|
|
253
|
+
const files = new Map<string, string>()
|
|
254
|
+
const fs = {
|
|
255
|
+
appendFileSync: (p: string, d: string) => files.set(p, (files.get(p) ?? '') + d),
|
|
256
|
+
readFileSync: (p: string) => files.get(p) ?? '',
|
|
257
|
+
writeFileSync: (p: string, d: string) => files.set(p, d),
|
|
258
|
+
renameSync: (from: string, to: string) => {
|
|
259
|
+
files.set(to, files.get(from) ?? '')
|
|
260
|
+
files.delete(from)
|
|
261
|
+
},
|
|
262
|
+
existsSync: (p: string) => files.has(p),
|
|
263
|
+
statSizeSync: (p: string) => Buffer.byteLength(files.get(p) ?? ''),
|
|
264
|
+
}
|
|
265
|
+
const spool = createInboundSpool({ path: spoolPath, fs, log: () => {} })
|
|
266
|
+
const buffer = createPendingInboundBuffer({ spool, log: () => {} })
|
|
267
|
+
const { ctx } = makeCtx({ pendingInboundBuffer: buffer, inboundSpool: spool })
|
|
268
|
+
// Spool-identifiable message shape (spoolId reads chatId/messageId).
|
|
269
|
+
const durableMsg = {
|
|
270
|
+
type: 'inbound' as const,
|
|
271
|
+
chatId: 'c1',
|
|
272
|
+
messageId: 77,
|
|
273
|
+
user: 'u',
|
|
274
|
+
userId: 1,
|
|
275
|
+
ts: 1000,
|
|
276
|
+
text: 'hold me',
|
|
277
|
+
meta: {},
|
|
278
|
+
}
|
|
279
|
+
__dispatchOneForTests({ kind: 'bufferInbound', key: k, msg: { msgId: 77, isSteering: false, payload: durableMsg } }, ctx)
|
|
280
|
+
__dispatchOneForTests({ kind: 'persistInbound', key: k, msg: { msgId: 77, isSteering: false, payload: durableMsg } }, ctx)
|
|
281
|
+
expect(buffer.depth('test-agent')).toBe(1)
|
|
282
|
+
expect(spool.liveCount()).toBe(1) // no double-spool
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
describe('dispatchEffects — perm-verdict effects', () => {
|
|
287
|
+
const permEv = { type: 'permission_decision' as const, requestId: 'r1', behavior: 'allow' as const, updatedInput: {}, timestamp: 0 }
|
|
288
|
+
|
|
289
|
+
it('deliverPermVerdict sends the payload to the client', () => {
|
|
290
|
+
const { ctx, clientSend } = makeCtx()
|
|
291
|
+
__dispatchOneForTests({ kind: 'deliverPermVerdict', verdict: { requestId: 'r1', behavior: 'allow', payload: permEv } }, ctx)
|
|
292
|
+
expect(clientSend).toHaveBeenCalledWith(permEv)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('persistPermVerdict pushes onto the pending permission buffer', () => {
|
|
296
|
+
const { ctx, perm, clientSend } = makeCtx()
|
|
297
|
+
__dispatchOneForTests({ kind: 'persistPermVerdict', verdict: { requestId: 'r1', behavior: 'allow', payload: permEv } }, ctx)
|
|
298
|
+
expect(clientSend).not.toHaveBeenCalled()
|
|
299
|
+
expect(perm.drain('test-agent')).toEqual([permEv])
|
|
300
|
+
})
|
|
212
301
|
})
|
|
213
302
|
|
|
214
|
-
|
|
303
|
+
describe('dispatchEffects — turn/poke callbacks', () => {
|
|
215
304
|
const k = 'c1:_' as never
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
}
|
|
305
|
+
|
|
306
|
+
it('setTurnStarted / clearTurnStarted / noteOutbound / firePoke fire their callbacks', () => {
|
|
307
|
+
const onSetTurnStarted = vi.fn()
|
|
308
|
+
const onClearTurnStarted = vi.fn()
|
|
309
|
+
const onNoteOutbound = vi.fn()
|
|
310
|
+
const onFirePoke = vi.fn()
|
|
311
|
+
const { ctx } = makeCtx({ onSetTurnStarted, onClearTurnStarted, onNoteOutbound, onFirePoke })
|
|
312
|
+
__dispatchOneForTests({ kind: 'setTurnStarted', key: k, at: 7 }, ctx)
|
|
313
|
+
__dispatchOneForTests({ kind: 'clearTurnStarted', key: k }, ctx)
|
|
314
|
+
__dispatchOneForTests({ kind: 'noteOutbound', key: k, at: 9 }, ctx)
|
|
315
|
+
__dispatchOneForTests({ kind: 'firePoke', key: k, level: 'fallback' }, ctx)
|
|
316
|
+
expect(onSetTurnStarted).toHaveBeenCalledWith(k, 7)
|
|
317
|
+
expect(onClearTurnStarted).toHaveBeenCalledWith(k)
|
|
318
|
+
expect(onNoteOutbound).toHaveBeenCalledWith(k, 9)
|
|
319
|
+
expect(onFirePoke).toHaveBeenCalledWith(k, 'fallback')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('logs an `unwired` trace (never silently no-ops) when a callback is absent', () => {
|
|
323
|
+
const { ctx, logs } = makeCtx()
|
|
324
|
+
__dispatchOneForTests({ kind: 'setTurnStarted', key: k, at: 0 }, ctx)
|
|
325
|
+
__dispatchOneForTests({ kind: 'firePoke', key: k, level: 'soft' }, ctx)
|
|
326
|
+
expect(logs.some((l) => l.includes('unwired effect=setTurnStarted'))).toBe(true)
|
|
327
|
+
expect(logs.some((l) => l.includes('unwired effect=firePoke'))).toBe(true)
|
|
328
|
+
})
|
|
329
|
+
})
|
|
@@ -19,7 +19,14 @@ import { describe, expect, it } from 'vitest'
|
|
|
19
19
|
import { readFileSync } from 'node:fs'
|
|
20
20
|
import { resolve } from 'node:path'
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
// #2996 Phase 5: the callback-query handler families moved verbatim to
|
|
23
|
+
// gateway/callback-query-handlers.ts; these pins read the gateway source
|
|
24
|
+
// COMBINED with that module so the wiring assertions keep covering the
|
|
25
|
+
// same runtime source text.
|
|
26
|
+
const gatewaySrc =
|
|
27
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
|
|
28
|
+
'\n' +
|
|
29
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
|
|
23
30
|
|
|
24
31
|
function proposeCallbackBlock(): string {
|
|
25
32
|
return (
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
isValidModelArg,
|
|
21
21
|
isSrModel,
|
|
22
22
|
isClaudeModel,
|
|
23
|
+
isBusyRefusalText,
|
|
24
|
+
isOfflineTrustedModelToken,
|
|
23
25
|
MODEL_ALIASES,
|
|
24
26
|
type ModelCommandDeps,
|
|
25
27
|
} from "../gateway/model-command.js";
|
|
@@ -201,7 +203,7 @@ describe("handleModelCommand — set", () => {
|
|
|
201
203
|
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
202
204
|
expect(calls).toEqual([{ agent: "klanker", command: "/model opus" }]);
|
|
203
205
|
expect(reply.text).toContain("<pre>⏺ Set model to sonnet</pre>");
|
|
204
|
-
expect(reply.text).toContain("
|
|
206
|
+
expect(reply.text).toContain("persists across restarts, deploys, and crashes");
|
|
205
207
|
expect(reply.html).toBe(true);
|
|
206
208
|
// A verified confirmation records the live model so /status stays honest
|
|
207
209
|
// (bug 1: the typed path never recorded the switch before).
|
|
@@ -769,12 +771,19 @@ describe("buildModelMenu", () => {
|
|
|
769
771
|
}
|
|
770
772
|
});
|
|
771
773
|
|
|
772
|
-
it("busy agent → no discovery,
|
|
774
|
+
it("busy agent → no discovery, STATIC keyboard whose taps ride the queue (#3039)", async () => {
|
|
773
775
|
const { deps, calls } = makeMenuDeps({ isBusy: () => true });
|
|
774
776
|
const menu = await buildModelMenu(deps);
|
|
777
|
+
// Never drives the picker mid-turn…
|
|
775
778
|
expect(calls.discover).toBe(0);
|
|
776
|
-
expect(menu.keyboard).toBeUndefined();
|
|
777
779
|
expect(menu.text).toContain("mid-turn");
|
|
780
|
+
// …but no dead-end either: static alias rows are offered so the operator
|
|
781
|
+
// can still lock in a choice (the tap queues at the gateway busy gate).
|
|
782
|
+
expect(menu.keyboard).toBeDefined();
|
|
783
|
+
const data = menu.keyboard!.flat().map(b => b.callback_data);
|
|
784
|
+
expect(data).toContain("mdl:alias:opus");
|
|
785
|
+
expect(data).toContain("mdl:alias:default");
|
|
786
|
+
expect(menu.text).not.toContain("Try again");
|
|
778
787
|
});
|
|
779
788
|
|
|
780
789
|
it("discovery failure → static v1 fallback with the reason, no keyboard", async () => {
|
|
@@ -1317,3 +1326,37 @@ describe("Fable alias callback injects /model fable", () => {
|
|
|
1317
1326
|
expect(out.answer).toContain("Invalid");
|
|
1318
1327
|
});
|
|
1319
1328
|
});
|
|
1329
|
+
|
|
1330
|
+
// ─── #3039: busy-refusal detection for the queued-command drain ──────────────
|
|
1331
|
+
|
|
1332
|
+
describe("isBusyRefusalText (#3039)", () => {
|
|
1333
|
+
it("matches the typed and menu busy refusals", async () => {
|
|
1334
|
+
const deps = makeDeps({ isBusy: () => true }).deps;
|
|
1335
|
+
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
1336
|
+
expect(isBusyRefusalText(reply.text)).toBe(true);
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
it("never matches a genuine confirmation or failure", () => {
|
|
1340
|
+
expect(isBusyRefusalText("⏺ Set model to Opus 4.8")).toBe(false);
|
|
1341
|
+
expect(isBusyRefusalText("✅ `/effort high` — Set effort level to high")).toBe(false);
|
|
1342
|
+
expect(isBusyRefusalText("❌ Switch to opus failed: tmux session not found")).toBe(false);
|
|
1343
|
+
});
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
describe("isOfflineTrustedModelToken (#3042 blocker 2a)", () => {
|
|
1348
|
+
it("trusts static Claude aliases and curated sr-* alias names/targets", () => {
|
|
1349
|
+
for (const a of MODEL_ALIASES) expect(isOfflineTrustedModelToken(a)).toBe(true);
|
|
1350
|
+
// Curated sr-* aliases resolve by construction (present in the LiteLLM config).
|
|
1351
|
+
const [alias, target] = Object.entries(SR_MODEL_ALIASES)[0];
|
|
1352
|
+
expect(isOfflineTrustedModelToken(alias)).toBe(true);
|
|
1353
|
+
expect(isOfflineTrustedModelToken(target)).toBe(true);
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
it("refuses hand-typed full ids — shape-valid garbage must never reach a boot carrier unconfirmed", () => {
|
|
1357
|
+
expect(isOfflineTrustedModelToken("claude-nonexistnet-9")).toBe(false);
|
|
1358
|
+
expect(isOfflineTrustedModelToken("claude-opus-4-8")).toBe(false); // real but unverifiable offline
|
|
1359
|
+
expect(isOfflineTrustedModelToken("sr-made-up/model")).toBe(false);
|
|
1360
|
+
expect(isOfflineTrustedModelToken("")).toBe(false);
|
|
1361
|
+
});
|
|
1362
|
+
});
|