switchroom 0.18.28 → 0.18.29
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/bin/handoff-briefing.sh +8 -1
- package/dist/auth-broker/index.js +0 -57
- package/dist/cli/switchroom.js +501 -497
- package/dist/host-control/main.js +1 -58
- package/dist/vault/approvals/kernel-server.js +0 -57
- package/dist/vault/broker/server.js +0 -57
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +37 -19
- package/telegram-plugin/dist/gateway/gateway.js +578 -580
- package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
- package/telegram-plugin/gateway/forward-origin.ts +9 -1
- package/telegram-plugin/gateway/gateway.ts +458 -385
- package/telegram-plugin/gateway/model-command.ts +227 -602
- package/telegram-plugin/gateway/turn-record-status.ts +45 -0
- package/telegram-plugin/history.ts +153 -23
- package/telegram-plugin/shared/local-time.ts +56 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
- package/telegram-plugin/tests/forward-origin.test.ts +30 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +86 -59
- package/telegram-plugin/tests/history.test.ts +88 -0
- package/telegram-plugin/tests/local-time.test.ts +68 -0
- package/telegram-plugin/tests/model-command.test.ts +317 -1535
- package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
- package/telegram-plugin/tier-downgrade.ts +4 -3
- package/telegram-plugin/turn-flush-safety.ts +25 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +40 -6
- package/vendor/hindsight-memory/tests/test_content.py +28 -7
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `/model` Telegram command — parser + handler coverage
|
|
2
|
+
* `/model` Telegram command — parser + handler coverage (rev 5, DETERMINISTIC
|
|
3
|
+
* SWITCH). This suite encodes the CURRENT contract after the inject-into-tmux +
|
|
4
|
+
* terminal-scrape path was RETIRED (reference/rfcs/session-model-stickiness.md
|
|
5
|
+
* §0.05):
|
|
3
6
|
*
|
|
4
|
-
* The
|
|
7
|
+
* 1. The bare `/model` form renders the dashboard; it never applies a switch.
|
|
8
|
+
* 2. The argument is shape-gated before it becomes a `claude --model` token.
|
|
9
|
+
* 3. EVERY switch — typed Claude→Claude, Claude→sr-*, sr-*→Claude, the
|
|
10
|
+
* Fable/alias button, and the picker SELECT — routes through the carrier
|
|
11
|
+
* relaunch (`scheduleModelRelaunch` / `scheduleModelDefaultRelaunch`). No
|
|
12
|
+
* inject, no cursor-nav select, no scrape, no optimistic `/status` record.
|
|
5
13
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* would wedge the pane (the /rate-limit-options class of wedge).
|
|
10
|
-
* 2. The argument is shape-gated before it's typed into the tmux
|
|
11
|
-
* pane: one token, no whitespace, no shell/control smuggling.
|
|
12
|
-
* 3. The set path injects exactly `/model <name>` (claude's own
|
|
13
|
-
* REPL verb — already on the inject allowlist) and relays the
|
|
14
|
-
* captured output, with the session-only persistence caveat.
|
|
14
|
+
* These tests FAIL on the OLD behavior on purpose: a Claude→Claude switch that
|
|
15
|
+
* called `inject`, a picker tap that called `select`, or any reply carrying an
|
|
16
|
+
* `optimistic` / scrape-derived `selectedModel` would not satisfy them.
|
|
15
17
|
*/
|
|
16
|
-
import { describe, it, expect
|
|
18
|
+
import { describe, it, expect } from "vitest";
|
|
17
19
|
import {
|
|
18
20
|
parseModelCommand,
|
|
19
21
|
planModelCommand,
|
|
@@ -29,27 +31,13 @@ import {
|
|
|
29
31
|
MODEL_ALIASES,
|
|
30
32
|
type ModelCommandDeps,
|
|
31
33
|
} from "../gateway/model-command.js";
|
|
32
|
-
import type { InjectResult } from "../../src/agents/inject.js";
|
|
33
|
-
|
|
34
|
-
function okResult(output: string): InjectResult {
|
|
35
|
-
return {
|
|
36
|
-
outcome: "ok",
|
|
37
|
-
output,
|
|
38
|
-
truncated: false,
|
|
39
|
-
command: "/model",
|
|
40
|
-
meta: { description: "Open model picker", expectsOutput: true },
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
34
|
|
|
35
|
+
/** A recorder-backed ModelCommandDeps. The inject/select deps are GONE (rev 5). */
|
|
44
36
|
function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
|
|
45
|
-
const calls: Array<{ agent: string; command: string }> = [];
|
|
46
|
-
const restartCalls: string[] = [];
|
|
47
37
|
const relaunchCalls: Array<{ model: string; reason: string }> = [];
|
|
38
|
+
const defaultRelaunchCalls: string[] = [];
|
|
39
|
+
const restartCalls: string[] = [];
|
|
48
40
|
const deps: ModelCommandDeps = {
|
|
49
|
-
inject: async (agent, command) => {
|
|
50
|
-
calls.push({ agent, command });
|
|
51
|
-
return okResult("⏺ Set model to sonnet");
|
|
52
|
-
},
|
|
53
41
|
getAgentName: () => "klanker",
|
|
54
42
|
getConfiguredModel: () => "claude-sonnet-5",
|
|
55
43
|
escapeHtml: (s) =>
|
|
@@ -59,765 +47,195 @@ function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
|
|
|
59
47
|
isBusy: () => false,
|
|
60
48
|
scheduleRestart: async (reason) => { restartCalls.push(reason); },
|
|
61
49
|
scheduleModelRelaunch: async (model, reason) => { relaunchCalls.push({ model, reason }); },
|
|
50
|
+
scheduleModelDefaultRelaunch: async (reason) => { defaultRelaunchCalls.push(reason); },
|
|
62
51
|
...overrides,
|
|
63
52
|
};
|
|
64
|
-
return { deps,
|
|
53
|
+
return { deps, relaunchCalls, defaultRelaunchCalls, restartCalls };
|
|
65
54
|
}
|
|
66
55
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
56
|
+
/** An error shaped like scheduleRestart's 15s-debounce throw. */
|
|
57
|
+
function restartInFlight(): Error {
|
|
58
|
+
const e = new Error("a restart is already in flight — try again in ~15s");
|
|
59
|
+
(e as { code?: string }).code = "restart_in_flight";
|
|
60
|
+
return e;
|
|
61
|
+
}
|
|
73
62
|
|
|
74
|
-
|
|
63
|
+
describe("parseModelCommand", () => {
|
|
64
|
+
it("bare /model → show", () => {
|
|
75
65
|
expect(parseModelCommand("/model")).toEqual({ kind: "show" });
|
|
76
|
-
expect(parseModelCommand("/model@klanker_bot")).toEqual({ kind: "show" });
|
|
77
|
-
expect(parseModelCommand("/model ")).toEqual({ kind: "show" });
|
|
78
66
|
});
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
expect(parseModelCommand("/model sonnet")).toEqual({ kind: "set", model: "sonnet" });
|
|
82
|
-
expect(parseModelCommand("/model@bot claude-opus-4-8")).toEqual({
|
|
83
|
-
kind: "set",
|
|
84
|
-
model: "claude-opus-4-8",
|
|
85
|
-
});
|
|
86
|
-
// 1m-context variant ids carry brackets
|
|
87
|
-
expect(parseModelCommand("/model claude-sonnet-5[1m]")).toEqual({
|
|
88
|
-
kind: "set",
|
|
89
|
-
model: "claude-sonnet-5[1m]",
|
|
90
|
-
});
|
|
67
|
+
it("/model opus → set opus", () => {
|
|
68
|
+
expect(parseModelCommand("/model opus")).toEqual({ kind: "set", model: "opus" });
|
|
91
69
|
});
|
|
92
|
-
|
|
93
|
-
it("/model help parses as help", () => {
|
|
70
|
+
it("/model help → help", () => {
|
|
94
71
|
expect(parseModelCommand("/model help")).toEqual({ kind: "help" });
|
|
95
72
|
});
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const p = parseModelCommand("/model sonnet; rm -rf /");
|
|
73
|
+
it("two args → help with reason", () => {
|
|
74
|
+
const p = parseModelCommand("/model opus sonnet");
|
|
99
75
|
expect(p?.kind).toBe("help");
|
|
100
76
|
});
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"/model sonnet\nEnter",
|
|
108
|
-
"/model ../../etc/passwd",
|
|
109
|
-
"/model a|b",
|
|
110
|
-
]) {
|
|
111
|
-
const p = parseModelCommand(bad);
|
|
112
|
-
expect(p?.kind, `should reject: ${bad}`).toBe("help");
|
|
113
|
-
}
|
|
77
|
+
it("invalid arg → help with reason", () => {
|
|
78
|
+
const p = parseModelCommand("/model bad name!");
|
|
79
|
+
expect(p?.kind).toBe("help");
|
|
80
|
+
});
|
|
81
|
+
it("non-/model text → null", () => {
|
|
82
|
+
expect(parseModelCommand("hello")).toBeNull();
|
|
114
83
|
});
|
|
115
84
|
});
|
|
116
85
|
|
|
117
86
|
describe("isValidModelArg", () => {
|
|
118
|
-
it("accepts aliases
|
|
119
|
-
for (const
|
|
120
|
-
expect(isValidModelArg(
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
it("accepts OpenRouter-style sr-vendor/model ids (embedded slash)", () => {
|
|
124
|
-
// `/` is a legal char in a model id — needed for OpenRouter-style
|
|
125
|
-
// `sr-vendor/model` routing. It is NOT a shell metachar inside the
|
|
126
|
-
// double-quoted `claude --model "$_EFFECTIVE_MODEL"` launch, so it is
|
|
127
|
-
// safe. Kept aligned with the shell shape gate in profiles/_base/start.sh.hbs.
|
|
128
|
-
for (const good of ["sr-openrouter/gpt-5", "sr-vendor/model", "sr-mistralai/mixtral-8x7b"]) {
|
|
129
|
-
expect(isValidModelArg(good), good).toBe(true);
|
|
87
|
+
it("accepts aliases + full ids + sr-*", () => {
|
|
88
|
+
for (const a of ["opus", "sonnet", "haiku", "fable", "claude-opus-4-8", "sr-glm-5", "sr-gpt-5.5"]) {
|
|
89
|
+
expect(isValidModelArg(a)).toBe(true);
|
|
130
90
|
}
|
|
131
91
|
});
|
|
132
|
-
it("rejects whitespace
|
|
133
|
-
for (const bad of ["
|
|
134
|
-
expect(isValidModelArg(bad)
|
|
92
|
+
it("rejects whitespace / control / shell smuggling", () => {
|
|
93
|
+
for (const bad of ["a b", "opus\n", "opus;rm", "", " "]) {
|
|
94
|
+
expect(isValidModelArg(bad)).toBe(false);
|
|
135
95
|
}
|
|
136
96
|
});
|
|
137
97
|
});
|
|
138
98
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
describe("model selection: aliases stay selectable (incl. fable)", () => {
|
|
145
|
-
it("lists fable as a first-class alias", () => {
|
|
146
|
-
// `fable` is the latest flagship (Fable 5) and must remain pickable.
|
|
147
|
-
expect(MODEL_ALIASES).toContain("fable");
|
|
148
|
-
// The standard set is intact alongside it.
|
|
149
|
-
for (const a of ["opus", "sonnet", "haiku", "default"]) {
|
|
150
|
-
expect(MODEL_ALIASES, a).toContain(a);
|
|
151
|
-
}
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("each alias is a valid model arg and parses as a set", () => {
|
|
155
|
-
for (const alias of MODEL_ALIASES) {
|
|
156
|
-
expect(isValidModelArg(alias), alias).toBe(true);
|
|
157
|
-
expect(parseModelCommand(`/model ${alias}`)).toEqual({ kind: "set", model: alias });
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("the help text surfaces the fable alias", async () => {
|
|
162
|
-
const reply = await handleModelCommand({ kind: "help" }, makeDeps());
|
|
163
|
-
expect(reply.text).toContain("fable");
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("passthrough: a full id (incl. the retired claude-fable-5 codename) is shape-accepted, not allowlisted", () => {
|
|
167
|
-
// switchroom does NOT allowlist models — the SHAPE gate passes any
|
|
168
|
-
// well-formed id through to claude, which is the sole validator. So the
|
|
169
|
-
// retired `claude-fable-5` codename still parses here (it just 4xx's at
|
|
170
|
-
// claude); selection flexibility (any current/future model) is preserved.
|
|
171
|
-
expect(parseModelCommand("/model claude-fable-5")).toEqual({
|
|
172
|
-
kind: "set",
|
|
173
|
-
model: "claude-fable-5",
|
|
174
|
-
});
|
|
175
|
-
expect(isValidModelArg("claude-fable-5")).toBe(true);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
describe("handleModelCommand — show / help never inject (picker-wedge guard)", () => {
|
|
180
|
-
it("show renders configured model + switch options without injecting", async () => {
|
|
181
|
-
const { deps, calls } = makeDeps();
|
|
182
|
-
const reply = await handleModelCommand({ kind: "show" }, deps);
|
|
183
|
-
expect(calls.length).toBe(0);
|
|
184
|
-
expect(reply.text).toContain("claude-sonnet-5");
|
|
185
|
-
expect(reply.text).toContain("/model opus");
|
|
186
|
-
expect(reply.text).toContain("switchroom.yaml");
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it("show falls back to 'default' when no model configured", async () => {
|
|
190
|
-
const { deps, calls } = makeDeps({ getConfiguredModel: () => null });
|
|
191
|
-
const reply = await handleModelCommand({ kind: "show" }, deps);
|
|
192
|
-
expect(calls.length).toBe(0);
|
|
193
|
-
expect(reply.text).toContain("`default`");
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it("help never injects", async () => {
|
|
197
|
-
const { deps, calls } = makeDeps();
|
|
198
|
-
const reply = await handleModelCommand({ kind: "help", reason: "nope" }, deps);
|
|
199
|
-
expect(calls.length).toBe(0);
|
|
200
|
-
expect(reply.text).toContain("nope");
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
describe("handleModelCommand — set — #3241 poll-until-signal wiring", () => {
|
|
205
|
-
it("forwards successPattern + errorPattern + settleBeforeSendMs to the inject primitive", async () => {
|
|
206
|
-
const seen: Array<{ command: string; opts: unknown }> = [];
|
|
207
|
-
const { deps } = makeDeps({
|
|
208
|
-
inject: async (_agent, command, opts) => {
|
|
209
|
-
seen.push({ command, opts });
|
|
210
|
-
return okResult("⏺ Set model to Sonnet 5 for this session");
|
|
211
|
-
},
|
|
212
|
-
});
|
|
213
|
-
await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
|
|
214
|
-
expect(seen).toHaveLength(1);
|
|
215
|
-
const opts = seen[0].opts as {
|
|
216
|
-
successPattern?: RegExp;
|
|
217
|
-
errorPattern?: RegExp;
|
|
218
|
-
settleBeforeSendMs?: number;
|
|
219
|
-
};
|
|
220
|
-
// A success pattern that matches claude's confirmation line, an error pattern
|
|
221
|
-
// that matches the "not found" line, and a clean-prompt pre-send wait.
|
|
222
|
-
expect(opts.successPattern?.test("⏺ Set model to Sonnet 5")).toBe(true);
|
|
223
|
-
expect(opts.errorPattern?.test("⎿ Model 'x' not found")).toBe(true);
|
|
224
|
-
expect(typeof opts.settleBeforeSendMs).toBe("number");
|
|
225
|
-
expect(opts.settleBeforeSendMs).toBeGreaterThan(0);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it("confirmation that lands after a banner → records the live model for /status", async () => {
|
|
229
|
-
// The inject primitive (poll-until-signal) is responsible for returning the
|
|
230
|
-
// confirmation and not the banner; here the handler receives that confirmation
|
|
231
|
-
// and must record it as the session override.
|
|
232
|
-
const { deps } = makeDeps({
|
|
233
|
-
inject: async () =>
|
|
234
|
-
okResult("⠋ extending Claude Fable 5 access…\n⎿ Set model to Fable 5 for this session"),
|
|
235
|
-
});
|
|
236
|
-
const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
|
|
237
|
-
expect(reply.selectedModel).toBe("Fable 5");
|
|
238
|
-
expect(reply.optimistic).toBeUndefined();
|
|
239
|
-
expect(reply.text).toContain("Set model to Fable 5");
|
|
240
|
-
// The banner is scrollback and must not leak as prose above the confirmation.
|
|
241
|
-
expect(reply.text).not.toContain("extending Claude Fable 5 access");
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it("scraped error line → failure verdict AND no override recorded (retract)", async () => {
|
|
245
|
-
const { deps } = makeDeps({
|
|
246
|
-
inject: async () => okResult("⎿ Model 'claude-bogus-99' not found"),
|
|
247
|
-
});
|
|
248
|
-
const reply = await handleModelCommand({ kind: "set", model: "claude-bogus-99" }, deps);
|
|
249
|
-
expect(reply.text).toContain("did not take");
|
|
250
|
-
expect(reply.selectedModel).toBeUndefined();
|
|
251
|
-
expect(reply.optimistic).toBeUndefined();
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
// #3242 review MEDIUM 1 — an access/entitlement denial must NOT be recorded
|
|
255
|
-
// optimistically. Each of these lines matches neither the confirmation prefix
|
|
256
|
-
// nor the OLD bad-id error regex, so before the widen they slipped into the
|
|
257
|
-
// optimistic branch and falsely recorded the switch.
|
|
258
|
-
for (const denial of [
|
|
259
|
-
"⎿ Fable is not available on your plan",
|
|
260
|
-
"⎿ access denied",
|
|
261
|
-
"⎿ Fable requires a Pro subscription",
|
|
262
|
-
"⎿ This model is not enabled for your account",
|
|
263
|
-
"⎿ No access to Fable on this tier",
|
|
264
|
-
"⎿ Fable 5 is currently unavailable",
|
|
265
|
-
]) {
|
|
266
|
-
it(`access-denial line → failure verdict AND no override (retract): ${JSON.stringify(denial)}`, async () => {
|
|
267
|
-
const { deps } = makeDeps({ inject: async () => okResult(denial) });
|
|
268
|
-
const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
|
|
269
|
-
expect(reply.text).toContain("did not take");
|
|
270
|
-
expect(reply.selectedModel).toBeUndefined();
|
|
271
|
-
expect(reply.optimistic).toBeUndefined();
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
it("a genuine confirmation is NEVER flipped to a failure by the widened denial regex", async () => {
|
|
276
|
-
// Confirmation-first ordering: even if scrollback in the same region says
|
|
277
|
-
// something "unavailable", a real "Set model to …" line wins.
|
|
278
|
-
const mixed = [
|
|
279
|
-
"the metrics endpoint was unavailable earlier",
|
|
280
|
-
"⎿ Set model to Fable 5 for this session",
|
|
281
|
-
].join("\n");
|
|
282
|
-
const { deps } = makeDeps({ inject: async () => okResult(mixed) });
|
|
283
|
-
const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
|
|
284
|
-
expect(reply.text).not.toContain("did not take");
|
|
285
|
-
expect(reply.selectedModel).toBe("Fable 5");
|
|
286
|
-
});
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
describe("handleModelCommand — set", () => {
|
|
290
|
-
it("injects exactly `/model <name>` once and relays a genuine confirmation + persistence note", async () => {
|
|
291
|
-
const { deps, calls } = makeDeps();
|
|
292
|
-
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
293
|
-
expect(calls).toEqual([{ agent: "klanker", command: "/model opus" }]);
|
|
294
|
-
expect(reply.text).toContain("<pre>⏺ Set model to sonnet</pre>");
|
|
295
|
-
expect(reply.text).toContain("lasts until the agent’s next restart");
|
|
296
|
-
expect(reply.html).toBe(true);
|
|
297
|
-
// A verified confirmation records the live model so /status stays honest
|
|
298
|
-
// (bug 1: the typed path never recorded the switch before).
|
|
299
|
-
expect(reply.selectedModel).toBe("sonnet");
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it("SILENT switch: suppresses raw pane scrollback instead of dumping it as a code block", async () => {
|
|
303
|
-
// claude switches models silently, so the pane capture below the command
|
|
304
|
-
// echo is just the agent's previous prose answer. It must NOT be relayed.
|
|
305
|
-
const scrollback = [
|
|
306
|
-
"Here's the summary you asked for earlier:",
|
|
307
|
-
"- point one about the deploy",
|
|
308
|
-
"- point two about the rollback plan",
|
|
309
|
-
].join("\n");
|
|
310
|
-
const { deps } = makeDeps({ inject: async () => okResult(scrollback) });
|
|
311
|
-
const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
|
|
312
|
-
// The leak: none of the scrollback prose reaches the reply, and there is
|
|
313
|
-
// no <pre> code block echoing the capture.
|
|
314
|
-
expect(reply.text).not.toContain("summary you asked for");
|
|
315
|
-
expect(reply.text).not.toContain("rollback plan");
|
|
316
|
-
expect(reply.text).not.toContain("<pre>");
|
|
317
|
-
// #3241 part B — poll-until-signal already waited the full window, so a
|
|
318
|
-
// missing confirmation line with NO scraped error is a SILENT switch, not a
|
|
319
|
-
// failure. Record the requested model optimistically so /status is right,
|
|
320
|
-
// and say so honestly (no false "switched (session)", no scrollback leak).
|
|
321
|
-
expect(reply.text).toContain("/model fable");
|
|
322
|
-
expect(reply.text).toContain("couldn't read a confirmation");
|
|
323
|
-
expect(reply.text).toContain("/status");
|
|
324
|
-
expect(reply.text).not.toContain("Recorded");
|
|
325
|
-
expect(reply.text).not.toContain("switched (session)");
|
|
326
|
-
expect(reply.selectedModel).toBe("Fable");
|
|
327
|
-
expect(reply.optimistic).toBe(true);
|
|
328
|
-
expect(reply.html).toBe(true);
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
it("relays only the confirmation line when the capture also carries scrollback", async () => {
|
|
332
|
-
const mixed = [
|
|
333
|
-
"Some earlier prose that must not leak",
|
|
334
|
-
"⏺ Set model to Fable 5 for this session",
|
|
335
|
-
].join("\n");
|
|
336
|
-
const { deps } = makeDeps({ inject: async () => okResult(mixed) });
|
|
337
|
-
const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
|
|
338
|
-
expect(reply.text).toContain("<pre>⏺ Set model to Fable 5 for this session</pre>");
|
|
339
|
-
expect(reply.text).not.toContain("earlier prose that must not leak");
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
it("does NOT relay scrollback prose that merely contains 'switched'/'set model' as ordinary words", async () => {
|
|
343
|
-
// No line begins with claude's real confirmation phrasing — these are just
|
|
344
|
-
// English sentences that happen to use the words. None must be relayed.
|
|
345
|
-
const prose = [
|
|
346
|
-
"I switched the deploy to blue-green as we discussed.",
|
|
347
|
-
"Then I set model behaviour aside and moved on to the tests.",
|
|
348
|
-
"The team kept model changes out of this release entirely.",
|
|
349
|
-
].join("\n");
|
|
350
|
-
const { deps } = makeDeps({ inject: async () => okResult(prose) });
|
|
351
|
-
const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
|
|
352
|
-
// The anchored regex rejects all three lines, so nothing leaks and there is
|
|
353
|
-
// no <pre> block. No confirmation AND no scraped error → #3241 optimistic
|
|
354
|
-
// record: the switch is reported as sent + recorded, never a false
|
|
355
|
-
// "switched", and no scrollback prose leaks.
|
|
356
|
-
expect(reply.text).not.toContain("<pre>");
|
|
357
|
-
expect(reply.text).not.toContain("switched the deploy");
|
|
358
|
-
expect(reply.text).not.toContain("set model behaviour");
|
|
359
|
-
expect(reply.text).not.toContain("kept model changes");
|
|
360
|
-
expect(reply.text).toContain("couldn't read a confirmation");
|
|
361
|
-
expect(reply.text).not.toContain("switched (session)");
|
|
362
|
-
expect(reply.selectedModel).toBe("Fable");
|
|
363
|
-
expect(reply.optimistic).toBe(true);
|
|
364
|
-
expect(reply.html).toBe(true);
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
it("re-gates the model arg at the seam (caller bypassing the parser)", async () => {
|
|
368
|
-
const { deps, calls } = makeDeps();
|
|
369
|
-
const reply = await handleModelCommand({ kind: "set", model: "a b; reboot" }, deps);
|
|
370
|
-
expect(calls.length).toBe(0);
|
|
371
|
-
expect(reply.text).toContain("not a valid model name");
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
it("ok_no_output explains the empty capture", async () => {
|
|
375
|
-
const { deps } = makeDeps({
|
|
376
|
-
inject: async () => ({
|
|
377
|
-
outcome: "ok_no_output",
|
|
378
|
-
output: "",
|
|
379
|
-
truncated: false,
|
|
380
|
-
command: "/model",
|
|
381
|
-
meta: { description: "Open model picker", expectsOutput: true },
|
|
382
|
-
}),
|
|
383
|
-
});
|
|
384
|
-
const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
|
|
385
|
-
// #3241 part B — an empty capture can't carry an error line, so the send is
|
|
386
|
-
// treated as a silent switch and the requested model is recorded
|
|
387
|
-
// optimistically (was: "no response captured", recorded nothing).
|
|
388
|
-
expect(reply.text).toContain("couldn't read a confirmation");
|
|
389
|
-
expect(reply.text).toContain("/status");
|
|
390
|
-
expect(reply.selectedModel).toBe("Sonnet");
|
|
391
|
-
expect(reply.optimistic).toBe(true);
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
it("session_missing failure surfaces the tmux-supervisor hint", async () => {
|
|
395
|
-
const { deps } = makeDeps({
|
|
396
|
-
inject: async () => ({
|
|
397
|
-
outcome: "failed",
|
|
398
|
-
output: "",
|
|
399
|
-
truncated: false,
|
|
400
|
-
command: "/model",
|
|
401
|
-
meta: null,
|
|
402
|
-
errorCode: "session_missing",
|
|
403
|
-
errorMessage: "tmux session not found",
|
|
404
|
-
}),
|
|
405
|
-
});
|
|
406
|
-
const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
|
|
407
|
-
expect(reply.text).toContain("tmux session not found");
|
|
408
|
-
expect(reply.text).toContain("tmux supervisor");
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
it("inject throwing is surfaced, not propagated", async () => {
|
|
412
|
-
const { deps } = makeDeps({
|
|
413
|
-
inject: async () => {
|
|
414
|
-
throw new Error("boom");
|
|
415
|
-
},
|
|
416
|
-
});
|
|
417
|
-
const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
|
|
418
|
-
expect(reply.text).toContain("boom");
|
|
419
|
-
});
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
describe("isSrModel / isClaudeModel helpers", () => {
|
|
423
|
-
it("isSrModel is true only for sr-* names", () => {
|
|
424
|
-
expect(isSrModel("sr-gemini-2.5-pro")).toBe(true);
|
|
425
|
-
expect(isSrModel("sr-deepseek-r1")).toBe(true);
|
|
426
|
-
expect(isSrModel("claude-sonnet-5")).toBe(false);
|
|
427
|
-
expect(isSrModel("sonnet")).toBe(false);
|
|
428
|
-
expect(isSrModel("")).toBe(false);
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
it("isClaudeModel is true for aliases and claude-* ids", () => {
|
|
432
|
-
for (const alias of MODEL_ALIASES) {
|
|
433
|
-
expect(isClaudeModel(alias), alias).toBe(true);
|
|
434
|
-
}
|
|
99
|
+
describe("isSrModel / isClaudeModel", () => {
|
|
100
|
+
it("classifies", () => {
|
|
101
|
+
expect(isSrModel("sr-glm-5")).toBe(true);
|
|
102
|
+
expect(isSrModel("opus")).toBe(false);
|
|
103
|
+
expect(isClaudeModel("opus")).toBe(true);
|
|
435
104
|
expect(isClaudeModel("claude-opus-4-8")).toBe(true);
|
|
436
|
-
expect(isClaudeModel("
|
|
437
|
-
expect(isClaudeModel("sr-
|
|
438
|
-
expect(isClaudeModel("gpt-4")).toBe(false);
|
|
105
|
+
expect(isClaudeModel("fable")).toBe(true);
|
|
106
|
+
expect(isClaudeModel("sr-glm-5")).toBe(false);
|
|
439
107
|
});
|
|
440
108
|
});
|
|
441
109
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
110
|
+
// ─── The headline rev-5 contract: every switch relaunches, nothing injects ───
|
|
111
|
+
|
|
112
|
+
describe("handleModelCommand — set — deterministic carrier relaunch (rev 5)", () => {
|
|
113
|
+
it("Claude→Claude `/model opus` (idle) calls scheduleModelRelaunch('opus') EXACTLY ONCE", async () => {
|
|
114
|
+
// FAILS on old code: Claude→Claude went through the inject+scrape path and
|
|
115
|
+
// NEVER called scheduleModelRelaunch.
|
|
116
|
+
const { deps, relaunchCalls, defaultRelaunchCalls } = makeDeps();
|
|
447
117
|
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
448
|
-
// Must NOT inject
|
|
449
|
-
expect(calls).toHaveLength(0);
|
|
450
|
-
// sr→Claude now rides the SAME carrier mechanism as Claude→sr so the
|
|
451
|
-
// requested Claude model survives the restart (bug 2). The bespoke
|
|
452
|
-
// scheduleRestart-without-carrier path is gone.
|
|
453
|
-
expect(restartCalls).toHaveLength(0);
|
|
454
118
|
expect(relaunchCalls).toHaveLength(1);
|
|
455
119
|
expect(relaunchCalls[0].model).toBe("opus");
|
|
456
|
-
expect(
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
expect(reply.
|
|
460
|
-
expect(reply.
|
|
120
|
+
expect(defaultRelaunchCalls).toHaveLength(0);
|
|
121
|
+
expect(reply.text).toContain("relaunching the session");
|
|
122
|
+
// No optimistic / scrape-derived fields on the reply (the retired-path guard).
|
|
123
|
+
expect((reply as Record<string, unknown>).selectedModel).toBeUndefined();
|
|
124
|
+
expect((reply as Record<string, unknown>).optimistic).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("Claude→Claude with a full id relaunches on that id", async () => {
|
|
128
|
+
const { deps, relaunchCalls } = makeDeps();
|
|
129
|
+
await handleModelCommand({ kind: "set", model: "claude-opus-4-8" }, deps);
|
|
130
|
+
expect(relaunchCalls).toEqual([
|
|
131
|
+
expect.objectContaining({ model: "claude-opus-4-8" }),
|
|
132
|
+
]);
|
|
461
133
|
});
|
|
462
134
|
|
|
463
|
-
it("
|
|
464
|
-
const { deps,
|
|
465
|
-
|
|
466
|
-
});
|
|
467
|
-
const reply = await handleModelCommand({ kind: "set", model: "claude-opus-4-8" }, deps);
|
|
468
|
-
expect(calls).toHaveLength(0);
|
|
469
|
-
expect(restartCalls).toHaveLength(0);
|
|
470
|
-
expect(relaunchCalls).toHaveLength(1);
|
|
471
|
-
expect(relaunchCalls[0].model).toBe("claude-opus-4-8");
|
|
472
|
-
expect(reply.text).toContain("sr-deepseek-r1");
|
|
473
|
-
expect(reply.text).toContain("30s");
|
|
135
|
+
it("sr-* typed `/model sr-glm-5` relaunches once (regression guard)", async () => {
|
|
136
|
+
const { deps, relaunchCalls } = makeDeps();
|
|
137
|
+
await handleModelCommand({ kind: "set", model: "sr-glm-5" }, deps);
|
|
138
|
+
expect(relaunchCalls).toEqual([expect.objectContaining({ model: "sr-glm-5" })]);
|
|
474
139
|
});
|
|
475
140
|
|
|
476
|
-
it("
|
|
477
|
-
const { deps,
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
|
|
481
|
-
// Normal inject path: still injects, no restart, no relaunch
|
|
482
|
-
expect(calls).toHaveLength(1);
|
|
483
|
-
expect(restartCalls).toHaveLength(0);
|
|
484
|
-
expect(relaunchCalls).toHaveLength(0);
|
|
485
|
-
expect(reply.text).toContain("Set model to sonnet");
|
|
141
|
+
it("non-Claude token passes the RAW token through (accepted, not picker-validated)", async () => {
|
|
142
|
+
const { deps, relaunchCalls } = makeDeps();
|
|
143
|
+
await handleModelCommand({ kind: "set", model: "sr-gpt-5.5" }, deps);
|
|
144
|
+
expect(relaunchCalls[0].model).toBe("sr-gpt-5.5");
|
|
486
145
|
});
|
|
487
146
|
|
|
488
|
-
it("
|
|
489
|
-
const { deps,
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
});
|
|
493
|
-
const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
|
|
494
|
-
expect(calls).toHaveLength(0);
|
|
495
|
-
expect(reply.text).toContain("Could not schedule restart");
|
|
496
|
-
expect(reply.text).toContain("hostd unreachable");
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
it("reports 'restart already in flight' honestly on a debounced dispatch (no silent no-op)", async () => {
|
|
500
|
-
const { deps } = makeDeps({
|
|
501
|
-
getActiveSessionModel: () => "sr-deepseek-r1",
|
|
502
|
-
scheduleModelRelaunch: async () => {
|
|
503
|
-
const e = new Error("a restart is already in flight — try again in ~15s");
|
|
504
|
-
(e as { code?: string }).code = "restart_in_flight";
|
|
505
|
-
throw e;
|
|
506
|
-
},
|
|
507
|
-
});
|
|
508
|
-
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
509
|
-
expect(reply.text).toContain("restart is already in flight");
|
|
510
|
-
expect(reply.text).toContain("15s");
|
|
511
|
-
// Never a false success.
|
|
512
|
-
expect(reply.text).not.toContain("Set model to");
|
|
147
|
+
it("short alias expands before relaunch (`flash` → `sr-gemini-2.5-flash`)", async () => {
|
|
148
|
+
const { deps, relaunchCalls } = makeDeps();
|
|
149
|
+
await handleModelCommand({ kind: "set", model: "flash" }, deps);
|
|
150
|
+
expect(relaunchCalls[0].model).toBe("sr-gemini-2.5-flash");
|
|
513
151
|
});
|
|
514
152
|
|
|
515
|
-
it("
|
|
516
|
-
const { deps,
|
|
517
|
-
getActiveSessionModel: () => null,
|
|
518
|
-
});
|
|
153
|
+
it("sr-*→Claude relaunches on the Claude token (no special inject path)", async () => {
|
|
154
|
+
const { deps, relaunchCalls } = makeDeps({ getActiveSessionModel: () => "sr-deepseek-v3" });
|
|
519
155
|
await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
520
|
-
expect(
|
|
521
|
-
expect(restartCalls).toHaveLength(0);
|
|
522
|
-
expect(relaunchCalls).toHaveLength(0);
|
|
156
|
+
expect(relaunchCalls).toEqual([expect.objectContaining({ model: "opus" })]);
|
|
523
157
|
});
|
|
524
|
-
});
|
|
525
158
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
expect(calls).toHaveLength(0);
|
|
159
|
+
it("`/model default` calls scheduleModelDefaultRelaunch, NOT scheduleModelRelaunch", async () => {
|
|
160
|
+
const { deps, relaunchCalls, defaultRelaunchCalls } = makeDeps();
|
|
161
|
+
const reply = await handleModelCommand({ kind: "set", model: "default" }, deps);
|
|
162
|
+
expect(defaultRelaunchCalls).toHaveLength(1);
|
|
531
163
|
expect(relaunchCalls).toHaveLength(0);
|
|
532
|
-
expect(reply.text).toContain("
|
|
533
|
-
expect(reply.selectedModel).toBeUndefined();
|
|
164
|
+
expect(reply.text).toContain("Reverting to the configured default");
|
|
534
165
|
});
|
|
535
166
|
|
|
536
|
-
it("
|
|
537
|
-
const { deps, relaunchCalls } = makeDeps({ isBusy: () => true });
|
|
538
|
-
const reply = await handleModelCommand({ kind: "set", model: "
|
|
167
|
+
it("busy session refuses without scheduling anything", async () => {
|
|
168
|
+
const { deps, relaunchCalls, defaultRelaunchCalls } = makeDeps({ isBusy: () => true });
|
|
169
|
+
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
539
170
|
expect(relaunchCalls).toHaveLength(0);
|
|
171
|
+
expect(defaultRelaunchCalls).toHaveLength(0);
|
|
540
172
|
expect(reply.text).toContain("mid-turn");
|
|
541
173
|
});
|
|
542
174
|
|
|
543
|
-
it("
|
|
175
|
+
it("debounce: a restart_in_flight throw yields the ~15s copy, not a double-dispatch", async () => {
|
|
176
|
+
let calls = 0;
|
|
544
177
|
const { deps } = makeDeps({
|
|
545
|
-
|
|
546
|
-
});
|
|
547
|
-
const reply = await handleModelCommand({ kind: "set", model: "claude-bogus" }, deps);
|
|
548
|
-
expect(reply.text).toContain("did not take");
|
|
549
|
-
expect(reply.text).toContain("Model not found");
|
|
550
|
-
expect(reply.text).not.toContain("Sticky across switchroom-managed relaunches");
|
|
551
|
-
expect(reply.selectedModel).toBeUndefined();
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
it("detects claude v2.1.205's real error shape: ⎿ Model 'name' not found (TUI-probe verified)", async () => {
|
|
555
|
-
// Captured verbatim from a disposable claude v2.1.205 TUI, 2026-07-10.
|
|
556
|
-
const { deps } = makeDeps({
|
|
557
|
-
inject: async () => okResult("⎿ Model 'claude-bogus-99' not found"),
|
|
558
|
-
});
|
|
559
|
-
const reply = await handleModelCommand({ kind: "set", model: "claude-bogus-99" }, deps);
|
|
560
|
-
expect(reply.text).toContain("did not take");
|
|
561
|
-
expect(reply.text).toContain("not found");
|
|
562
|
-
expect(reply.selectedModel).toBeUndefined();
|
|
563
|
-
});
|
|
564
|
-
|
|
565
|
-
it("scrollback prose CONTAINING 'model not found' mid-sentence is NOT a failure (anchored error scan)", async () => {
|
|
566
|
-
// The error regex is line-start anchored, like the confirmation prefix —
|
|
567
|
-
// prose that merely mentions the phrase must not flip a successful switch
|
|
568
|
-
// into a reported failure (the false-FAILURE variant of the scrollback-leak
|
|
569
|
-
// class this PR eliminates).
|
|
570
|
-
const { deps } = makeDeps({
|
|
571
|
-
inject: async () => okResult("deploy failed: model not found in registry"),
|
|
178
|
+
scheduleModelRelaunch: async () => { calls++; throw restartInFlight(); },
|
|
572
179
|
});
|
|
573
180
|
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
574
|
-
expect(
|
|
575
|
-
expect(reply.text).
|
|
576
|
-
// No LINE-ANCHORED error and no confirmation → #3241 optimistic record: the
|
|
577
|
-
// mid-sentence "model not found" prose does NOT flip the switch to a failure,
|
|
578
|
-
// and the requested model is recorded (was: "couldn't confirm", nothing).
|
|
579
|
-
expect(reply.text).toContain("couldn't read a confirmation");
|
|
580
|
-
expect(reply.selectedModel).toBe("Opus");
|
|
581
|
-
expect(reply.optimistic).toBe(true);
|
|
181
|
+
expect(calls).toBe(1);
|
|
182
|
+
expect(reply.text).toContain("~15s");
|
|
582
183
|
});
|
|
583
184
|
|
|
584
|
-
it("
|
|
585
|
-
// Captured verbatim from a disposable claude v2.1.205 TUI, 2026-07-10:
|
|
586
|
-
// the arg form is NOT silent — it prints this line, and the ⎿ glyph
|
|
587
|
-
// survives the inject capture. The name extraction must stop at "and
|
|
588
|
-
// saved", not swallow the whole sentence.
|
|
185
|
+
it("a generic dispatch failure surfaces an honest error", async () => {
|
|
589
186
|
const { deps } = makeDeps({
|
|
590
|
-
|
|
591
|
-
okResult("⎿ Set model to Opus 4.8 and saved as your default for new sessions"),
|
|
187
|
+
scheduleModelRelaunch: async () => { throw new Error("hostd down"); },
|
|
592
188
|
});
|
|
593
189
|
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
594
|
-
expect(reply.text).toContain("Set model to Opus 4.8");
|
|
595
|
-
expect(reply.selectedModel).toBe("Opus 4.8");
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
it("does NOT record an override when the confirmation is 'Kept model as' (no change)", async () => {
|
|
599
|
-
const { deps } = makeDeps({
|
|
600
|
-
inject: async () => okResult("⏺ Kept model as Opus 4.8 (default)"),
|
|
601
|
-
});
|
|
602
|
-
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
603
|
-
// The kept line is still relayed as a confirmation, but nothing is recorded.
|
|
604
|
-
expect(reply.selectedModel).toBeUndefined();
|
|
605
|
-
});
|
|
606
|
-
});
|
|
607
|
-
|
|
608
|
-
describe("handleModelCommand — Claude → sr-* session relaunch", () => {
|
|
609
|
-
it("schedules a model relaunch (carrier) instead of injecting for an sr-* target", async () => {
|
|
610
|
-
const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
|
|
611
|
-
getActiveSessionModel: () => null,
|
|
612
|
-
});
|
|
613
|
-
const reply = await handleModelCommand({ kind: "set", model: "sr-glm-5" }, deps);
|
|
614
|
-
// Must NOT inject (claude's picker rejects sr-* ids)
|
|
615
|
-
expect(calls).toHaveLength(0);
|
|
616
|
-
// Must NOT use the sr→claude scheduleRestart path
|
|
617
|
-
expect(restartCalls).toHaveLength(0);
|
|
618
|
-
// Must schedule the carrier-based relaunch with the full sr-* id
|
|
619
|
-
expect(relaunchCalls).toHaveLength(1);
|
|
620
|
-
expect(relaunchCalls[0].model).toBe("sr-glm-5");
|
|
621
|
-
expect(reply.text).toContain("sr-glm-5");
|
|
622
|
-
expect(reply.text).toContain("30s");
|
|
623
|
-
expect(reply.html).toBe(true);
|
|
624
|
-
});
|
|
625
|
-
|
|
626
|
-
it("expands a short sr alias then relaunches on the full id", async () => {
|
|
627
|
-
const { deps, calls, relaunchCalls } = makeDeps({ getActiveSessionModel: () => null });
|
|
628
|
-
await handleModelCommand({ kind: "set", model: "glm" }, deps);
|
|
629
|
-
expect(calls).toHaveLength(0);
|
|
630
|
-
expect(relaunchCalls).toHaveLength(1);
|
|
631
|
-
expect(relaunchCalls[0].model).toBe("sr-glm-5");
|
|
632
|
-
});
|
|
633
|
-
|
|
634
|
-
it("Claude target still uses the instant inject path (no relaunch)", async () => {
|
|
635
|
-
const { deps, calls, relaunchCalls, restartCalls } = makeDeps({
|
|
636
|
-
getActiveSessionModel: () => null,
|
|
637
|
-
});
|
|
638
|
-
await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
|
|
639
|
-
expect(calls).toHaveLength(1);
|
|
640
|
-
expect(relaunchCalls).toHaveLength(0);
|
|
641
|
-
expect(restartCalls).toHaveLength(0);
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
it("sr-* → Claude rides scheduleModelRelaunch (carrier) so the Claude model survives the restart", async () => {
|
|
645
|
-
const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
|
|
646
|
-
getActiveSessionModel: () => "sr-glm-5",
|
|
647
|
-
});
|
|
648
|
-
await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
649
|
-
expect(calls).toHaveLength(0);
|
|
650
|
-
expect(restartCalls).toHaveLength(0);
|
|
651
|
-
expect(relaunchCalls).toHaveLength(1);
|
|
652
|
-
expect(relaunchCalls[0].model).toBe("opus");
|
|
653
|
-
});
|
|
654
|
-
|
|
655
|
-
it("surfaces scheduleModelRelaunch failures without propagating the error", async () => {
|
|
656
|
-
const { deps, calls } = makeDeps({
|
|
657
|
-
getActiveSessionModel: () => null,
|
|
658
|
-
scheduleModelRelaunch: async () => { throw new Error("carrier write failed"); },
|
|
659
|
-
});
|
|
660
|
-
const reply = await handleModelCommand({ kind: "set", model: "sr-glm-5" }, deps);
|
|
661
|
-
expect(calls).toHaveLength(0);
|
|
662
190
|
expect(reply.text).toContain("Could not schedule model switch");
|
|
663
|
-
expect(reply.text).toContain("carrier write failed");
|
|
664
|
-
});
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
// ---------------------------------------------------------------------------
|
|
668
|
-
// Manual full-sr-* passthrough (Ken 2026-07-08): the /model MENU stays curated
|
|
669
|
-
// to the main SR_MODEL_ALIASES set, but typing `/model <any registered sr-*>`
|
|
670
|
-
// must switch to that exact model — the set path is a SHAPE gate only, with NO
|
|
671
|
-
// whitelist against SR_MODEL_LABELS / SR_MODEL_ALIASES. These guard that any
|
|
672
|
-
// arbitrary sr-* id passes through verbatim and schedules the relaunch on the
|
|
673
|
-
// exact id (not rejected, not remapped, not injected).
|
|
674
|
-
// ---------------------------------------------------------------------------
|
|
675
|
-
describe("handleModelCommand — arbitrary sr-* passthrough (no whitelist)", () => {
|
|
676
|
-
// sr-* names that are NOT in SR_MODEL_ALIASES (so not menu-reachable) but ARE
|
|
677
|
-
// registered in the live litellm config — must still switch when typed.
|
|
678
|
-
const ARBITRARY_SR = [
|
|
679
|
-
"sr-gpt-oss-120b",
|
|
680
|
-
"sr-gpt-oss-20b",
|
|
681
|
-
"sr-minimax-m3",
|
|
682
|
-
"sr-gemini-flash-lite",
|
|
683
|
-
"sr-gpt-5.5",
|
|
684
|
-
"sr-gpt-5-codex",
|
|
685
|
-
"sr-gpt-5.2-codex",
|
|
686
|
-
"sr-deepseek-v4-flash",
|
|
687
|
-
];
|
|
688
|
-
|
|
689
|
-
it("each arbitrary sr-* passes the shape gate and isSrModel", () => {
|
|
690
|
-
for (const name of ARBITRARY_SR) {
|
|
691
|
-
expect(isValidModelArg(name), `${name} must pass MODEL_ARG_RE`).toBe(true);
|
|
692
|
-
expect(isSrModel(name), `${name} must be recognised as sr-*`).toBe(true);
|
|
693
|
-
expect(isClaudeModel(name)).toBe(false);
|
|
694
|
-
}
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
it("relaunches on the exact typed id — not rejected, not remapped", async () => {
|
|
698
|
-
for (const name of ARBITRARY_SR) {
|
|
699
|
-
const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
|
|
700
|
-
getActiveSessionModel: () => null,
|
|
701
|
-
});
|
|
702
|
-
const reply = await handleModelCommand({ kind: "set", model: name }, deps);
|
|
703
|
-
// Never injected (claude's picker rejects sr-* ids).
|
|
704
|
-
expect(calls, `${name} must not inject`).toHaveLength(0);
|
|
705
|
-
// Never the sr→claude restart path (source session is Claude here).
|
|
706
|
-
expect(restartCalls, `${name} must not scheduleRestart`).toHaveLength(0);
|
|
707
|
-
// Scheduled the carrier relaunch on the EXACT id (no alias remap).
|
|
708
|
-
expect(relaunchCalls).toHaveLength(1);
|
|
709
|
-
expect(relaunchCalls[0].model, `${name} must relaunch verbatim`).toBe(name);
|
|
710
|
-
expect(reply.text).toContain(name);
|
|
711
|
-
}
|
|
712
191
|
});
|
|
713
192
|
|
|
714
|
-
it("
|
|
715
|
-
|
|
716
|
-
|
|
193
|
+
it("the reply type carries no optimistic/selectedModel field on ANY set outcome", async () => {
|
|
194
|
+
// The retired-contract guard (F3): the fields were DELETED from the reply,
|
|
195
|
+
// so an unapplied switch can no longer be optimistically recorded.
|
|
196
|
+
const { deps } = makeDeps();
|
|
197
|
+
for (const model of ["opus", "sr-glm-5", "default", "fable"]) {
|
|
198
|
+
const reply = await handleModelCommand({ kind: "set", model }, deps);
|
|
199
|
+
expect(Object.prototype.hasOwnProperty.call(reply, "selectedModel")).toBe(false);
|
|
200
|
+
expect(Object.prototype.hasOwnProperty.call(reply, "optimistic")).toBe(false);
|
|
717
201
|
}
|
|
718
202
|
});
|
|
719
|
-
|
|
720
|
-
it("switching FROM an arbitrary sr-* back to Claude carries the Claude model via the relaunch carrier", async () => {
|
|
721
|
-
const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
|
|
722
|
-
getActiveSessionModel: () => "sr-gpt-oss-120b",
|
|
723
|
-
});
|
|
724
|
-
await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
725
|
-
expect(calls).toHaveLength(0);
|
|
726
|
-
expect(restartCalls).toHaveLength(0);
|
|
727
|
-
expect(relaunchCalls).toHaveLength(1);
|
|
728
|
-
expect(relaunchCalls[0].model).toBe("opus");
|
|
729
|
-
});
|
|
730
203
|
});
|
|
731
204
|
|
|
732
|
-
describe("
|
|
733
|
-
it("
|
|
734
|
-
const {
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
// ---------------------------------------------------------------------------
|
|
740
|
-
// Picker-driven menu (v2) — buildModelMenu + handleModelMenuCallback
|
|
741
|
-
// ---------------------------------------------------------------------------
|
|
742
|
-
|
|
743
|
-
describe("SR_MODEL_ALIASES / expandSrAlias", () => {
|
|
744
|
-
let expandSrAlias: (arg: string) => string;
|
|
745
|
-
let SR_MODEL_ALIASES: Record<string, string>;
|
|
746
|
-
|
|
747
|
-
beforeAll(async () => {
|
|
748
|
-
const mod = await import("../gateway/model-command.js");
|
|
749
|
-
expandSrAlias = mod.expandSrAlias;
|
|
750
|
-
SR_MODEL_ALIASES = mod.SR_MODEL_ALIASES;
|
|
751
|
-
});
|
|
752
|
-
|
|
753
|
-
it("expands known short aliases to full sr-* ids", () => {
|
|
754
|
-
expect(expandSrAlias("flash")).toBe("sr-gemini-2.5-flash");
|
|
755
|
-
expect(expandSrAlias("gemini")).toBe("sr-gemini-2.5-pro");
|
|
756
|
-
expect(expandSrAlias("deepseek")).toBe("sr-deepseek-v3");
|
|
757
|
-
expect(expandSrAlias("r1")).toBe("sr-deepseek-r1");
|
|
758
|
-
expect(expandSrAlias("glm")).toBe("sr-glm-5");
|
|
759
|
-
expect(expandSrAlias("codex")).toBe("sr-codex-5.5");
|
|
760
|
-
});
|
|
761
|
-
|
|
762
|
-
it("is case-insensitive", () => {
|
|
763
|
-
expect(expandSrAlias("Flash")).toBe("sr-gemini-2.5-flash");
|
|
764
|
-
expect(expandSrAlias("CODEX")).toBe("sr-codex-5.5");
|
|
765
|
-
});
|
|
766
|
-
|
|
767
|
-
it("passes through unknown names unchanged", () => {
|
|
768
|
-
expect(expandSrAlias("opus")).toBe("opus");
|
|
769
|
-
expect(expandSrAlias("sr-gemini-2.5-flash")).toBe("sr-gemini-2.5-flash");
|
|
770
|
-
expect(expandSrAlias("claude-opus-4-8")).toBe("claude-opus-4-8");
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
it("every alias target is a valid sr-* model arg", () => {
|
|
774
|
-
for (const [alias, target] of Object.entries(SR_MODEL_ALIASES)) {
|
|
775
|
-
expect(target.startsWith("sr-"), `${alias} → ${target} must start with sr-`).toBe(true);
|
|
776
|
-
}
|
|
777
|
-
});
|
|
778
|
-
|
|
779
|
-
it("handleModelCommand relaunches on the expanded sr-* id, not the short alias", async () => {
|
|
780
|
-
const { deps, calls, relaunchCalls } = makeDeps({ getActiveSessionModel: () => null });
|
|
781
|
-
await handleModelCommand({ kind: "set", model: "flash" }, deps);
|
|
782
|
-
// sr-* targets no longer inject — they carrier-relaunch on the full id.
|
|
783
|
-
expect(calls).toHaveLength(0);
|
|
784
|
-
expect(relaunchCalls).toHaveLength(1);
|
|
785
|
-
expect(relaunchCalls[0].model).toBe("sr-gemini-2.5-flash");
|
|
205
|
+
describe("handleModelCommand — show / help never schedule a switch", () => {
|
|
206
|
+
it("show renders the configured model + options, schedules nothing", async () => {
|
|
207
|
+
const { deps, relaunchCalls, defaultRelaunchCalls } = makeDeps();
|
|
208
|
+
const reply = await handleModelCommand({ kind: "show" }, deps);
|
|
209
|
+
expect(reply.text).toContain("claude-sonnet-5");
|
|
210
|
+
expect(relaunchCalls).toHaveLength(0);
|
|
211
|
+
expect(defaultRelaunchCalls).toHaveLength(0);
|
|
786
212
|
});
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
const
|
|
790
|
-
getActiveSessionModel: () => "sr-deepseek-v3",
|
|
791
|
-
});
|
|
792
|
-
await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
793
|
-
expect(calls).toHaveLength(0);
|
|
794
|
-
expect(restartCalls).toHaveLength(0);
|
|
795
|
-
expect(relaunchCalls).toHaveLength(1);
|
|
796
|
-
expect(relaunchCalls[0].model).toBe("opus");
|
|
213
|
+
it("help lists the aliases including fable", async () => {
|
|
214
|
+
const reply = await handleModelCommand({ kind: "help" }, makeDeps().deps);
|
|
215
|
+
for (const a of MODEL_ALIASES) expect(reply.text).toContain(a);
|
|
797
216
|
});
|
|
798
217
|
});
|
|
799
218
|
|
|
219
|
+
// ─── Menu / callback deterministic relaunch ─────────────────────────────────
|
|
220
|
+
|
|
800
221
|
import {
|
|
801
222
|
buildModelMenu,
|
|
802
223
|
handleModelMenuCallback,
|
|
803
224
|
modelSelectCallbackData,
|
|
804
|
-
|
|
225
|
+
isRecognizedSwitchToken,
|
|
805
226
|
classifyDiscoveredOptions,
|
|
227
|
+
canonicalClaudeToken,
|
|
806
228
|
MODEL_CALLBACK_REFRESH,
|
|
807
|
-
MODEL_CALLBACK_HEADER,
|
|
808
229
|
MODEL_CALLBACK_SR,
|
|
809
230
|
MODEL_CALLBACK_ALIAS,
|
|
810
231
|
MODEL_CALLBACK_PAGE_EXTERNAL,
|
|
811
|
-
MODEL_CALLBACK_PAGE_MAIN,
|
|
812
232
|
SR_MODEL_LABELS,
|
|
813
233
|
SR_MODEL_ALIASES,
|
|
814
234
|
EXTRA_CLAUDE_ALIASES,
|
|
235
|
+
expandSrAlias,
|
|
815
236
|
externalModelNames,
|
|
816
|
-
isSrToClaudeTransition,
|
|
817
|
-
optimisticModelRecordLabel,
|
|
818
237
|
type ModelMenuDeps,
|
|
819
238
|
} from "../gateway/model-command.js";
|
|
820
|
-
import { labelTag } from "../../src/agents/model-picker.js";
|
|
821
239
|
|
|
822
240
|
const OPTIONS = [
|
|
823
241
|
{ index: 1, label: "Default (recommended)", detail: "Opus 4.8 with 1M context", current: false },
|
|
@@ -825,924 +243,288 @@ const OPTIONS = [
|
|
|
825
243
|
{ index: 3, label: "Haiku", detail: "Haiku 4.5 · Fastest", current: false },
|
|
826
244
|
];
|
|
827
245
|
|
|
828
|
-
function makeMenuDeps(overrides: Partial<ModelMenuDeps> = {}) {
|
|
829
|
-
const
|
|
830
|
-
const
|
|
831
|
-
const deps = {
|
|
246
|
+
function makeMenuDeps(overrides: Partial<ModelMenuDeps & ModelCommandDeps> = {}) {
|
|
247
|
+
const base = makeDeps();
|
|
248
|
+
const calls = { discover: 0 };
|
|
249
|
+
const deps: ModelMenuDeps & ModelCommandDeps = {
|
|
832
250
|
...base.deps,
|
|
833
251
|
discover: async () => {
|
|
834
252
|
calls.discover++;
|
|
835
253
|
return { ok: true as const, options: OPTIONS, currentLabel: "Sonnet" };
|
|
836
254
|
},
|
|
837
|
-
select: async (_a: string, label: string) => {
|
|
838
|
-
calls.select.push(label);
|
|
839
|
-
return { ok: true as const, confirmation: `Set model to ${label} for this session` };
|
|
840
|
-
},
|
|
841
255
|
isBusy: () => false,
|
|
842
256
|
getQuotaBrief: async () => "29% / 5h · 33% / 7d",
|
|
843
257
|
discoverSrModels: async () => [],
|
|
844
258
|
...overrides,
|
|
845
259
|
};
|
|
846
|
-
return {
|
|
260
|
+
return {
|
|
261
|
+
deps,
|
|
262
|
+
calls,
|
|
263
|
+
relaunchCalls: base.relaunchCalls,
|
|
264
|
+
defaultRelaunchCalls: base.defaultRelaunchCalls,
|
|
265
|
+
};
|
|
847
266
|
}
|
|
848
267
|
|
|
849
|
-
describe("
|
|
850
|
-
it("
|
|
851
|
-
|
|
852
|
-
const menu = await buildModelMenu(deps);
|
|
853
|
-
expect(calls.discover).toBe(1);
|
|
854
|
-
expect(menu.text).toContain("**Sonnet**");
|
|
855
|
-
expect(menu.text).toContain("29% / 5h · 33% / 7d");
|
|
856
|
-
expect(menu.keyboard).toBeDefined();
|
|
857
|
-
// 3 scraped option rows + static Fable row + refresh row
|
|
858
|
-
// (no external row here — discoverSrModels returns [] and the default
|
|
859
|
-
// makeMenuDeps has no SR seed override, but externalModelNames seeds from
|
|
860
|
-
// SR_MODEL_ALIASES, so the External row IS present — see dedicated tests).
|
|
861
|
-
expect(menu.keyboard![1][0].text).toBe("✅ Sonnet");
|
|
862
|
-
expect(menu.keyboard![0][0].text).toBe("Default (recommended)");
|
|
863
|
-
// Refresh is always the last row.
|
|
864
|
-
expect(menu.keyboard![menu.keyboard!.length - 1][0].callback_data).toBe(
|
|
865
|
-
MODEL_CALLBACK_REFRESH,
|
|
866
|
-
);
|
|
268
|
+
describe("modelSelectCallbackData — embeds the canonical token (rev 5)", () => {
|
|
269
|
+
it("an alias row carries the alias token", () => {
|
|
270
|
+
expect(modelSelectCallbackData("Haiku")).toBe("mdl:s:haiku");
|
|
867
271
|
});
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
const { deps } = makeMenuDeps();
|
|
871
|
-
const menu = await buildModelMenu(deps);
|
|
872
|
-
for (const row of menu.keyboard!) {
|
|
873
|
-
for (const btn of row) {
|
|
874
|
-
expect(Buffer.byteLength(btn.callback_data, "utf-8")).toBeLessThanOrEqual(64);
|
|
875
|
-
}
|
|
876
|
-
}
|
|
272
|
+
it("a full-id row carries the id", () => {
|
|
273
|
+
expect(modelSelectCallbackData("claude-opus-4-8")).toBe("mdl:s:claude-opus-4-8");
|
|
877
274
|
});
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
const { deps, calls } = makeMenuDeps({ isBusy: () => true });
|
|
881
|
-
const menu = await buildModelMenu(deps);
|
|
882
|
-
// Never drives the picker mid-turn…
|
|
883
|
-
expect(calls.discover).toBe(0);
|
|
884
|
-
expect(menu.text).toContain("mid-turn");
|
|
885
|
-
// …but no dead-end either: static alias rows are offered so the operator
|
|
886
|
-
// can still lock in a choice (the tap queues at the gateway busy gate).
|
|
887
|
-
expect(menu.keyboard).toBeDefined();
|
|
888
|
-
const data = menu.keyboard!.flat().map(b => b.callback_data);
|
|
889
|
-
expect(data).toContain("mdl:alias:opus");
|
|
890
|
-
expect(data).toContain("mdl:alias:default");
|
|
891
|
-
expect(menu.text).not.toContain("Try again");
|
|
892
|
-
});
|
|
893
|
-
|
|
894
|
-
it("discovery failure → static v1 fallback with the reason, no keyboard", async () => {
|
|
895
|
-
const { deps } = makeMenuDeps({
|
|
896
|
-
discover: async () => ({ ok: false as const, reason: "tmux session not found" }),
|
|
897
|
-
});
|
|
898
|
-
const menu = await buildModelMenu(deps);
|
|
899
|
-
expect(menu.keyboard).toBeUndefined();
|
|
900
|
-
expect(menu.text).toContain("picker unavailable");
|
|
901
|
-
expect(menu.text).toContain("Configured:");
|
|
275
|
+
it("the Default row carries the `default` sentinel", () => {
|
|
276
|
+
expect(modelSelectCallbackData("Default (recommended)")).toBe("mdl:s:default");
|
|
902
277
|
});
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
},
|
|
909
|
-
});
|
|
910
|
-
const menu = await buildModelMenu(deps);
|
|
911
|
-
expect(menu.keyboard).toBeDefined();
|
|
912
|
-
expect(menu.text).not.toContain("Quota:");
|
|
278
|
+
it("N2: an UNMAPPED Claude row does NOT collapse to the `default` sentinel", () => {
|
|
279
|
+
// A label whose first word is neither a known alias nor claude-* nor default
|
|
280
|
+
// must not masquerade as a default-revert; it carries an empty (rejected) suffix.
|
|
281
|
+
expect(modelSelectCallbackData("Sparkle 9 (preview)")).toBe("mdl:s:");
|
|
282
|
+
expect(modelSelectCallbackData("Sparkle 9 (preview)")).not.toBe("mdl:s:default");
|
|
913
283
|
});
|
|
914
284
|
});
|
|
915
285
|
|
|
916
|
-
describe("
|
|
917
|
-
it("
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
expect(out.answer).toContain("Set model to Haiku");
|
|
922
|
-
expect(out.reply.text).toContain("✅");
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
it("stale tag (options changed) → never selects, re-renders menu", async () => {
|
|
926
|
-
const { deps, calls } = makeMenuDeps();
|
|
927
|
-
const staleTag = `mdl:s:${labelTag("Removed Model")}`;
|
|
928
|
-
const out = await handleModelMenuCallback(staleTag, deps);
|
|
929
|
-
expect(calls.select).toEqual([]);
|
|
930
|
-
expect(out.answer).toContain("refreshed");
|
|
931
|
-
expect(out.reply.keyboard).toBeDefined();
|
|
932
|
-
});
|
|
933
|
-
|
|
934
|
-
it("tapping the ✔ (default) row STILL drives a switch — ✔ is the new-session default, not the live session model", async () => {
|
|
935
|
-
// OPTIONS marks "Sonnet" current (the ✔). An agent launched on a
|
|
936
|
-
// different model must still be able to apply the ✔ row to its live
|
|
937
|
-
// session — skipping it was the "tapped Default, nothing happened" bug.
|
|
938
|
-
const { deps, calls } = makeMenuDeps();
|
|
939
|
-
const out = await handleModelMenuCallback(modelSelectCallbackData("Sonnet"), deps);
|
|
940
|
-
expect(calls.select).toEqual(["Sonnet"]);
|
|
941
|
-
expect(out.reply.text).toContain("✅");
|
|
942
|
-
expect(out.reply.keyboard).toBeDefined();
|
|
943
|
-
});
|
|
944
|
-
|
|
945
|
-
it("busy agent → toastOnly refusal that leaves the menu untouched", async () => {
|
|
946
|
-
const { deps, calls } = makeMenuDeps({ isBusy: () => true });
|
|
947
|
-
const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
|
|
948
|
-
expect(calls.select).toEqual([]);
|
|
949
|
-
expect(out.answer).toContain("mid-turn");
|
|
950
|
-
// toastOnly tells the gateway to NOT edit the menu — buttons survive.
|
|
951
|
-
expect(out.toastOnly).toBe(true);
|
|
286
|
+
describe("isRecognizedSwitchToken (N1 allowlist)", () => {
|
|
287
|
+
it("accepts default, sr-*, aliases, and claude-* ids", () => {
|
|
288
|
+
for (const t of ["default", "sr-glm-5", "opus", "fable", "claude-opus-4-8"]) {
|
|
289
|
+
expect(isRecognizedSwitchToken(t)).toBe(true);
|
|
290
|
+
}
|
|
952
291
|
});
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
});
|
|
958
|
-
const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
|
|
959
|
-
expect(out.answer).toContain("failed");
|
|
960
|
-
expect(out.reply.text).toContain("cursor verification failed");
|
|
961
|
-
// The menu buttons are preserved — a failure no longer collapses the
|
|
962
|
-
// menu to a button-less error (the "nothing happened" bug).
|
|
963
|
-
expect(out.reply.keyboard).toBeDefined();
|
|
292
|
+
it("rejects a stale 8-hex labelTag and other garbage", () => {
|
|
293
|
+
for (const t of ["1a2b3c4d", "", "deadbeef", "Sparkle9"]) {
|
|
294
|
+
expect(isRecognizedSwitchToken(t)).toBe(false);
|
|
295
|
+
}
|
|
964
296
|
});
|
|
297
|
+
});
|
|
965
298
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
299
|
+
describe("handleModelMenuCallback — every switch tap relaunches (rev 5)", () => {
|
|
300
|
+
it("picker SELECT `mdl:s:haiku` relaunches on 'haiku' and never scrapes/selects", async () => {
|
|
301
|
+
// FAILS on old code: the SELECT branch called deps.select and re-discovered
|
|
302
|
+
// the picker; here there is no select dep at all.
|
|
303
|
+
const { deps, relaunchCalls, calls } = makeMenuDeps();
|
|
970
304
|
const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
|
|
971
|
-
expect(
|
|
972
|
-
|
|
973
|
-
expect(
|
|
974
|
-
expect(out.reply.
|
|
975
|
-
|
|
976
|
-
expect(
|
|
305
|
+
expect(relaunchCalls).toEqual([expect.objectContaining({ model: "haiku" })]);
|
|
306
|
+
// The switch path does NOT drive discovery.
|
|
307
|
+
expect(calls.discover).toBe(0);
|
|
308
|
+
expect(out.reply.text).toContain("relaunching");
|
|
309
|
+
expect(Object.prototype.hasOwnProperty.call(out, "selectedModel")).toBe(false);
|
|
310
|
+
expect(Object.prototype.hasOwnProperty.call(out, "selectedModelToken")).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("N1: a stale SELECT token (old 8-hex labelTag) does NOT relaunch — re-renders instead", async () => {
|
|
314
|
+
// A menu rendered by the OLD gateway carries `mdl:s:<hex>`. The hex passes the
|
|
315
|
+
// loose shape gate but is not a recognized model token; relaunching onto it
|
|
316
|
+
// would write a garbage carrier that --fallback-model silently masks. The
|
|
317
|
+
// handler must re-render, not schedule anything.
|
|
318
|
+
const { deps, relaunchCalls, defaultRelaunchCalls } = makeMenuDeps();
|
|
319
|
+
const out = await handleModelMenuCallback("mdl:s:1a2b3c4d", deps);
|
|
320
|
+
expect(relaunchCalls).toHaveLength(0);
|
|
321
|
+
expect(defaultRelaunchCalls).toHaveLength(0);
|
|
322
|
+
expect(out.answer).toContain("menu refreshed");
|
|
977
323
|
});
|
|
978
324
|
|
|
979
|
-
it("
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
});
|
|
985
|
-
const out = await handleModelMenuCallback(modelSelectCallbackData("Default (recommended)"), deps);
|
|
986
|
-
expect(out.reply.text).toContain("✅");
|
|
987
|
-
// Crucially, NOTHING is recorded — no display label leaks into the override.
|
|
988
|
-
expect(out.selectedModel).toBeUndefined();
|
|
989
|
-
expect(out.reply.text).not.toContain("Default (recommended)");
|
|
325
|
+
it("N1: an empty SELECT suffix (unmapped row) does NOT relaunch", async () => {
|
|
326
|
+
const { deps, relaunchCalls, defaultRelaunchCalls } = makeMenuDeps();
|
|
327
|
+
await handleModelMenuCallback("mdl:s:", deps);
|
|
328
|
+
expect(relaunchCalls).toHaveLength(0);
|
|
329
|
+
expect(defaultRelaunchCalls).toHaveLength(0);
|
|
990
330
|
});
|
|
991
331
|
|
|
992
|
-
it("
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
});
|
|
998
|
-
const out = await handleModelMenuCallback(modelSelectCallbackData("Default (recommended)"), deps);
|
|
999
|
-
expect(out.selectedModel).toBe("Opus 4.8");
|
|
1000
|
-
// "Default (recommended)" yields no --model token → carrier boots the config default.
|
|
1001
|
-
expect(out.selectedModelToken).toBeUndefined();
|
|
332
|
+
it("picker SELECT of the Default row routes to the default relaunch", async () => {
|
|
333
|
+
const { deps, relaunchCalls, defaultRelaunchCalls } = makeMenuDeps();
|
|
334
|
+
await handleModelMenuCallback("mdl:s:default", deps);
|
|
335
|
+
expect(defaultRelaunchCalls).toHaveLength(1);
|
|
336
|
+
expect(relaunchCalls).toHaveLength(0);
|
|
1002
337
|
});
|
|
1003
338
|
|
|
1004
|
-
it("
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
});
|
|
1008
|
-
|
|
1009
|
-
expect(out.selectedModel).toBe("Sonnet");
|
|
1010
|
-
expect(out.selectedModelToken).toBe("sonnet");
|
|
339
|
+
it("Fable button `mdl:alias:fable` relaunches on 'fable' (never injects)", async () => {
|
|
340
|
+
// FAILS on old code: the alias branch injected `/model fable` and scraped.
|
|
341
|
+
const { deps, relaunchCalls } = makeMenuDeps();
|
|
342
|
+
await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
|
|
343
|
+
expect(relaunchCalls).toEqual([expect.objectContaining({ model: "fable" })]);
|
|
1011
344
|
});
|
|
1012
|
-
});
|
|
1013
345
|
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
describe("handleModelMenuCallback — alias button symmetry (#3242 MEDIUM 2)", () => {
|
|
1020
|
-
it("ok_no_output (silent switch via the button) → optimistic record, not a failure", async () => {
|
|
1021
|
-
const { deps } = makeMenuDeps({
|
|
1022
|
-
inject: async () => ({
|
|
1023
|
-
outcome: "ok_no_output" as const,
|
|
1024
|
-
output: "",
|
|
1025
|
-
truncated: false,
|
|
1026
|
-
command: "/model",
|
|
1027
|
-
meta: { description: "Open model picker", expectsOutput: true },
|
|
1028
|
-
}),
|
|
1029
|
-
});
|
|
1030
|
-
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
|
|
1031
|
-
expect(out.selectedModel).toBe("Fable"); // normalized display form
|
|
1032
|
-
expect(out.selectedModelToken).toBe("fable");
|
|
1033
|
-
// Provisional copy (#3242 FIX 2): doesn't assert the switch succeeded, points
|
|
1034
|
-
// at /status, and never reads as a failure.
|
|
1035
|
-
expect(out.reply.text).not.toContain("failed");
|
|
1036
|
-
expect(out.reply.text).toContain("/status");
|
|
1037
|
-
expect(out.reply.text).toContain("/model fable");
|
|
346
|
+
it("Default alias button routes to the default relaunch", async () => {
|
|
347
|
+
const { deps, relaunchCalls, defaultRelaunchCalls } = makeMenuDeps();
|
|
348
|
+
await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}default`, deps);
|
|
349
|
+
expect(defaultRelaunchCalls).toHaveLength(1);
|
|
350
|
+
expect(relaunchCalls).toHaveLength(0);
|
|
1038
351
|
});
|
|
1039
352
|
|
|
1040
|
-
it("
|
|
1041
|
-
const { deps } = makeMenuDeps(
|
|
1042
|
-
|
|
1043
|
-
});
|
|
1044
|
-
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
|
|
1045
|
-
expect(out.selectedModel).toBe("Fable");
|
|
1046
|
-
expect(out.selectedModelToken).toBe("fable");
|
|
1047
|
-
// The banner must not leak as the confirmation.
|
|
1048
|
-
expect(out.reply.text).not.toContain("extending Claude Fable 5 access");
|
|
353
|
+
it("sr-* tap `mdl:sr:sr-glm-5` relaunches on the sr id", async () => {
|
|
354
|
+
const { deps, relaunchCalls } = makeMenuDeps();
|
|
355
|
+
await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-glm-5`, deps);
|
|
356
|
+
expect(relaunchCalls).toEqual([expect.objectContaining({ model: "sr-glm-5" })]);
|
|
1049
357
|
});
|
|
1050
358
|
|
|
1051
|
-
it("
|
|
1052
|
-
const { deps } = makeMenuDeps({
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
expect(
|
|
1057
|
-
expect(out.reply.text).toContain("did not take");
|
|
359
|
+
it("a switch tap while mid-turn refuses (toastOnly) and schedules nothing", async () => {
|
|
360
|
+
const { deps, relaunchCalls } = makeMenuDeps({ isBusy: () => true });
|
|
361
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}opus`, deps);
|
|
362
|
+
expect(out.toastOnly).toBe(true);
|
|
363
|
+
expect(out.busyRefusal).toBe(true);
|
|
364
|
+
expect(relaunchCalls).toHaveLength(0);
|
|
1058
365
|
});
|
|
1059
366
|
|
|
1060
|
-
it("
|
|
367
|
+
it("restart_in_flight on a menu tap yields the ~15s copy, one dispatch", async () => {
|
|
368
|
+
let calls = 0;
|
|
1061
369
|
const { deps } = makeMenuDeps({
|
|
1062
|
-
|
|
370
|
+
scheduleModelRelaunch: async () => { calls++; throw restartInFlight(); },
|
|
1063
371
|
});
|
|
1064
372
|
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
|
|
1065
|
-
expect(
|
|
1066
|
-
expect(out.
|
|
1067
|
-
});
|
|
1068
|
-
});
|
|
1069
|
-
|
|
1070
|
-
describe("sessionModelFromConfirmation", () => {
|
|
1071
|
-
it("pulls the model name from claude's session-switch confirmation", () => {
|
|
1072
|
-
expect(sessionModelFromConfirmation("Set model to Fable 5 for this session only")).toBe("Fable 5");
|
|
1073
|
-
expect(sessionModelFromConfirmation("Set model to Opus 4.8 (1M context) for this session only")).toBe("Opus 4.8");
|
|
1074
|
-
expect(sessionModelFromConfirmation("Switched to Haiku 4.5")).toBe("Haiku 4.5");
|
|
1075
|
-
});
|
|
1076
|
-
it("terminates the name at 'and saved' (claude v2.1.205 arg-form phrasing, TUI-probe verified)", () => {
|
|
1077
|
-
expect(
|
|
1078
|
-
sessionModelFromConfirmation("⎿ Set model to Opus 4.8 and saved as your default for new sessions"),
|
|
1079
|
-
).toBe("Opus 4.8");
|
|
1080
|
-
});
|
|
1081
|
-
it("returns null when no recognizable name is present", () => {
|
|
1082
|
-
expect(sessionModelFromConfirmation("Kept model as Opus 4.8 (default)")).toBeNull();
|
|
1083
|
-
expect(sessionModelFromConfirmation("")).toBeNull();
|
|
373
|
+
expect(calls).toBe(1);
|
|
374
|
+
expect(out.reply.text).toContain("~15s");
|
|
1084
375
|
});
|
|
1085
376
|
|
|
1086
|
-
it("
|
|
1087
|
-
const { deps,
|
|
377
|
+
it("Refresh re-renders without scheduling", async () => {
|
|
378
|
+
const { deps, relaunchCalls } = makeMenuDeps();
|
|
1088
379
|
const out = await handleModelMenuCallback(MODEL_CALLBACK_REFRESH, deps);
|
|
380
|
+
expect(relaunchCalls).toHaveLength(0);
|
|
1089
381
|
expect(out.answer).toBe("Refreshed");
|
|
1090
|
-
expect(calls.discover).toBe(1);
|
|
1091
|
-
expect(out.reply.keyboard).toBeDefined();
|
|
1092
|
-
});
|
|
1093
|
-
});
|
|
1094
|
-
|
|
1095
|
-
// ---------------------------------------------------------------------------
|
|
1096
|
-
// Ship D — sr-* (LiteLLM non-Anthropic) model support
|
|
1097
|
-
// ---------------------------------------------------------------------------
|
|
1098
|
-
|
|
1099
|
-
const OPTIONS_WITH_SR = [
|
|
1100
|
-
{ index: 1, label: "Default (recommended)", detail: "Opus 4.8 with 1M context", current: false },
|
|
1101
|
-
{ index: 2, label: "Sonnet", detail: "Sonnet 5", current: true },
|
|
1102
|
-
{ index: 3, label: "sr-gemini-2.5-pro", detail: "", current: false },
|
|
1103
|
-
{ index: 4, label: "sr-deepseek-r1", detail: "", current: false },
|
|
1104
|
-
// internal path — should be filtered out
|
|
1105
|
-
{ index: 5, label: "openrouter/google/gemini-2.5-pro", detail: "", current: false },
|
|
1106
|
-
// bare OpenAI models from GATEWAY_MODEL_DISCOVERY — should also be filtered out
|
|
1107
|
-
{ index: 6, label: "gpt-4", detail: "", current: false },
|
|
1108
|
-
{ index: 7, label: "gpt-4o", detail: "", current: false },
|
|
1109
|
-
{ index: 8, label: "voyage-law-2", detail: "", current: false },
|
|
1110
|
-
// full claude ID — should be in claude bucket
|
|
1111
|
-
{ index: 9, label: "claude-opus-4-8", detail: "", current: false },
|
|
1112
|
-
];
|
|
1113
|
-
|
|
1114
|
-
describe("classifyDiscoveredOptions", () => {
|
|
1115
|
-
it("puts native Claude options in claude, sr-* in sr, drops others", () => {
|
|
1116
|
-
const { claude, sr } = classifyDiscoveredOptions(OPTIONS_WITH_SR);
|
|
1117
|
-
expect(claude.map((o) => o.label)).toEqual([
|
|
1118
|
-
"Default (recommended)", "Sonnet", "claude-opus-4-8",
|
|
1119
|
-
]);
|
|
1120
|
-
expect(sr.map((o) => o.label)).toEqual(["sr-gemini-2.5-pro", "sr-deepseek-r1"]);
|
|
1121
|
-
// openrouter/*, gpt-*, voyage-* not present in either bucket
|
|
1122
|
-
const all = [...claude, ...sr];
|
|
1123
|
-
expect(all.find((o) => o.label.includes("openrouter"))).toBeUndefined();
|
|
1124
|
-
expect(all.find((o) => o.label.startsWith("gpt-"))).toBeUndefined();
|
|
1125
|
-
expect(all.find((o) => o.label.startsWith("voyage-"))).toBeUndefined();
|
|
1126
|
-
});
|
|
1127
|
-
|
|
1128
|
-
it("handles a list with no sr-* models", () => {
|
|
1129
|
-
const { claude, sr } = classifyDiscoveredOptions(OPTIONS);
|
|
1130
|
-
expect(claude).toHaveLength(3);
|
|
1131
|
-
expect(sr).toHaveLength(0);
|
|
1132
|
-
});
|
|
1133
|
-
});
|
|
1134
|
-
|
|
1135
|
-
describe("SR_MODEL_LABELS", () => {
|
|
1136
|
-
it("has friendly names for the standard sr-* models", () => {
|
|
1137
|
-
expect(SR_MODEL_LABELS["sr-gemini-2.5-pro"]).toBe("Gemini 2.5 Pro");
|
|
1138
|
-
expect(SR_MODEL_LABELS["sr-deepseek-r1"]).toBe("DeepSeek R1");
|
|
1139
|
-
});
|
|
1140
|
-
|
|
1141
|
-
it("bumps sr-glm-5 label to GLM-5.2 (now targets glm-5.2 in litellm)", () => {
|
|
1142
|
-
expect(SR_MODEL_LABELS["sr-glm-5"]).toBe("GLM-5.2");
|
|
1143
|
-
});
|
|
1144
|
-
|
|
1145
|
-
it("has friendly names for the new OpenRouter sr-* models (display-only)", () => {
|
|
1146
|
-
expect(SR_MODEL_LABELS["sr-gpt-oss-20b"]).toBe("GPT-OSS 20B");
|
|
1147
|
-
expect(SR_MODEL_LABELS["sr-gpt-oss-120b"]).toBe("GPT-OSS 120B");
|
|
1148
|
-
expect(SR_MODEL_LABELS["sr-gpt-5.5"]).toBe("GPT-5.5");
|
|
1149
|
-
expect(SR_MODEL_LABELS["sr-gpt-5-codex"]).toBe("GPT-5 Codex");
|
|
1150
|
-
expect(SR_MODEL_LABELS["sr-gpt-5.2-codex"]).toBe("GPT-5.2 Codex");
|
|
1151
|
-
expect(SR_MODEL_LABELS["sr-gemini-flash-lite"]).toBe("Gemini 3.1 Flash Lite");
|
|
1152
|
-
expect(SR_MODEL_LABELS["sr-minimax-m3"]).toBe("MiniMax M3");
|
|
1153
|
-
expect(SR_MODEL_LABELS["sr-deepseek-v4-flash"]).toBe("DeepSeek V4 Flash");
|
|
1154
|
-
});
|
|
1155
|
-
});
|
|
1156
|
-
|
|
1157
|
-
describe("menu stays curated — new OpenRouter models are display-only, not in the picker", () => {
|
|
1158
|
-
// The 8 new models are typeable (manual passthrough) but must NOT bloat the
|
|
1159
|
-
// /model keyboard. externalModelNames() seeds the picker from SR_MODEL_ALIASES
|
|
1160
|
-
// values, so these ids must be ABSENT from both the alias table and the picker
|
|
1161
|
-
// list. This locks in Ken's "menu = main models only" decision.
|
|
1162
|
-
const NEW_SR = [
|
|
1163
|
-
"sr-gpt-oss-20b",
|
|
1164
|
-
"sr-gpt-oss-120b",
|
|
1165
|
-
"sr-gpt-5.5",
|
|
1166
|
-
"sr-gpt-5-codex",
|
|
1167
|
-
"sr-gpt-5.2-codex",
|
|
1168
|
-
"sr-gemini-flash-lite",
|
|
1169
|
-
"sr-minimax-m3",
|
|
1170
|
-
"sr-deepseek-v4-flash",
|
|
1171
|
-
];
|
|
1172
|
-
|
|
1173
|
-
it("SR_MODEL_ALIASES stays the curated 6-entry main set (no new short aliases)", () => {
|
|
1174
|
-
expect(Object.keys(SR_MODEL_ALIASES).sort()).toEqual(
|
|
1175
|
-
["codex", "deepseek", "flash", "gemini", "glm", "r1"],
|
|
1176
|
-
);
|
|
1177
|
-
// None of the new sr-* ids are an alias target.
|
|
1178
|
-
const targets = new Set(Object.values(SR_MODEL_ALIASES));
|
|
1179
|
-
for (const name of NEW_SR) {
|
|
1180
|
-
expect(targets.has(name), `${name} must NOT be an alias target`).toBe(false);
|
|
1181
|
-
}
|
|
1182
|
-
});
|
|
1183
|
-
|
|
1184
|
-
it("externalModelNames (picker seed) excludes the new models", () => {
|
|
1185
|
-
const picker = externalModelNames([]);
|
|
1186
|
-
for (const name of NEW_SR) {
|
|
1187
|
-
expect(picker.includes(name), `${name} must NOT appear in the picker`).toBe(false);
|
|
1188
|
-
}
|
|
1189
|
-
// The curated main set is still exactly the alias targets.
|
|
1190
|
-
expect(picker.sort()).toEqual([...new Set(Object.values(SR_MODEL_ALIASES))].sort());
|
|
1191
|
-
});
|
|
1192
|
-
});
|
|
1193
|
-
|
|
1194
|
-
describe("buildModelMenu — with sr-* models", () => {
|
|
1195
|
-
// sr-* models now come from discoverSrModels (LiteLLM), not the claude picker.
|
|
1196
|
-
function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
|
|
1197
|
-
return makeMenuDeps({
|
|
1198
|
-
discoverSrModels: async () => ["sr-gemini-2.5-pro", "sr-deepseek-r1"],
|
|
1199
|
-
...overrides,
|
|
1200
|
-
});
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
// Nested-page design (this PR): sr-* models no longer render inline on the
|
|
1204
|
-
// main page — they live behind the "🌐 External models ▸" button on a second
|
|
1205
|
-
// keyboard page. Live discoverSrModels() results are UNION-ed with the static
|
|
1206
|
-
// SR_MODEL_ALIASES seed, so the external page always has at least the six
|
|
1207
|
-
// curated aliases even when discovery returns [].
|
|
1208
|
-
|
|
1209
|
-
it("live-discovered sr-* models appear on the EXTERNAL page (not inline on main)", async () => {
|
|
1210
|
-
const { deps } = makeMenuDepsWithSr();
|
|
1211
|
-
const main = await buildModelMenu(deps, "main");
|
|
1212
|
-
const mainButtons = main.keyboard!.flat();
|
|
1213
|
-
// Not inline on the main page…
|
|
1214
|
-
expect(mainButtons.find((b) => b.text === "🌐 Gemini 2.5 Pro")).toBeUndefined();
|
|
1215
|
-
// …but the External-open button is present.
|
|
1216
|
-
expect(mainButtons.find((b) => b.callback_data === MODEL_CALLBACK_PAGE_EXTERNAL)).toBeDefined();
|
|
1217
|
-
|
|
1218
|
-
const ext = await buildModelMenu(deps, "external");
|
|
1219
|
-
const extButtons = ext.keyboard!.flat();
|
|
1220
|
-
expect(extButtons.find((b) => b.text === "🌐 Gemini 2.5 Pro")).toBeDefined();
|
|
1221
|
-
expect(extButtons.find((b) => b.text === "🌐 DeepSeek R1")).toBeDefined();
|
|
1222
|
-
// openrouter/* / non-sr-* never shown at all.
|
|
1223
|
-
expect(extButtons.find((b) => b.text.includes("openrouter"))).toBeUndefined();
|
|
1224
|
-
});
|
|
1225
|
-
|
|
1226
|
-
it("external-page sr-* buttons use the mdl:sr: callback prefix", async () => {
|
|
1227
|
-
const { deps } = makeMenuDepsWithSr();
|
|
1228
|
-
const menu = await buildModelMenu(deps, "external");
|
|
1229
|
-
const srButton = menu.keyboard!.flat().find((b) => b.text === "🌐 Gemini 2.5 Pro");
|
|
1230
|
-
expect(srButton?.callback_data).toBe(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`);
|
|
1231
|
-
});
|
|
1232
|
-
|
|
1233
|
-
it("external page has exactly one header row (billed-separately)", async () => {
|
|
1234
|
-
const { deps } = makeMenuDepsWithSr();
|
|
1235
|
-
const menu = await buildModelMenu(deps, "external");
|
|
1236
|
-
const headers = menu.keyboard!.flat().filter((b) => b.callback_data === MODEL_CALLBACK_HEADER);
|
|
1237
|
-
expect(headers.length).toBe(1);
|
|
1238
|
-
expect(headers[0].text).toContain("External");
|
|
1239
|
-
});
|
|
1240
|
-
|
|
1241
|
-
it("main page carries NO header rows (headers live on the external page)", async () => {
|
|
1242
|
-
const { deps } = makeMenuDeps();
|
|
1243
|
-
const menu = await buildModelMenu(deps, "main");
|
|
1244
|
-
const headers = (menu.keyboard ?? []).flat().filter((b) => b.callback_data === MODEL_CALLBACK_HEADER);
|
|
1245
|
-
expect(headers.length).toBe(0);
|
|
1246
|
-
});
|
|
1247
|
-
|
|
1248
|
-
it("header-row tap returns toastOnly without inject or model change", async () => {
|
|
1249
|
-
const { deps, injectCalls } = makeMenuDepsWithSr();
|
|
1250
|
-
const out = await handleModelMenuCallback(MODEL_CALLBACK_HEADER, deps);
|
|
1251
|
-
expect(out.toastOnly).toBe(true);
|
|
1252
|
-
expect(out.selectedModel).toBeUndefined();
|
|
1253
|
-
expect(injectCalls).toHaveLength(0);
|
|
1254
|
-
});
|
|
1255
|
-
|
|
1256
|
-
it("main page points at the External page for OpenRouter-billed models", async () => {
|
|
1257
|
-
const { deps } = makeMenuDepsWithSr();
|
|
1258
|
-
const menu = await buildModelMenu(deps, "main");
|
|
1259
|
-
expect(menu.text).toContain("Max/Pro subscription");
|
|
1260
|
-
expect(menu.text).toContain("External models");
|
|
1261
|
-
});
|
|
1262
|
-
});
|
|
1263
|
-
|
|
1264
|
-
describe("handleModelMenuCallback — sr-* selection", () => {
|
|
1265
|
-
function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
|
|
1266
|
-
return makeMenuDeps({
|
|
1267
|
-
discoverSrModels: async () => ["sr-gemini-2.5-pro", "sr-deepseek-r1"],
|
|
1268
|
-
...overrides,
|
|
1269
|
-
});
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
it("sr-* tap delegates to the carrier relaunch — never a picker-rejecting inject", async () => {
|
|
1273
|
-
// The gateway intercepts mdl:sr: before this function; if a direct caller
|
|
1274
|
-
// reaches it, delegating to scheduleModelRelaunch is the ONLY safe path (an
|
|
1275
|
-
// inject of `/model sr-*` 4xxs — picker rejects the id, base-URL never repointed).
|
|
1276
|
-
const relaunch: Array<{ model: string }> = [];
|
|
1277
|
-
const { deps, calls, injectCalls } = makeMenuDepsWithSr({
|
|
1278
|
-
scheduleModelRelaunch: async (model) => { relaunch.push({ model }); },
|
|
1279
|
-
});
|
|
1280
|
-
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
|
|
1281
|
-
// No inject, no cursor nav — a carrier relaunch on the exact sr-* id.
|
|
1282
|
-
expect(injectCalls).toHaveLength(0);
|
|
1283
|
-
expect(calls.select).toHaveLength(0);
|
|
1284
|
-
expect(relaunch).toEqual([{ model: "sr-gemini-2.5-pro" }]);
|
|
1285
|
-
expect(out.selectedModel).toBe("sr-gemini-2.5-pro");
|
|
1286
|
-
expect(out.reply.keyboard).toBeUndefined();
|
|
1287
|
-
expect(out.reply.text).toContain('🔄');
|
|
1288
|
-
expect(out.reply.text).not.toContain('picker unavailable');
|
|
1289
382
|
});
|
|
1290
383
|
|
|
1291
|
-
it("
|
|
1292
|
-
const
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
scheduleModelRelaunch: async (model) => { relaunch.push(model); },
|
|
1296
|
-
});
|
|
1297
|
-
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
|
|
1298
|
-
expect(out.toastOnly).toBe(true);
|
|
1299
|
-
expect(injectCalls).toHaveLength(0);
|
|
1300
|
-
expect(relaunch).toHaveLength(0);
|
|
1301
|
-
});
|
|
1302
|
-
|
|
1303
|
-
it("rejects malformed sr-* callback data", async () => {
|
|
1304
|
-
const { deps } = makeMenuDepsWithSr();
|
|
1305
|
-
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}bad name with spaces`, deps);
|
|
1306
|
-
expect(out.answer).toBe("Invalid model name");
|
|
1307
|
-
});
|
|
1308
|
-
});
|
|
1309
|
-
|
|
1310
|
-
// ---------------------------------------------------------------------------
|
|
1311
|
-
// isSrToClaudeTransition helper (used by gateway callback handler)
|
|
1312
|
-
// ---------------------------------------------------------------------------
|
|
1313
|
-
|
|
1314
|
-
describe("isSrToClaudeTransition", () => {
|
|
1315
|
-
it("true when prev is sr-* and next is not sr-*", () => {
|
|
1316
|
-
expect(isSrToClaudeTransition("sr-gemini-2.5-pro", "Haiku 4.5")).toBe(true);
|
|
1317
|
-
expect(isSrToClaudeTransition("sr-deepseek-r1", "Fable 5")).toBe(true);
|
|
1318
|
-
expect(isSrToClaudeTransition("sr-deepseek-r1", "claude-opus-4-8")).toBe(true);
|
|
1319
|
-
});
|
|
1320
|
-
|
|
1321
|
-
it("false when prev is not sr-* (Claude → Claude)", () => {
|
|
1322
|
-
expect(isSrToClaudeTransition("Opus 4.8", "Haiku 4.5")).toBe(false);
|
|
1323
|
-
expect(isSrToClaudeTransition(null, "Sonnet")).toBe(false);
|
|
1324
|
-
expect(isSrToClaudeTransition(undefined, "Sonnet")).toBe(false);
|
|
1325
|
-
});
|
|
1326
|
-
|
|
1327
|
-
it("false when prev is sr-* but next is also sr-* (sr-* → sr-*)", () => {
|
|
1328
|
-
expect(isSrToClaudeTransition("sr-gemini-2.5-pro", "sr-deepseek-r1")).toBe(false);
|
|
1329
|
-
});
|
|
1330
|
-
|
|
1331
|
-
// #3242 review FIX 1 — optimisticModelRecordLabel must NOT de-prefix an sr-*
|
|
1332
|
-
// token: the stored selectedModel doubles as the sr-*→Claude sentinel that
|
|
1333
|
-
// isSrToClaudeTransition (prevModel.startsWith('sr-')) reads. De-prefixing to
|
|
1334
|
-
// a friendly label ("kimi k2") would silently kill the graceful restart that
|
|
1335
|
-
// tears down LiteLLM routing on a subsequent Claude switch.
|
|
1336
|
-
it("optimisticModelRecordLabel keeps sr-* tokens verbatim so the sentinel survives", () => {
|
|
1337
|
-
const recorded = optimisticModelRecordLabel("sr-kimi-k2");
|
|
1338
|
-
expect(recorded).toBe("sr-kimi-k2"); // NOT "kimi k2"
|
|
1339
|
-
// The recorded value still trips the sr→Claude transition on a later switch.
|
|
1340
|
-
expect(isSrToClaudeTransition(recorded, "opus")).toBe(true);
|
|
1341
|
-
// Regression guard: the de-prefixed form would NOT — that was the bug.
|
|
1342
|
-
expect(isSrToClaudeTransition("kimi k2", "opus")).toBe(false);
|
|
1343
|
-
});
|
|
1344
|
-
|
|
1345
|
-
it("optimisticModelRecordLabel Title-cases a bare Claude alias, leaves full ids as-is", () => {
|
|
1346
|
-
expect(optimisticModelRecordLabel("fable")).toBe("Fable");
|
|
1347
|
-
expect(optimisticModelRecordLabel("opus")).toBe("Opus");
|
|
1348
|
-
expect(optimisticModelRecordLabel("claude-opus-4-8")).toBe("claude-opus-4-8");
|
|
1349
|
-
});
|
|
1350
|
-
|
|
1351
|
-
it("false when switching to sr-* from Claude (Claude → sr-*)", () => {
|
|
1352
|
-
expect(isSrToClaudeTransition("Sonnet", "sr-gemini-2.5-pro")).toBe(false);
|
|
1353
|
-
});
|
|
1354
|
-
});
|
|
1355
|
-
|
|
1356
|
-
// ---------------------------------------------------------------------------
|
|
1357
|
-
// Paginated picker — Fable in the Claude group + nested External page.
|
|
1358
|
-
// ---------------------------------------------------------------------------
|
|
1359
|
-
|
|
1360
|
-
describe("externalModelNames", () => {
|
|
1361
|
-
it("seeds from SR_MODEL_ALIASES values even when discovery is empty", () => {
|
|
1362
|
-
const names = externalModelNames([]);
|
|
1363
|
-
for (const target of Object.values(SR_MODEL_ALIASES)) {
|
|
1364
|
-
expect(names).toContain(target);
|
|
1365
|
-
}
|
|
1366
|
-
// All six curated aliases, deduped.
|
|
1367
|
-
expect(names.length).toBe(new Set(Object.values(SR_MODEL_ALIASES)).size);
|
|
1368
|
-
expect(names).toEqual([...names].sort());
|
|
1369
|
-
});
|
|
1370
|
-
|
|
1371
|
-
it("unions live discovery, dedupes, and drops non-sr-* names", () => {
|
|
1372
|
-
const names = externalModelNames(["sr-brand-new", "sr-glm-5", "gpt-4o", "voyage-law-2"]);
|
|
1373
|
-
expect(names).toContain("sr-brand-new");
|
|
1374
|
-
expect(names).toContain("sr-glm-5");
|
|
1375
|
-
// sr-glm-5 already came from aliases — deduped, not doubled.
|
|
1376
|
-
expect(names.filter((n) => n === "sr-glm-5").length).toBe(1);
|
|
1377
|
-
// Non-sr-* names never surface (subscription-honest).
|
|
1378
|
-
expect(names).not.toContain("gpt-4o");
|
|
1379
|
-
expect(names).not.toContain("voyage-law-2");
|
|
384
|
+
it("page-nav swaps the keyboard without scheduling", async () => {
|
|
385
|
+
const { deps, relaunchCalls } = makeMenuDeps();
|
|
386
|
+
await handleModelMenuCallback(MODEL_CALLBACK_PAGE_EXTERNAL, deps);
|
|
387
|
+
expect(relaunchCalls).toHaveLength(0);
|
|
1380
388
|
});
|
|
1381
389
|
});
|
|
1382
390
|
|
|
1383
|
-
describe("
|
|
1384
|
-
it("
|
|
1385
|
-
const { deps } = makeMenuDeps();
|
|
391
|
+
describe("buildModelMenu — render only (discovery is not the switch path)", () => {
|
|
392
|
+
it("renders current model, quota, and one button per option", async () => {
|
|
393
|
+
const { deps, calls } = makeMenuDeps();
|
|
1386
394
|
const menu = await buildModelMenu(deps);
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
expect(
|
|
1390
|
-
expect(
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
expect(
|
|
1394
|
-
|
|
1395
|
-
expect(menu.keyboard![menu.keyboard!.length - 1][0].callback_data).toBe(
|
|
1396
|
-
MODEL_CALLBACK_REFRESH,
|
|
1397
|
-
);
|
|
395
|
+
expect(calls.discover).toBe(1);
|
|
396
|
+
expect(menu.text).toContain("**Sonnet**");
|
|
397
|
+
expect(menu.text).toContain("29% / 5h · 33% / 7d");
|
|
398
|
+
expect(menu.keyboard).toBeDefined();
|
|
399
|
+
// SELECT rows now carry canonical tokens, not label hashes.
|
|
400
|
+
const selectRows = menu.keyboard!.flat().map((b) => b.callback_data).filter((d) => d.startsWith("mdl:s:"));
|
|
401
|
+
expect(selectRows).toContain("mdl:s:default");
|
|
402
|
+
expect(selectRows).toContain("mdl:s:haiku");
|
|
1398
403
|
});
|
|
1399
404
|
|
|
1400
|
-
it("
|
|
1401
|
-
// Force externalModelNames to be empty by stubbing SR aliases away is not
|
|
1402
|
-
// possible (static), but a build with an empty alias set is covered by the
|
|
1403
|
-
// externalModelNames unit test. Here we assert the button is gated on the
|
|
1404
|
-
// list being non-empty via the real (non-empty) path: it IS present.
|
|
405
|
+
it("every callback_data fits Telegram's 64-byte cap", async () => {
|
|
1405
406
|
const { deps } = makeMenuDeps();
|
|
1406
407
|
const menu = await buildModelMenu(deps);
|
|
1407
|
-
const
|
|
1408
|
-
|
|
1409
|
-
});
|
|
1410
|
-
|
|
1411
|
-
it("dedupes the static Fable row if the scraped options already include Fable", async () => {
|
|
1412
|
-
const { deps } = makeMenuDeps({
|
|
1413
|
-
discover: async () => ({
|
|
1414
|
-
ok: true as const,
|
|
1415
|
-
options: [
|
|
1416
|
-
{ index: 1, label: "Sonnet", detail: "", current: true },
|
|
1417
|
-
{ index: 2, label: "Fable", detail: "Fable 5", current: false },
|
|
1418
|
-
],
|
|
1419
|
-
currentLabel: "Sonnet",
|
|
1420
|
-
}),
|
|
1421
|
-
});
|
|
1422
|
-
const menu = await buildModelMenu(deps);
|
|
1423
|
-
const flat = menu.keyboard!.flat();
|
|
1424
|
-
// Exactly one Fable button, and it's the scraped (select) one, not the alias.
|
|
1425
|
-
const fables = flat.filter((b) => b.text === "Fable" || b.text === "✅ Fable");
|
|
1426
|
-
expect(fables.length).toBe(1);
|
|
1427
|
-
expect(fables[0].callback_data.startsWith(MODEL_CALLBACK_ALIAS)).toBe(false);
|
|
1428
|
-
});
|
|
1429
|
-
|
|
1430
|
-
it("every callback_data still fits Telegram's 64-byte cap", async () => {
|
|
1431
|
-
const { deps } = makeMenuDeps();
|
|
1432
|
-
for (const page of ["main", "external"] as const) {
|
|
1433
|
-
const menu = await buildModelMenu(deps, page);
|
|
1434
|
-
for (const btn of menu.keyboard!.flat()) {
|
|
408
|
+
for (const row of menu.keyboard!) {
|
|
409
|
+
for (const btn of row) {
|
|
1435
410
|
expect(Buffer.byteLength(btn.callback_data, "utf-8")).toBeLessThanOrEqual(64);
|
|
1436
411
|
}
|
|
1437
412
|
}
|
|
1438
413
|
});
|
|
1439
|
-
});
|
|
1440
414
|
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
const
|
|
1444
|
-
|
|
1445
|
-
const
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
expect(btn, `missing external button for ${target}`).toBeDefined();
|
|
1449
|
-
expect(btn!.text.startsWith("🌐")).toBe(true);
|
|
1450
|
-
}
|
|
1451
|
-
expect(flat.some((b) => b.callback_data === MODEL_CALLBACK_PAGE_MAIN)).toBe(true);
|
|
1452
|
-
expect(flat.some((b) => b.callback_data === MODEL_CALLBACK_REFRESH)).toBe(true);
|
|
1453
|
-
});
|
|
1454
|
-
|
|
1455
|
-
it("external page body text makes the billed-separately split explicit", async () => {
|
|
1456
|
-
const { deps } = makeMenuDeps();
|
|
1457
|
-
const menu = await buildModelMenu(deps, "external");
|
|
1458
|
-
expect(menu.text).toContain("billed separately");
|
|
1459
|
-
expect(menu.text).toContain("OpenRouter");
|
|
1460
|
-
expect(menu.text).toContain("subscription");
|
|
415
|
+
it("busy agent → static keyboard, no discovery", async () => {
|
|
416
|
+
const { deps, calls } = makeMenuDeps({ isBusy: () => true });
|
|
417
|
+
const menu = await buildModelMenu(deps);
|
|
418
|
+
expect(calls.discover).toBe(0);
|
|
419
|
+
const data = menu.keyboard!.flat().map((b) => b.callback_data);
|
|
420
|
+
expect(data).toContain("mdl:alias:opus");
|
|
421
|
+
expect(data).toContain("mdl:alias:default");
|
|
1461
422
|
});
|
|
1462
423
|
});
|
|
1463
424
|
|
|
1464
|
-
|
|
1465
|
-
it("PAGE_EXTERNAL renders the external page and does NOT select/inject", async () => {
|
|
1466
|
-
const { deps, calls, injectCalls } = makeMenuDeps();
|
|
1467
|
-
const out = await handleModelMenuCallback(MODEL_CALLBACK_PAGE_EXTERNAL, deps);
|
|
1468
|
-
expect(calls.select).toEqual([]);
|
|
1469
|
-
expect(injectCalls).toEqual([]);
|
|
1470
|
-
expect(out.selectedModel).toBeUndefined();
|
|
1471
|
-
const flat = out.reply.keyboard!.flat();
|
|
1472
|
-
expect(flat.some((b) => b.callback_data === MODEL_CALLBACK_PAGE_MAIN)).toBe(true);
|
|
1473
|
-
expect(out.reply.text).toContain("billed separately");
|
|
1474
|
-
});
|
|
425
|
+
// ─── Preserved pure-logic helpers ───────────────────────────────────────────
|
|
1475
426
|
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
expect(
|
|
1480
|
-
expect(
|
|
1481
|
-
expect(out.selectedModel).toBeUndefined();
|
|
1482
|
-
const flat = out.reply.keyboard!.flat();
|
|
1483
|
-
expect(flat.some((b) => b.callback_data === MODEL_CALLBACK_PAGE_EXTERNAL)).toBe(true);
|
|
1484
|
-
expect(flat.some((b) => b.text === "Fable")).toBe(true);
|
|
427
|
+
describe("canonicalClaudeToken", () => {
|
|
428
|
+
it("aliases → alias, ids → id, Default → null", () => {
|
|
429
|
+
expect(canonicalClaudeToken("Opus")).toBe("opus");
|
|
430
|
+
expect(canonicalClaudeToken("claude-opus-4-8")).toBe("claude-opus-4-8");
|
|
431
|
+
expect(canonicalClaudeToken("Default (recommended)")).toBeNull();
|
|
1485
432
|
});
|
|
1486
433
|
});
|
|
1487
434
|
|
|
1488
|
-
describe("
|
|
1489
|
-
it("
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
expect(calls.select).toEqual([]);
|
|
1494
|
-
expect(injectCalls).toHaveLength(1);
|
|
1495
|
-
expect(injectCalls[0].command).toBe("/model fable");
|
|
1496
|
-
expect(out.reply.text).toContain("✅");
|
|
435
|
+
describe("SR_MODEL_ALIASES / expandSrAlias", () => {
|
|
436
|
+
it("expands known aliases, passes others through", () => {
|
|
437
|
+
expect(expandSrAlias("flash")).toBe("sr-gemini-2.5-flash");
|
|
438
|
+
expect(expandSrAlias("opus")).toBe("opus");
|
|
439
|
+
expect(expandSrAlias("sr-glm-5")).toBe("sr-glm-5");
|
|
1497
440
|
});
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
expect(EXTRA_CLAUDE_ALIASES.some((a) => a.alias === "fable" && a.label === "Fable")).toBe(true);
|
|
441
|
+
it("every alias target is a sr-* id", () => {
|
|
442
|
+
for (const target of Object.values(SR_MODEL_ALIASES)) expect(isSrModel(target)).toBe(true);
|
|
1501
443
|
});
|
|
444
|
+
});
|
|
1502
445
|
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
const
|
|
1506
|
-
|
|
1507
|
-
|
|
446
|
+
describe("classifyDiscoveredOptions", () => {
|
|
447
|
+
it("splits native Claude rows from sr-* rows and drops routing paths", () => {
|
|
448
|
+
const { claude, sr } = classifyDiscoveredOptions([
|
|
449
|
+
{ index: 1, label: "Opus", current: false },
|
|
450
|
+
{ index: 2, label: "sr-glm-5", current: false },
|
|
451
|
+
{ index: 3, label: "openrouter/foo", current: false },
|
|
452
|
+
{ index: 4, label: "gpt-4o", current: false },
|
|
453
|
+
]);
|
|
454
|
+
expect(claude.map((o) => o.label)).toEqual(["Opus"]);
|
|
455
|
+
expect(sr.map((o) => o.label)).toEqual(["sr-glm-5"]);
|
|
1508
456
|
});
|
|
1509
457
|
});
|
|
1510
458
|
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
1517
|
-
expect(isBusyRefusalText(reply.text)).toBe(true);
|
|
459
|
+
describe("externalModelNames / SR_MODEL_LABELS / EXTRA_CLAUDE_ALIASES", () => {
|
|
460
|
+
it("seeds from the curated alias targets and merges discovered sr-*", () => {
|
|
461
|
+
const names = externalModelNames(["sr-gpt-oss-20b"]);
|
|
462
|
+
expect(names).toContain("sr-gemini-2.5-flash");
|
|
463
|
+
expect(names).toContain("sr-gpt-oss-20b");
|
|
1518
464
|
});
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
expect(
|
|
465
|
+
it("Fable is offered as an extra Claude alias", () => {
|
|
466
|
+
expect(EXTRA_CLAUDE_ALIASES.some((a) => a.alias === "fable")).toBe(true);
|
|
467
|
+
});
|
|
468
|
+
it("labels are display-only strings", () => {
|
|
469
|
+
expect(SR_MODEL_LABELS["sr-glm-5"]).toBeTypeOf("string");
|
|
1524
470
|
});
|
|
1525
471
|
});
|
|
1526
472
|
|
|
1527
|
-
|
|
1528
473
|
describe("isOfflineTrustedModelToken (#3042 blocker 2a)", () => {
|
|
1529
|
-
it("trusts static
|
|
1530
|
-
|
|
1531
|
-
// Curated sr-* aliases resolve by construction (present in the LiteLLM config).
|
|
1532
|
-
const [alias, target] = Object.entries(SR_MODEL_ALIASES)[0];
|
|
1533
|
-
expect(isOfflineTrustedModelToken(alias)).toBe(true);
|
|
1534
|
-
expect(isOfflineTrustedModelToken(target)).toBe(true);
|
|
1535
|
-
});
|
|
1536
|
-
|
|
1537
|
-
it("refuses hand-typed full ids — shape-valid garbage must never reach a boot carrier unconfirmed", () => {
|
|
1538
|
-
expect(isOfflineTrustedModelToken("claude-nonexistnet-9")).toBe(false);
|
|
1539
|
-
expect(isOfflineTrustedModelToken("claude-opus-4-8")).toBe(false); // real but unverifiable offline
|
|
1540
|
-
expect(isOfflineTrustedModelToken("sr-made-up/model")).toBe(false);
|
|
1541
|
-
expect(isOfflineTrustedModelToken("")).toBe(false);
|
|
1542
|
-
});
|
|
1543
|
-
|
|
1544
|
-
it("#3043 item 1: accepts case-variant aliases the live path already normalizes (OPUS, Sonnet)", () => {
|
|
1545
|
-
// The live accept path lowercases (isClaudeModel / expandSrAlias) so a
|
|
1546
|
-
// queued `/model OPUS` is accepted live — the persist gate must not then
|
|
1547
|
-
// refuse it with the over-conservative "couldn't verify" card.
|
|
1548
|
-
for (const a of MODEL_ALIASES) {
|
|
1549
|
-
expect(isOfflineTrustedModelToken(a.toUpperCase())).toBe(true);
|
|
1550
|
-
}
|
|
474
|
+
it("trusts static aliases + curated sr targets, refuses arbitrary ids", () => {
|
|
475
|
+
expect(isOfflineTrustedModelToken("opus")).toBe(true);
|
|
1551
476
|
expect(isOfflineTrustedModelToken("OPUS")).toBe(true);
|
|
1552
|
-
expect(isOfflineTrustedModelToken("
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
expect(isOfflineTrustedModelToken(
|
|
477
|
+
expect(isOfflineTrustedModelToken("flash")).toBe(true);
|
|
478
|
+
expect(isOfflineTrustedModelToken("sr-gemini-2.5-flash")).toBe(true);
|
|
479
|
+
expect(isOfflineTrustedModelToken("claude-random-9")).toBe(false);
|
|
480
|
+
expect(isOfflineTrustedModelToken("sr-obscure-xyz")).toBe(false);
|
|
1556
481
|
});
|
|
1557
482
|
});
|
|
1558
483
|
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
describe("isModelCommandBusy folds both busy signals", () => {
|
|
1566
|
-
it("is busy when the turn atom is set", () => {
|
|
1567
|
-
expect(isModelCommandBusy({ currentTurnActive: true, turnInFlight: false })).toBe(true);
|
|
1568
|
-
});
|
|
1569
|
-
|
|
1570
|
-
it("is busy when only the authoritative delivery-machine/approval gate is set", () => {
|
|
1571
|
-
// THE SWALLOW REGRESSION: the pre-fix handler gated on `currentTurn !==
|
|
1572
|
-
// null` ALONE. A session busy by the delivery-machine / pending-approval
|
|
1573
|
-
// gate while the turn atom is cleared (the recovered-late / premature-
|
|
1574
|
-
// turn-end window) read as idle, so the switch injected into a busy pane
|
|
1575
|
-
// and was swallowed as literal text. Folding turnInFlight closes it.
|
|
1576
|
-
expect(isModelCommandBusy({ currentTurnActive: false, turnInFlight: true })).toBe(true);
|
|
1577
|
-
});
|
|
484
|
+
describe("isBusyRefusalText (#3039)", () => {
|
|
485
|
+
it("matches the mid-turn refusal copy", () => {
|
|
486
|
+
expect(isBusyRefusalText("⏳ The agent is mid-turn — …")).toBe(true);
|
|
487
|
+
expect(isBusyRefusalText("done")).toBe(false);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
1578
490
|
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
});
|
|
491
|
+
describe("#3177 — routing decision helpers", () => {
|
|
492
|
+
it("isModelCommandBusy folds both signals", () => {
|
|
493
|
+
expect(isModelCommandBusy({ currentTurnActive: true, turnInFlight: false })).toBe(true);
|
|
494
|
+
expect(isModelCommandBusy({ currentTurnActive: false, turnInFlight: true })).toBe(true);
|
|
495
|
+
expect(isModelCommandBusy({ currentTurnActive: false, turnInFlight: false })).toBe(false);
|
|
1582
496
|
});
|
|
1583
497
|
|
|
1584
|
-
|
|
1585
|
-
const busyByAtom = { currentTurnActive: true, turnInFlight: false, menuEnabled: true };
|
|
1586
|
-
const busyByGate = { currentTurnActive: false, turnInFlight: true, menuEnabled: true };
|
|
498
|
+
it("planModelCommand routes each shape", () => {
|
|
1587
499
|
const idle = { currentTurnActive: false, turnInFlight: false, menuEnabled: true };
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
500
|
+
expect(planModelCommand({ kind: "show" }, idle)).toEqual({ kind: "menu" });
|
|
501
|
+
expect(planModelCommand({ kind: "set", model: "opus" }, idle)).toEqual({
|
|
502
|
+
kind: "apply",
|
|
503
|
+
parsed: { kind: "set", model: "opus" },
|
|
1592
504
|
});
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
const d = planModelCommand({ kind: "set", model: "opus" }, busyByGate);
|
|
1598
|
-
expect(d).toEqual({ kind: "queue", target: "opus" });
|
|
1599
|
-
});
|
|
1600
|
-
|
|
1601
|
-
it("expands sr-* aliases into the queued target token", () => {
|
|
1602
|
-
const d = planModelCommand({ kind: "set", model: "flash" }, busyByAtom);
|
|
1603
|
-
expect(d).toEqual({ kind: "queue", target: "sr-gemini-2.5-flash" });
|
|
1604
|
-
});
|
|
1605
|
-
|
|
1606
|
-
it("APPLIES a set immediately when idle by both signals", () => {
|
|
1607
|
-
const d = planModelCommand({ kind: "set", model: "opus" }, idle);
|
|
1608
|
-
expect(d).toEqual({ kind: "apply", parsed: { kind: "set", model: "opus" } });
|
|
1609
|
-
});
|
|
1610
|
-
|
|
1611
|
-
it("renders the MENU for bare /model when the picker is enabled (even mid-turn)", () => {
|
|
1612
|
-
expect(planModelCommand({ kind: "show" }, busyByGate)).toEqual({ kind: "menu" });
|
|
1613
|
-
});
|
|
1614
|
-
|
|
1615
|
-
it("APPLIES the text show path when the picker is disabled", () => {
|
|
1616
|
-
const d = planModelCommand({ kind: "show" }, { ...idle, menuEnabled: false });
|
|
1617
|
-
expect(d).toEqual({ kind: "apply", parsed: { kind: "show" } });
|
|
1618
|
-
});
|
|
1619
|
-
|
|
1620
|
-
it("APPLIES help so a bad arg still gets an explicit reply", () => {
|
|
1621
|
-
const parsed = { kind: "help", reason: "not a valid model name: !!" } as const;
|
|
1622
|
-
expect(planModelCommand(parsed, busyByGate)).toEqual({ kind: "apply", parsed });
|
|
505
|
+
const busy = { currentTurnActive: true, turnInFlight: false, menuEnabled: true };
|
|
506
|
+
expect(planModelCommand({ kind: "set", model: "flash" }, busy)).toEqual({
|
|
507
|
+
kind: "queue",
|
|
508
|
+
target: "sr-gemini-2.5-flash",
|
|
1623
509
|
});
|
|
1624
510
|
});
|
|
1625
511
|
|
|
1626
|
-
|
|
1627
|
-
const
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
const r = resolveStaleAwareBusy({
|
|
1634
|
-
currentTurnActive: true,
|
|
1635
|
-
turnAgeMs: HARD_TTL + 1,
|
|
1636
|
-
machineInTurn: false,
|
|
1637
|
-
oldestPendingApprovalAgeMs: null,
|
|
1638
|
-
hardTtlMs: HARD_TTL,
|
|
1639
|
-
});
|
|
1640
|
-
expect(r.currentTurnActive).toBe(false);
|
|
1641
|
-
expect(r.turnInFlight).toBe(false);
|
|
1642
|
-
expect(r.clearStaleTurn).toBe(true);
|
|
1643
|
-
// Routing sees "idle" → applies, not queues.
|
|
1644
|
-
const d = planModelCommand(
|
|
1645
|
-
{ kind: "set", model: "opus" },
|
|
1646
|
-
{ currentTurnActive: r.currentTurnActive, turnInFlight: r.turnInFlight, menuEnabled: true },
|
|
1647
|
-
);
|
|
1648
|
-
expect(d).toEqual({ kind: "apply", parsed: { kind: "set", model: "opus" } });
|
|
1649
|
-
});
|
|
1650
|
-
|
|
1651
|
-
it("QUEUES a set when the turn is FRESH (marker within the TTL) — no regression", () => {
|
|
1652
|
-
const r = resolveStaleAwareBusy({
|
|
1653
|
-
currentTurnActive: true,
|
|
1654
|
-
turnAgeMs: 5_000,
|
|
1655
|
-
machineInTurn: false,
|
|
1656
|
-
oldestPendingApprovalAgeMs: null,
|
|
1657
|
-
hardTtlMs: HARD_TTL,
|
|
1658
|
-
});
|
|
1659
|
-
expect(r.currentTurnActive).toBe(true);
|
|
1660
|
-
expect(r.clearStaleTurn).toBe(false);
|
|
1661
|
-
const d = planModelCommand(
|
|
1662
|
-
{ kind: "set", model: "opus" },
|
|
1663
|
-
{ currentTurnActive: r.currentTurnActive, turnInFlight: r.turnInFlight, menuEnabled: true },
|
|
1664
|
-
);
|
|
1665
|
-
expect(d).toEqual({ kind: "queue", target: "opus" });
|
|
1666
|
-
});
|
|
1667
|
-
|
|
1668
|
-
it("APPLIES when only a WEDGED approval (older than the TTL) holds the gate on an idle session", () => {
|
|
1669
|
-
const r = resolveStaleAwareBusy({
|
|
1670
|
-
currentTurnActive: false,
|
|
1671
|
-
turnAgeMs: null,
|
|
1672
|
-
machineInTurn: false,
|
|
1673
|
-
oldestPendingApprovalAgeMs: HARD_TTL + 1,
|
|
1674
|
-
hardTtlMs: HARD_TTL,
|
|
1675
|
-
});
|
|
1676
|
-
expect(r.turnInFlight).toBe(false);
|
|
1677
|
-
expect(r.currentTurnActive).toBe(false);
|
|
1678
|
-
const d = planModelCommand(
|
|
1679
|
-
{ kind: "set", model: "opus" },
|
|
1680
|
-
{ currentTurnActive: r.currentTurnActive, turnInFlight: r.turnInFlight, menuEnabled: true },
|
|
1681
|
-
);
|
|
1682
|
-
expect(d).toEqual({ kind: "apply", parsed: { kind: "set", model: "opus" } });
|
|
1683
|
-
});
|
|
1684
|
-
|
|
1685
|
-
it("QUEUES when a RECENT pending approval holds the gate — a real block is preserved", () => {
|
|
1686
|
-
const r = resolveStaleAwareBusy({
|
|
1687
|
-
currentTurnActive: false,
|
|
1688
|
-
turnAgeMs: null,
|
|
1689
|
-
machineInTurn: false,
|
|
1690
|
-
oldestPendingApprovalAgeMs: 3_000,
|
|
1691
|
-
hardTtlMs: HARD_TTL,
|
|
1692
|
-
});
|
|
1693
|
-
expect(r.turnInFlight).toBe(true);
|
|
1694
|
-
const d = planModelCommand(
|
|
1695
|
-
{ kind: "set", model: "opus" },
|
|
1696
|
-
{ currentTurnActive: r.currentTurnActive, turnInFlight: r.turnInFlight, menuEnabled: true },
|
|
1697
|
-
);
|
|
1698
|
-
expect(d).toEqual({ kind: "queue", target: "opus" });
|
|
1699
|
-
});
|
|
1700
|
-
|
|
1701
|
-
it("QUEUES when the machine is genuinely in-turn regardless of atom age", () => {
|
|
1702
|
-
const r = resolveStaleAwareBusy({
|
|
1703
|
-
currentTurnActive: false,
|
|
1704
|
-
turnAgeMs: null,
|
|
1705
|
-
machineInTurn: true,
|
|
1706
|
-
oldestPendingApprovalAgeMs: null,
|
|
1707
|
-
hardTtlMs: HARD_TTL,
|
|
1708
|
-
});
|
|
1709
|
-
expect(r.turnInFlight).toBe(true);
|
|
1710
|
-
expect(r.clearStaleTurn).toBe(false);
|
|
1711
|
-
});
|
|
1712
|
-
|
|
1713
|
-
it("does NOT clear or discount when there is no turn atom", () => {
|
|
1714
|
-
const r = resolveStaleAwareBusy({
|
|
1715
|
-
currentTurnActive: false,
|
|
1716
|
-
turnAgeMs: null,
|
|
1717
|
-
machineInTurn: false,
|
|
1718
|
-
oldestPendingApprovalAgeMs: null,
|
|
1719
|
-
hardTtlMs: HARD_TTL,
|
|
1720
|
-
});
|
|
1721
|
-
expect(r).toEqual({ currentTurnActive: false, turnInFlight: false, clearStaleTurn: false });
|
|
1722
|
-
});
|
|
1723
|
-
|
|
1724
|
-
it("keeps a non-null atom busy when its age is exactly at the ceiling (strict >)", () => {
|
|
1725
|
-
const r = resolveStaleAwareBusy({
|
|
1726
|
-
currentTurnActive: true,
|
|
1727
|
-
turnAgeMs: HARD_TTL,
|
|
1728
|
-
machineInTurn: false,
|
|
1729
|
-
oldestPendingApprovalAgeMs: null,
|
|
1730
|
-
hardTtlMs: HARD_TTL,
|
|
1731
|
-
});
|
|
1732
|
-
expect(r.currentTurnActive).toBe(true);
|
|
1733
|
-
expect(r.clearStaleTurn).toBe(false);
|
|
512
|
+
it("resolveStaleAwareBusy discounts a stale turn atom", () => {
|
|
513
|
+
const out = resolveStaleAwareBusy({
|
|
514
|
+
currentTurnActive: true,
|
|
515
|
+
turnAgeMs: 10 * 60_000,
|
|
516
|
+
machineInTurn: false,
|
|
517
|
+
oldestPendingApprovalAgeMs: null,
|
|
518
|
+
hardTtlMs: 5 * 60_000,
|
|
1734
519
|
});
|
|
520
|
+
expect(out.currentTurnActive).toBe(false);
|
|
521
|
+
expect(out.clearStaleTurn).toBe(true);
|
|
1735
522
|
});
|
|
1736
523
|
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
it("marks the show + help forms with placeholder args", () => {
|
|
1744
|
-
expect(modelCommandReceiptLine("finn", { kind: "show" }, false)).toContain("kind=show arg=(show)");
|
|
1745
|
-
expect(modelCommandReceiptLine("finn", { kind: "help" }, false)).toContain("kind=help arg=(help)");
|
|
1746
|
-
});
|
|
524
|
+
it("modelCommandReceiptLine is stable + greppable", () => {
|
|
525
|
+
const line = modelCommandReceiptLine("klanker", { kind: "set", model: "opus" }, false);
|
|
526
|
+
expect(line).toContain("gw /model received");
|
|
527
|
+
expect(line).toContain("agent=klanker");
|
|
528
|
+
expect(line).toContain("arg=opus");
|
|
1747
529
|
});
|
|
1748
530
|
});
|