switchroom 0.15.45 → 0.16.5
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 +56 -15
- package/dist/auth-broker/index.js +383 -97
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +7 -4
- package/dist/cli/notion-write-pretool.mjs +35 -4
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/switchroom.js +2894 -841
- package/dist/host-control/main.js +2685 -207
- package/dist/vault/approvals/kernel-server.js +7453 -7413
- package/dist/vault/broker/server.js +11428 -11388
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +97 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +0 -19
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +55 -12
- package/telegram-plugin/dist/gateway/gateway.js +2938 -977
- package/telegram-plugin/dist/server.js +55 -12
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1857 -292
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/model-command.ts +115 -4
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-command.test.ts +134 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the resume-after-swap gate (auth-failover-stall Fix 1).
|
|
3
|
+
*
|
|
4
|
+
* The gate owns the decision the gateway consults in doFireFleetAutoFallback
|
|
5
|
+
* after a SUCCESSFUL swap: should we restart to resume the turn the mid-turn
|
|
6
|
+
* 429 killed? It must:
|
|
7
|
+
* - return 'resume' on the first switched outcome (so exactly one restart
|
|
8
|
+
* fires), recorded so a follow-on swap is suppressed;
|
|
9
|
+
* - return 'skip-inflight' on a SECOND swap within the single-flight window
|
|
10
|
+
* (a 429 storm cannot loop-restart the agent);
|
|
11
|
+
* - return 'skip-stale' when the failed turn is older than maxAgeMs (an
|
|
12
|
+
* ancient interrupted turn is not resurrected);
|
|
13
|
+
* - never be consulted on all-blocked (verified by the gateway-seam test
|
|
14
|
+
* below, which only calls decide() on 'switched').
|
|
15
|
+
*
|
|
16
|
+
* The gate is pure (no process restart), so these tests run with a fake clock
|
|
17
|
+
* and never touch a real process — the restart itself is a separate seam
|
|
18
|
+
* (triggerSelfRestart) that the gateway wires to a 'resume' verdict.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from 'vitest'
|
|
22
|
+
import {
|
|
23
|
+
createFleetFallbackResumeGate,
|
|
24
|
+
DEFAULT_RESUME_MAX_AGE_MS,
|
|
25
|
+
DEFAULT_RESUME_SINGLE_FLIGHT_MS,
|
|
26
|
+
} from '../fleet-fallback-resume.js'
|
|
27
|
+
|
|
28
|
+
function fakeClock(start = 1_000_000) {
|
|
29
|
+
let t = start
|
|
30
|
+
return {
|
|
31
|
+
now: () => t,
|
|
32
|
+
advance: (ms: number) => {
|
|
33
|
+
t += ms
|
|
34
|
+
},
|
|
35
|
+
set: (ms: number) => {
|
|
36
|
+
t = ms
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('createFleetFallbackResumeGate — resume verdict', () => {
|
|
42
|
+
it("returns 'resume' on the first switched outcome", () => {
|
|
43
|
+
const clk = fakeClock()
|
|
44
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
45
|
+
// Failed turn started just now → fresh, not stale.
|
|
46
|
+
expect(gate.decide(clk.now())).toBe('resume')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("treats a null (unknown) failed-turn timestamp as resumable", () => {
|
|
50
|
+
const clk = fakeClock()
|
|
51
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
52
|
+
// null defers staleness to the boot-resume 3h failsafe → resume here.
|
|
53
|
+
expect(gate.decide(null)).toBe('resume')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('createFleetFallbackResumeGate — single-flight guard', () => {
|
|
58
|
+
it('suppresses a SECOND swap inside the single-flight window (no double-resume)', () => {
|
|
59
|
+
const clk = fakeClock()
|
|
60
|
+
const gate = createFleetFallbackResumeGate({
|
|
61
|
+
nowFn: clk.now,
|
|
62
|
+
singleFlightMs: DEFAULT_RESUME_SINGLE_FLIGHT_MS,
|
|
63
|
+
})
|
|
64
|
+
expect(gate.decide(clk.now())).toBe('resume')
|
|
65
|
+
// A 429 storm: a second swap fires 1s later. Must NOT re-arm.
|
|
66
|
+
clk.advance(1_000)
|
|
67
|
+
expect(gate.decide(clk.now())).toBe('skip-inflight')
|
|
68
|
+
// Still suppressed near the end of the window.
|
|
69
|
+
clk.advance(DEFAULT_RESUME_SINGLE_FLIGHT_MS - 2_000)
|
|
70
|
+
expect(gate.decide(clk.now())).toBe('skip-inflight')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('re-arms once the single-flight window has fully elapsed', () => {
|
|
74
|
+
const clk = fakeClock()
|
|
75
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now, singleFlightMs: 60_000 })
|
|
76
|
+
expect(gate.decide(clk.now())).toBe('resume')
|
|
77
|
+
clk.advance(60_001)
|
|
78
|
+
// A genuinely new swap after the window resumes again (one per swap).
|
|
79
|
+
expect(gate.decide(clk.now())).toBe('resume')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('a rapid burst of N swaps yields exactly ONE resume', () => {
|
|
83
|
+
const clk = fakeClock()
|
|
84
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now, singleFlightMs: 60_000 })
|
|
85
|
+
let resumes = 0
|
|
86
|
+
for (let i = 0; i < 10; i++) {
|
|
87
|
+
if (gate.decide(clk.now()) === 'resume') resumes++
|
|
88
|
+
clk.advance(500) // 0.5s between storm events, all inside the window
|
|
89
|
+
}
|
|
90
|
+
expect(resumes).toBe(1)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('createFleetFallbackResumeGate — staleness guard', () => {
|
|
95
|
+
it("suppresses ('skip-stale') a failed turn older than maxAgeMs", () => {
|
|
96
|
+
const clk = fakeClock()
|
|
97
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
98
|
+
const ancientStart = clk.now() - (DEFAULT_RESUME_MAX_AGE_MS + 60_000)
|
|
99
|
+
expect(gate.decide(ancientStart)).toBe('skip-stale')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('a stale verdict does NOT arm the single-flight window', () => {
|
|
103
|
+
const clk = fakeClock()
|
|
104
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
105
|
+
expect(gate.decide(clk.now() - (DEFAULT_RESUME_MAX_AGE_MS + 1))).toBe('skip-stale')
|
|
106
|
+
// A subsequent FRESH turn must still resume — the stale skip must not have
|
|
107
|
+
// recorded an arm time.
|
|
108
|
+
expect(gate.decide(clk.now())).toBe('resume')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('a turn just under maxAgeMs still resumes', () => {
|
|
112
|
+
const clk = fakeClock()
|
|
113
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now, maxAgeMs: 10_800_000 })
|
|
114
|
+
expect(gate.decide(clk.now() - (10_800_000 - 1_000))).toBe('resume')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('honours a custom maxAgeMs', () => {
|
|
118
|
+
const clk = fakeClock()
|
|
119
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now, maxAgeMs: 60_000 })
|
|
120
|
+
expect(gate.decide(clk.now() - 61_000)).toBe('skip-stale')
|
|
121
|
+
expect(gate.decide(clk.now() - 30_000)).toBe('resume')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('createFleetFallbackResumeGate — reset / inspect seams', () => {
|
|
126
|
+
it('reset() clears the single-flight arm', () => {
|
|
127
|
+
const clk = fakeClock()
|
|
128
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
129
|
+
expect(gate.decide(clk.now())).toBe('resume')
|
|
130
|
+
expect(gate.decide(clk.now())).toBe('skip-inflight')
|
|
131
|
+
gate.reset()
|
|
132
|
+
expect(gate.inspect().lastResumedAtMs).toBe(Number.NEGATIVE_INFINITY)
|
|
133
|
+
expect(gate.decide(clk.now())).toBe('resume')
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Gateway-seam contract test. Mirrors how doFireFleetAutoFallback consults the
|
|
139
|
+
* gate: it calls decide() ONLY on outcome.kind === 'switched', and translates a
|
|
140
|
+
* 'resume' verdict into exactly one restart. We mock the restart as a counter,
|
|
141
|
+
* so no process is touched. This pins the "all-blocked is a no-op" and
|
|
142
|
+
* "exactly-once" contracts at the call-site shape.
|
|
143
|
+
*/
|
|
144
|
+
describe('gateway seam — decide() consulted only on switched, restart fires once', () => {
|
|
145
|
+
type Outcome = { kind: 'switched' | 'all-blocked' }
|
|
146
|
+
|
|
147
|
+
function simulateDispatch(
|
|
148
|
+
gate: ReturnType<typeof createFleetFallbackResumeGate>,
|
|
149
|
+
outcome: Outcome,
|
|
150
|
+
failedTurnStartedAtMs: number | null,
|
|
151
|
+
restart: () => void,
|
|
152
|
+
): void {
|
|
153
|
+
// The actual gateway code path: resume is reached ONLY on 'switched'.
|
|
154
|
+
if (outcome.kind === 'switched') {
|
|
155
|
+
if (gate.decide(failedTurnStartedAtMs) === 'resume') restart()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
it('switched → restart fires exactly once', () => {
|
|
160
|
+
const clk = fakeClock()
|
|
161
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
162
|
+
let restarts = 0
|
|
163
|
+
simulateDispatch(gate, { kind: 'switched' }, clk.now(), () => restarts++)
|
|
164
|
+
expect(restarts).toBe(1)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('all-blocked → decide() is never consulted, restart never fires', () => {
|
|
168
|
+
const clk = fakeClock()
|
|
169
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
170
|
+
let restarts = 0
|
|
171
|
+
simulateDispatch(gate, { kind: 'all-blocked' }, clk.now(), () => restarts++)
|
|
172
|
+
expect(restarts).toBe(0)
|
|
173
|
+
// The gate stayed unarmed, so a subsequent real switch still resumes.
|
|
174
|
+
simulateDispatch(gate, { kind: 'switched' }, clk.now(), () => restarts++)
|
|
175
|
+
expect(restarts).toBe(1)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('a 429 storm of switched outcomes restarts exactly once', () => {
|
|
179
|
+
const clk = fakeClock()
|
|
180
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now, singleFlightMs: 60_000 })
|
|
181
|
+
let restarts = 0
|
|
182
|
+
for (let i = 0; i < 5; i++) {
|
|
183
|
+
simulateDispatch(gate, { kind: 'switched' }, clk.now(), () => restarts++)
|
|
184
|
+
clk.advance(1_000)
|
|
185
|
+
}
|
|
186
|
+
expect(restarts).toBe(1)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('a stale switched outcome does not restart', () => {
|
|
190
|
+
const clk = fakeClock()
|
|
191
|
+
const gate = createFleetFallbackResumeGate({ nowFn: clk.now })
|
|
192
|
+
let restarts = 0
|
|
193
|
+
const ancient = clk.now() - (DEFAULT_RESUME_MAX_AGE_MS + 1)
|
|
194
|
+
simulateDispatch(gate, { kind: 'switched' }, ancient, () => restarts++)
|
|
195
|
+
expect(restarts).toBe(0)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
readCleanShutdownMarker,
|
|
36
36
|
clearCleanShutdownMarker,
|
|
37
37
|
shouldSuppressRecoveryBanner,
|
|
38
|
+
shouldSuppressBootResume,
|
|
38
39
|
resolveShutdownMarker,
|
|
39
40
|
DEFAULT_MAX_AGE_MS,
|
|
40
41
|
EXTERNAL_RESTART_FALLBACK_REASON,
|
|
@@ -344,6 +345,122 @@ describe("resolveShutdownMarker (SIGTERM-handler sequencing)", () => {
|
|
|
344
345
|
});
|
|
345
346
|
});
|
|
346
347
|
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// Boot-resume gate: shouldSuppressBootResume
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
describe("shouldSuppressBootResume", () => {
|
|
353
|
+
// Core contract: clean shutdown (fresh marker) → suppress; crash (no marker
|
|
354
|
+
// or stale) → do not suppress; forceAlways override → never suppress.
|
|
355
|
+
|
|
356
|
+
it("returns false when no marker is present (crash/OOM — resume as before)", () => {
|
|
357
|
+
expect(shouldSuppressBootResume(null, Date.now())).toBe(false);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("returns true for a fresh clean-shutdown marker (operator/roll restart — suppress)", () => {
|
|
361
|
+
const now = 1_700_000_000_000;
|
|
362
|
+
const marker: CleanShutdownMarker = { ts: now - 5_000, signal: "SIGTERM" };
|
|
363
|
+
expect(shouldSuppressBootResume(marker, now)).toBe(true);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("returns true at age=0 (marker written right before boot)", () => {
|
|
367
|
+
const now = 1_700_000_000_000;
|
|
368
|
+
const marker: CleanShutdownMarker = { ts: now, signal: "SIGTERM" };
|
|
369
|
+
expect(shouldSuppressBootResume(marker, now)).toBe(true);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("returns false when marker age equals maxAgeMs (boundary is exclusive)", () => {
|
|
373
|
+
const now = 1_700_000_000_000;
|
|
374
|
+
const marker: CleanShutdownMarker = { ts: now - DEFAULT_MAX_AGE_MS, signal: "SIGTERM" };
|
|
375
|
+
expect(shouldSuppressBootResume(marker, now)).toBe(false);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("returns false for a stale marker (drain took >60s — treat as crash)", () => {
|
|
379
|
+
const now = 1_700_000_000_000;
|
|
380
|
+
const marker: CleanShutdownMarker = { ts: now - 90_000, signal: "SIGTERM" };
|
|
381
|
+
expect(shouldSuppressBootResume(marker, now)).toBe(false);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("treats clock skew (future ts) as stale to avoid false suppression", () => {
|
|
385
|
+
const now = 1_700_000_000_000;
|
|
386
|
+
const marker: CleanShutdownMarker = { ts: now + 10_000, signal: "SIGTERM" };
|
|
387
|
+
expect(shouldSuppressBootResume(marker, now)).toBe(false);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("respects a custom maxAgeMs", () => {
|
|
391
|
+
const now = 1_700_000_000_000;
|
|
392
|
+
const marker: CleanShutdownMarker = { ts: now - 30_000, signal: "SIGTERM" };
|
|
393
|
+
expect(shouldSuppressBootResume(marker, now, { maxAgeMs: 60_000 })).toBe(true);
|
|
394
|
+
expect(shouldSuppressBootResume(marker, now, { maxAgeMs: 10_000 })).toBe(false);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("forceAlways=true disables suppression even for a fresh clean marker (escape hatch)", () => {
|
|
398
|
+
// SWITCHROOM_BOOT_RESUME_ALWAYS=1 must restore unconditional resume.
|
|
399
|
+
const now = 1_700_000_000_000;
|
|
400
|
+
const marker: CleanShutdownMarker = { ts: now - 1_000, signal: "SIGTERM" };
|
|
401
|
+
expect(shouldSuppressBootResume(marker, now, { forceAlways: true })).toBe(false);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("forceAlways=false has no effect (default behaviour is the gate)", () => {
|
|
405
|
+
const now = 1_700_000_000_000;
|
|
406
|
+
const marker: CleanShutdownMarker = { ts: now - 1_000, signal: "SIGTERM" };
|
|
407
|
+
expect(shouldSuppressBootResume(marker, now, { forceAlways: false })).toBe(true);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("works for SIGTERM and SIGINT (signal value is opaque)", () => {
|
|
411
|
+
const now = 1_700_000_000_000;
|
|
412
|
+
expect(shouldSuppressBootResume({ ts: now, signal: "SIGTERM" }, now)).toBe(true);
|
|
413
|
+
expect(shouldSuppressBootResume({ ts: now, signal: "SIGINT" }, now)).toBe(true);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("works with a marker that carries a reason field (rollout attribution is preserved)", () => {
|
|
417
|
+
const now = 1_700_000_000_000;
|
|
418
|
+
const marker: CleanShutdownMarker = {
|
|
419
|
+
ts: now - 2_000,
|
|
420
|
+
signal: "SIGTERM",
|
|
421
|
+
reason: "operator: switchroom update",
|
|
422
|
+
};
|
|
423
|
+
expect(shouldSuppressBootResume(marker, now)).toBe(true);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Boot-resume gate: gateway wiring (source-level)
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
describe("gateway.ts boot-resume clean-shutdown gate (source-level)", () => {
|
|
432
|
+
// Source-grep pins ensure the gate wiring in gateway.ts stays present
|
|
433
|
+
// after refactors. Pure unit tests on shouldSuppressBootResume cover the
|
|
434
|
+
// decision logic; these cover the wiring.
|
|
435
|
+
const gatewaySource = readFileSync(
|
|
436
|
+
join(import.meta.dir, "..", "gateway", "gateway.ts"),
|
|
437
|
+
"utf8",
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
it("imports shouldSuppressBootResume from clean-shutdown-marker", () => {
|
|
441
|
+
expect(gatewaySource).toContain("shouldSuppressBootResume");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("reads the clean-shutdown marker before building the boot-resume inbound", () => {
|
|
445
|
+
expect(gatewaySource).toContain("bootResumeCleanMarker");
|
|
446
|
+
expect(gatewaySource).toContain("readCleanShutdownMarker(bootResumeMarkerPath)");
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("calls shouldSuppressBootResume with the marker, now, and forceAlways", () => {
|
|
450
|
+
expect(gatewaySource).toContain("shouldSuppressBootResume(bootResumeCleanMarker, Date.now()");
|
|
451
|
+
expect(gatewaySource).toContain("forceAlways: bootResumeForceAlways");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("provides the SWITCHROOM_BOOT_RESUME_ALWAYS escape hatch", () => {
|
|
455
|
+
expect(gatewaySource).toContain("SWITCHROOM_BOOT_RESUME_ALWAYS");
|
|
456
|
+
expect(gatewaySource).toContain("=== '1'");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("logs a diagnostic when boot-resume is suppressed", () => {
|
|
460
|
+
expect(gatewaySource).toContain("boot-resume suppressed (clean shutdown");
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
347
464
|
describe("gateway.ts shutdown-handler wiring (source-level)", () => {
|
|
348
465
|
// Source-grep pins so a future refactor can't silently drop the
|
|
349
466
|
// reason-preserving + fallback-writing behaviour the 2026-04-24 fix
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { createAnswerStream
|
|
2
|
+
import { createAnswerStream } from '../answer-stream.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* #656 — gateway turn_end no-reply path.
|
|
@@ -30,7 +30,6 @@ async function flushMicrotasks(times = 10): Promise<void> {
|
|
|
30
30
|
let nextMessageId = 5000
|
|
31
31
|
|
|
32
32
|
beforeEach(() => {
|
|
33
|
-
__resetDraftIdForTests()
|
|
34
33
|
nextMessageId = 5000
|
|
35
34
|
vi.useFakeTimers()
|
|
36
35
|
})
|
|
@@ -46,9 +45,7 @@ describe('#656 — answer-stream retract() at turn_end emits nothing', () => {
|
|
|
46
45
|
const deleteMessage = vi.fn(async () => {})
|
|
47
46
|
|
|
48
47
|
const stream = createAnswerStream({
|
|
49
|
-
chatId: 'chat-no-reply',
|
|
50
|
-
isPrivateChat: false,
|
|
51
|
-
minInitialChars: 400,
|
|
48
|
+
chatId: 'chat-no-reply', minInitialChars: 400,
|
|
52
49
|
throttleMs: 250,
|
|
53
50
|
sendMessage: sendMessage as never,
|
|
54
51
|
editMessageText: editMessageText as never,
|
|
@@ -81,9 +78,7 @@ describe('#656 — answer-stream retract() at turn_end emits nothing', () => {
|
|
|
81
78
|
const deleteMessage = vi.fn(async () => {})
|
|
82
79
|
|
|
83
80
|
const stream = createAnswerStream({
|
|
84
|
-
chatId: 'supergroup-topic',
|
|
85
|
-
isPrivateChat: false, // supergroup → message transport (no draft)
|
|
86
|
-
threadId: 4,
|
|
81
|
+
chatId: 'supergroup-topic', threadId: 4,
|
|
87
82
|
minInitialChars: Number.MAX_SAFE_INTEGER,
|
|
88
83
|
throttleMs: 250,
|
|
89
84
|
sendMessage: sendMessage as never,
|
|
@@ -110,9 +105,7 @@ describe('#656 — answer-stream retract() at turn_end emits nothing', () => {
|
|
|
110
105
|
const deleteMessage = vi.fn(async () => {})
|
|
111
106
|
|
|
112
107
|
const stream = createAnswerStream({
|
|
113
|
-
chatId: 'chat-no-reply',
|
|
114
|
-
isPrivateChat: false,
|
|
115
|
-
minInitialChars: 10,
|
|
108
|
+
chatId: 'chat-no-reply', minInitialChars: 10,
|
|
116
109
|
throttleMs: THROTTLE,
|
|
117
110
|
sendMessage: sendMessage as never,
|
|
118
111
|
editMessageText: editMessageText as never,
|
|
@@ -444,6 +444,66 @@ describe('hasOutboundDeliveredSince', () => {
|
|
|
444
444
|
it('returns false when no history is present for the chat', () => {
|
|
445
445
|
expect(hasOutboundDeliveredSince('-999', 0)).toBe(false)
|
|
446
446
|
})
|
|
447
|
+
|
|
448
|
+
// #2474 follow-up — the duplicate-represent guard passes a LOW minChars so a
|
|
449
|
+
// terse-but-genuine reply counts as "the user was answered". The escalate
|
|
450
|
+
// branch keeps the 200-char default.
|
|
451
|
+
describe('minChars parameter (decoupled represent-guard threshold)', () => {
|
|
452
|
+
it('default threshold (200) does NOT count a terse real reply', () => {
|
|
453
|
+
const openedAt = 1_000_000 * 1000
|
|
454
|
+
recordOutbound({
|
|
455
|
+
chat_id: '-100',
|
|
456
|
+
thread_id: null,
|
|
457
|
+
message_ids: [10],
|
|
458
|
+
texts: ['Yes — done.'], // < 200 chars
|
|
459
|
+
ts: 1_000_001,
|
|
460
|
+
})
|
|
461
|
+
// escalate-branch behavior is unchanged: a terse reply is NOT substantive
|
|
462
|
+
expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(false)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('minChars=1 DOES count a terse real reply (fixes the #2472 terse-reply gap)', () => {
|
|
466
|
+
const openedAt = 1_000_000 * 1000
|
|
467
|
+
recordOutbound({
|
|
468
|
+
chat_id: '-100',
|
|
469
|
+
thread_id: null,
|
|
470
|
+
message_ids: [10],
|
|
471
|
+
texts: ['Merged, all three landed.'], // genuine short reply
|
|
472
|
+
ts: 1_000_001,
|
|
473
|
+
})
|
|
474
|
+
// represent-guard threshold: any real reply suppresses the duplicate
|
|
475
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, undefined, 1)).toBe(true)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('minChars=1 still does NOT count an empty/whitespace-only row', () => {
|
|
479
|
+
// A degenerate outbound (no real content) must never read as "answered",
|
|
480
|
+
// even at the lowest threshold — minChars is clamped to >= 1.
|
|
481
|
+
const openedAt = 1_000_000 * 1000
|
|
482
|
+
recordOutbound({
|
|
483
|
+
chat_id: '-100',
|
|
484
|
+
thread_id: null,
|
|
485
|
+
message_ids: [10],
|
|
486
|
+
texts: [''],
|
|
487
|
+
ts: 1_000_001,
|
|
488
|
+
})
|
|
489
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, undefined, 1)).toBe(false)
|
|
490
|
+
// minChars=0 is clamped up to 1, so an empty row is still excluded
|
|
491
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, undefined, 0)).toBe(false)
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('minChars=1 respects the thread filter (terse reply scoped to its thread)', () => {
|
|
495
|
+
const openedAt = 1_000_000 * 1000
|
|
496
|
+
recordOutbound({
|
|
497
|
+
chat_id: '-100',
|
|
498
|
+
thread_id: 5,
|
|
499
|
+
message_ids: [10],
|
|
500
|
+
texts: ['ok'],
|
|
501
|
+
ts: 1_000_001,
|
|
502
|
+
})
|
|
503
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, 5, 1)).toBe(true)
|
|
504
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, 6, 1)).toBe(false)
|
|
505
|
+
})
|
|
506
|
+
})
|
|
447
507
|
})
|
|
448
508
|
|
|
449
509
|
describe('secret redaction at persistence (both directions)', () => {
|
|
@@ -253,7 +253,10 @@ import {
|
|
|
253
253
|
handleModelMenuCallback,
|
|
254
254
|
modelSelectCallbackData,
|
|
255
255
|
sessionModelFromConfirmation,
|
|
256
|
+
classifyDiscoveredOptions,
|
|
256
257
|
MODEL_CALLBACK_REFRESH,
|
|
258
|
+
MODEL_CALLBACK_SR,
|
|
259
|
+
SR_MODEL_LABELS,
|
|
257
260
|
type ModelMenuDeps,
|
|
258
261
|
} from "../gateway/model-command.js";
|
|
259
262
|
import { labelTag } from "../../src/agents/model-picker.js";
|
|
@@ -422,3 +425,134 @@ describe("sessionModelFromConfirmation", () => {
|
|
|
422
425
|
expect(out.reply.keyboard).toBeDefined();
|
|
423
426
|
});
|
|
424
427
|
});
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// Ship D — sr-* (LiteLLM non-Anthropic) model support
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
const OPTIONS_WITH_SR = [
|
|
434
|
+
{ index: 1, label: "Default (recommended)", detail: "Opus 4.8 with 1M context", current: false },
|
|
435
|
+
{ index: 2, label: "Sonnet", detail: "Sonnet 4.6", current: true },
|
|
436
|
+
{ index: 3, label: "sr-gemini-2.5-pro", detail: "", current: false },
|
|
437
|
+
{ index: 4, label: "sr-deepseek-r1", detail: "", current: false },
|
|
438
|
+
// internal path — should be filtered out
|
|
439
|
+
{ index: 5, label: "openrouter/google/gemini-2.5-pro", detail: "", current: false },
|
|
440
|
+
// bare OpenAI models from GATEWAY_MODEL_DISCOVERY — should also be filtered out
|
|
441
|
+
{ index: 6, label: "gpt-4", detail: "", current: false },
|
|
442
|
+
{ index: 7, label: "gpt-4o", detail: "", current: false },
|
|
443
|
+
{ index: 8, label: "voyage-law-2", detail: "", current: false },
|
|
444
|
+
// full claude ID — should be in claude bucket
|
|
445
|
+
{ index: 9, label: "claude-opus-4-8", detail: "", current: false },
|
|
446
|
+
];
|
|
447
|
+
|
|
448
|
+
describe("classifyDiscoveredOptions", () => {
|
|
449
|
+
it("puts native Claude options in claude, sr-* in sr, drops others", () => {
|
|
450
|
+
const { claude, sr } = classifyDiscoveredOptions(OPTIONS_WITH_SR);
|
|
451
|
+
expect(claude.map((o) => o.label)).toEqual([
|
|
452
|
+
"Default (recommended)", "Sonnet", "claude-opus-4-8",
|
|
453
|
+
]);
|
|
454
|
+
expect(sr.map((o) => o.label)).toEqual(["sr-gemini-2.5-pro", "sr-deepseek-r1"]);
|
|
455
|
+
// openrouter/*, gpt-*, voyage-* not present in either bucket
|
|
456
|
+
const all = [...claude, ...sr];
|
|
457
|
+
expect(all.find((o) => o.label.includes("openrouter"))).toBeUndefined();
|
|
458
|
+
expect(all.find((o) => o.label.startsWith("gpt-"))).toBeUndefined();
|
|
459
|
+
expect(all.find((o) => o.label.startsWith("voyage-"))).toBeUndefined();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("handles a list with no sr-* models", () => {
|
|
463
|
+
const { claude, sr } = classifyDiscoveredOptions(OPTIONS);
|
|
464
|
+
expect(claude).toHaveLength(3);
|
|
465
|
+
expect(sr).toHaveLength(0);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
describe("SR_MODEL_LABELS", () => {
|
|
470
|
+
it("has friendly names for the standard sr-* models", () => {
|
|
471
|
+
expect(SR_MODEL_LABELS["sr-gemini-2.5-pro"]).toBe("Gemini 2.5 Pro");
|
|
472
|
+
expect(SR_MODEL_LABELS["sr-deepseek-r1"]).toBe("DeepSeek R1");
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe("buildModelMenu — with sr-* models", () => {
|
|
477
|
+
function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
|
|
478
|
+
return makeMenuDeps({
|
|
479
|
+
discover: async () => ({
|
|
480
|
+
ok: true as const,
|
|
481
|
+
options: OPTIONS_WITH_SR,
|
|
482
|
+
currentLabel: "Sonnet",
|
|
483
|
+
}),
|
|
484
|
+
...overrides,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
it("shows 🌐 buttons for sr-* models, normal buttons for claude models", async () => {
|
|
489
|
+
const { deps } = makeMenuDepsWithSr();
|
|
490
|
+
const menu = await buildModelMenu(deps);
|
|
491
|
+
expect(menu.keyboard).toBeDefined();
|
|
492
|
+
const allButtons = menu.keyboard!.flat();
|
|
493
|
+
// 🌐 buttons for sr-*
|
|
494
|
+
expect(allButtons.find((b) => b.text === "🌐 Gemini 2.5 Pro")).toBeDefined();
|
|
495
|
+
expect(allButtons.find((b) => b.text === "🌐 DeepSeek R1")).toBeDefined();
|
|
496
|
+
// Regular buttons for Claude models
|
|
497
|
+
expect(allButtons.find((b) => b.text === "Default (recommended)")).toBeDefined();
|
|
498
|
+
// openrouter/* not shown at all
|
|
499
|
+
expect(allButtons.find((b) => b.text.includes("openrouter"))).toBeUndefined();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("sr-* buttons use mdl:sr: callback prefix", async () => {
|
|
503
|
+
const { deps } = makeMenuDepsWithSr();
|
|
504
|
+
const menu = await buildModelMenu(deps);
|
|
505
|
+
const srButton = menu.keyboard!.flat().find((b) => b.text === "🌐 Gemini 2.5 Pro");
|
|
506
|
+
expect(srButton?.callback_data).toBe(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("shows 🌐 = non-Anthropic legend when sr-* models are present", async () => {
|
|
510
|
+
const { deps } = makeMenuDepsWithSr();
|
|
511
|
+
const menu = await buildModelMenu(deps);
|
|
512
|
+
expect(menu.text).toContain("🌐 = non-Anthropic");
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("no legend when no sr-* models in picker", async () => {
|
|
516
|
+
const { deps } = makeMenuDeps();
|
|
517
|
+
const menu = await buildModelMenu(deps);
|
|
518
|
+
expect(menu.text).not.toContain("🌐 = non-Anthropic");
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
describe("handleModelMenuCallback — sr-* selection", () => {
|
|
523
|
+
function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
|
|
524
|
+
return makeMenuDeps({
|
|
525
|
+
discover: async () => ({
|
|
526
|
+
ok: true as const,
|
|
527
|
+
options: OPTIONS_WITH_SR,
|
|
528
|
+
currentLabel: "Sonnet",
|
|
529
|
+
}),
|
|
530
|
+
...overrides,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
it("sr-* tap uses inject path, not cursor nav", async () => {
|
|
535
|
+
const { deps, calls, injectCalls } = makeMenuDepsWithSr();
|
|
536
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
|
|
537
|
+
// inject was called with the raw /model command
|
|
538
|
+
expect(injectCalls).toContainEqual({ agent: "klanker", command: "/model sr-gemini-2.5-pro" });
|
|
539
|
+
// select (cursor nav) was NOT called
|
|
540
|
+
expect(calls.select).toHaveLength(0);
|
|
541
|
+
expect(out.answer).toContain("Set model to sonnet");
|
|
542
|
+
expect(out.selectedModel).toBe("sr-gemini-2.5-pro");
|
|
543
|
+
expect(out.reply.keyboard).toBeDefined();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("sr-* tap while busy returns toast-only with no inject", async () => {
|
|
547
|
+
const { deps, injectCalls } = makeMenuDepsWithSr({ isBusy: () => true });
|
|
548
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
|
|
549
|
+
expect(out.toastOnly).toBe(true);
|
|
550
|
+
expect(injectCalls).toHaveLength(0);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("rejects malformed sr-* callback data", async () => {
|
|
554
|
+
const { deps } = makeMenuDepsWithSr();
|
|
555
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}bad name with spaces`, deps);
|
|
556
|
+
expect(out.answer).toBe("Invalid model name");
|
|
557
|
+
});
|
|
558
|
+
});
|