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.
@@ -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 headline guarantees:
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
- * 1. The bare `/model` form NEVER reaches the inject primitive —
7
- * with no argument claude renders an interactive picker modal
8
- * that Telegram can't drive (no arrows, no Esc), so injecting it
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, beforeAll } from "vitest";
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, calls, restartCalls, relaunchCalls };
53
+ return { deps, relaunchCalls, defaultRelaunchCalls, restartCalls };
65
54
  }
66
55
 
67
- describe("parseModelCommand", () => {
68
- it("returns null for non-/model text", () => {
69
- expect(parseModelCommand("/auth list")).toBeNull();
70
- expect(parseModelCommand("model sonnet")).toBeNull();
71
- expect(parseModelCommand("/modelx sonnet")).toBeNull();
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
- it("bare /model (and @botname form) parses as show", () => {
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
- it("single valid token parses as set", () => {
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
- it("rejects multi-token args (no second token can ride into the pane)", () => {
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
- it("rejects shell/control smuggling shapes", () => {
103
- for (const bad of [
104
- "/model $(reboot)",
105
- "/model `id`",
106
- "/model -opus", // leading dash — looks like a flag
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 and full ids", () => {
119
- for (const good of [...MODEL_ALIASES, "claude-opus-4-8", "claude-haiku-4-5-20251001", "claude-sonnet-5[1m]"]) {
120
- expect(isValidModelArg(good), good).toBe(true);
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, metacharacters, and over-long strings", () => {
133
- for (const bad of ["", " ", "a b", "a;b", "-x", "a".repeat(120), "a\tb", "a\nb", "/leading"]) {
134
- expect(isValidModelArg(bad), JSON.stringify(bad)).toBe(false);
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
- // Regression for the 2026-06-13 fleet outage: defaults.model was pinned to
140
- // the full codename `claude-fable-5`, which Anthropic retired server-side →
141
- // every agent 4xx'd. The fix is to select models by ALIAS (durable) instead
142
- // of pinned ids. This locks in that `fable` (and the other aliases) stay
143
- // selectable, and documents the alias-vs-codename distinction.
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("claude-sonnet-5[1m]")).toBe(true);
437
- expect(isClaudeModel("sr-gemini-2.5-pro")).toBe(false);
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
- describe("handleModelCommand — sr-* → Claude graceful restart", () => {
443
- it("carries the requested Claude model across the restart (relaunch carrier), never injects", async () => {
444
- const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
445
- getActiveSessionModel: () => "sr-gemini-2.5-pro",
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(relaunchCalls[0].reason).toContain("sr-to-claude");
457
- // Reply mentions the sr-* model and ~30s
458
- expect(reply.text).toContain("sr-gemini-2.5-pro");
459
- expect(reply.text).toContain("30s");
460
- expect(reply.html).toBe(true);
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("carries a full claude-* id across the restart when session is on sr-*", async () => {
464
- const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
465
- getActiveSessionModel: () => "sr-deepseek-r1",
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("does NOT restart when switching between Claude models (no sr-* session)", async () => {
477
- const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
478
- getActiveSessionModel: () => "Opus 4.8",
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("surfaces relaunch dispatch failures without propagating the error", async () => {
489
- const { deps, calls } = makeDeps({
490
- getActiveSessionModel: () => "sr-deepseek-r1",
491
- scheduleModelRelaunch: async () => { throw new Error("hostd unreachable"); },
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("null session model (no prior override) still uses normal inject path for Claude target", async () => {
516
- const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
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(calls).toHaveLength(1);
521
- expect(restartCalls).toHaveLength(0);
522
- expect(relaunchCalls).toHaveLength(0);
156
+ expect(relaunchCalls).toEqual([expect.objectContaining({ model: "opus" })]);
523
157
  });
524
- });
525
158
 
526
- describe("handleModelCommand — busy gate + honest unverified reporting", () => {
527
- it("refuses a typed switch while the agent is mid-turn (no inject, no relaunch)", async () => {
528
- const { deps, calls, relaunchCalls } = makeDeps({ isBusy: () => true });
529
- const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
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("mid-turn");
533
- expect(reply.selectedModel).toBeUndefined();
164
+ expect(reply.text).toContain("Reverting to the configured default");
534
165
  });
535
166
 
536
- it("refuses an sr-* switch too while mid-turn", async () => {
537
- const { deps, relaunchCalls } = makeDeps({ isBusy: () => true });
538
- const reply = await handleModelCommand({ kind: "set", model: "sr-glm-5" }, deps);
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("reports a claude error output as an HONEST failure, not a switch", async () => {
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
- inject: async () => okResult("Error: Model not found"),
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(reply.text).not.toContain("did not take");
575
- expect(reply.text).not.toContain("model not found");
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("recognises claude v2.1.205's real arg-form confirmation (⎿ glyph + 'and saved as your default')", async () => {
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
- inject: async () =>
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("parseModelCommand accepts arbitrary sr-* ids as a set command", () => {
715
- for (const name of ARBITRARY_SR) {
716
- expect(parseModelCommand(`/model ${name}`)).toEqual({ kind: "set", model: name });
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("inject allowlist contract", () => {
733
- it("/model stays on the inject allowlist (the set path depends on it)", async () => {
734
- const { INJECT_COMMANDS } = await import("../../src/agents/inject.js");
735
- expect(INJECT_COMMANDS.has("/model")).toBe(true);
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
- it("handleModelCommand with a Claude alias relaunches (carrier) when session is on sr-*", async () => {
789
- const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
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
- sessionModelFromConfirmation,
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 calls = { discover: 0, select: [] as string[] };
830
- const base = makeDeps(); // v1 deps (inject/getConfiguredModel/escapeHtml/preBlock)
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 { deps, calls, injectCalls: base.calls };
260
+ return {
261
+ deps,
262
+ calls,
263
+ relaunchCalls: base.relaunchCalls,
264
+ defaultRelaunchCalls: base.defaultRelaunchCalls,
265
+ };
847
266
  }
848
267
 
849
- describe("buildModelMenu", () => {
850
- it("renders current model, quota brief, and one button per discovered option", async () => {
851
- const { deps, calls } = makeMenuDeps();
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
- it("every callback_data fits Telegram's 64-byte cap", async () => {
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
- it("busy agent → no discovery, STATIC keyboard whose taps ride the queue (#3039)", async () => {
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
- it("quota failure never blocks the menu", async () => {
905
- const { deps } = makeMenuDeps({
906
- getQuotaBrief: async () => {
907
- throw new Error("broker down");
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("handleModelMenuCallback", () => {
917
- it("mdl:s:<tag> selects by re-discovered label", async () => {
918
- const { deps, calls } = makeMenuDeps();
919
- const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
920
- expect(calls.select).toEqual(["Haiku"]);
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
- it("selection failure surfaces the reason AND keeps the menu so the operator can retry", async () => {
955
- const { deps } = makeMenuDeps({
956
- select: async () => ({ ok: false as const, reason: "cursor verification failed" }),
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
- it("a successful switch banners the confirmation, keeps the menu, AND reports the live model for /status", async () => {
967
- const { deps } = makeMenuDeps({
968
- select: async () => ({ ok: true as const, confirmation: "Set model to Haiku 4.5 for this session only" }),
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(out.answer).toContain("Haiku 4.5");
972
- expect(out.reply.text).toContain("✅");
973
- expect(out.reply.text).toContain("Set model to Haiku 4.5");
974
- expect(out.reply.keyboard).toBeDefined();
975
- // The gateway records this so /status reflects the live session model.
976
- expect(out.selectedModel).toBe("Haiku 4.5");
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("tapping the Default row when already on it (Kept model as) records NO override", async () => {
980
- // Bug 6: "Kept model as X" is a no-change; the old code fell back to
981
- // target.label and stored "Default (recommended)" verbatim into /status.
982
- const { deps } = makeMenuDeps({
983
- select: async () => ({ ok: true as const, confirmation: "Kept model as Opus 4.8 (default)" }),
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("a real switch on the Default row stores a canonical token, never the display label", async () => {
993
- // The Default row confirms with a real model name; the /status value is that
994
- // name (never "Default (recommended)"), and the carrier token is canonical.
995
- const { deps } = makeMenuDeps({
996
- select: async () => ({ ok: true as const, confirmation: "Set model to Opus 4.8 for this session only" }),
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("selecting an alias row exposes a canonical --model token for the sr→claude carrier", async () => {
1005
- const { deps } = makeMenuDeps({
1006
- select: async () => ({ ok: true as const, confirmation: "Set model to Sonnet for this session" }),
1007
- });
1008
- const out = await handleModelMenuCallback(modelSelectCallbackData("Sonnet"), deps);
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
- // #3242 review MEDIUM 2 — the alias BUTTON (mdl:alias:<alias>, e.g. the Fable
1015
- // button) must be symmetric with the typed set path: record-on-send / retract-
1016
- // on-scraped-error, handling BOTH ok and ok_no_output. Before the fix a silent
1017
- // successful button-switch (ok_no_output, or ok with no confirmation line) fell
1018
- // through to "Switch failed" and dropped the override.
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("ok with no confirmation line (banner-only) → optimistic record", async () => {
1041
- const { deps } = makeMenuDeps({
1042
- inject: async () => okResult("⠋ extending Claude Fable 5 access…"),
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("scraped access-denial line via the button → failure, no override (retract)", async () => {
1052
- const { deps } = makeMenuDeps({
1053
- inject: async () => okResult("⎿ Fable is not available on your plan"),
1054
- });
1055
- const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
1056
- expect(out.selectedModel).toBeUndefined();
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("genuine confirmation via the button still records the display name", async () => {
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
- inject: async () => okResult("⎿ Set model to Fable 5 for this session"),
370
+ scheduleModelRelaunch: async () => { calls++; throw restartInFlight(); },
1063
371
  });
1064
372
  const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
1065
- expect(out.selectedModel).toBe("Fable 5");
1066
- expect(out.selectedModelToken).toBe("fable");
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("mdl:r re-renders the dashboard", async () => {
1087
- const { deps, calls } = makeMenuDeps();
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("sr-* tap while busy returns toast-only with no relaunch", async () => {
1292
- const relaunch: string[] = [];
1293
- const { deps, injectCalls } = makeMenuDepsWithSr({
1294
- isBusy: () => true,
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("paginated model menu — main page", () => {
1384
- it("main page includes a Fable button and an External-models-open button", async () => {
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
- const flat = menu.keyboard!.flat();
1388
- const fable = flat.find((b) => b.text === "Fable");
1389
- expect(fable).toBeDefined();
1390
- expect(fable!.callback_data).toBe(`${MODEL_CALLBACK_ALIAS}fable`);
1391
- const ext = flat.find((b) => b.callback_data === MODEL_CALLBACK_PAGE_EXTERNAL);
1392
- expect(ext).toBeDefined();
1393
- expect(ext!.text).toContain("External");
1394
- // Refresh is last.
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("no External-open button when there are no external models", async () => {
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 flat = menu.keyboard!.flat();
1408
- expect(flat.some((b) => b.callback_data === MODEL_CALLBACK_PAGE_EXTERNAL)).toBe(true);
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
- describe("paginated model menu — external page", () => {
1442
- it("lists all six SR_MODEL_ALIASES models plus a Back button", async () => {
1443
- const { deps } = makeMenuDeps();
1444
- const menu = await buildModelMenu(deps, "external");
1445
- const flat = menu.keyboard!.flat();
1446
- for (const target of Object.values(SR_MODEL_ALIASES)) {
1447
- const btn = flat.find((b) => b.callback_data === `${MODEL_CALLBACK_SR}${target}`);
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
- describe("page callbacks swap the keyboard without switching model", () => {
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
- it("PAGE_MAIN renders the main page and does NOT select/inject", async () => {
1477
- const { deps, calls, injectCalls } = makeMenuDeps();
1478
- const out = await handleModelMenuCallback(MODEL_CALLBACK_PAGE_MAIN, deps);
1479
- expect(calls.select).toEqual([]);
1480
- expect(injectCalls).toEqual([]);
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("Fable alias callback injects /model fable", () => {
1489
- it("injects exactly '/model fable' and reports the session model", async () => {
1490
- const { deps, calls, injectCalls } = makeMenuDeps();
1491
- const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
1492
- // Alias path uses inject, never the cursor-nav select path.
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
- it("EXTRA_CLAUDE_ALIASES contains fable", () => {
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
- it("rejects an invalid alias without injecting", async () => {
1504
- const { deps, injectCalls } = makeMenuDeps();
1505
- const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}bad name`, deps);
1506
- expect(injectCalls).toEqual([]);
1507
- expect(out.answer).toContain("Invalid");
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
- // ─── #3039: busy-refusal detection for the queued-command drain ──────────────
1512
-
1513
- describe("isBusyRefusalText (#3039)", () => {
1514
- it("matches the typed and menu busy refusals", async () => {
1515
- const deps = makeDeps({ isBusy: () => true }).deps;
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
- it("never matches a genuine confirmation or failure", () => {
1521
- expect(isBusyRefusalText("⏺ Set model to Opus 4.8")).toBe(false);
1522
- expect(isBusyRefusalText("✅ `/effort high` — Set effort level to high")).toBe(false);
1523
- expect(isBusyRefusalText("❌ Switch to opus failed: tmux session not found")).toBe(false);
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 Claude aliases and curated sr-* alias names/targets", () => {
1530
- for (const a of MODEL_ALIASES) expect(isOfflineTrustedModelToken(a)).toBe(true);
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("Sonnet")).toBe(true);
1553
- // Curated sr-* alias, upper-cased, still resolves.
1554
- const [alias] = Object.entries(SR_MODEL_ALIASES)[0];
1555
- expect(isOfflineTrustedModelToken(alias.toUpperCase())).toBe(true);
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
- // #3177 — a typed /model must NEVER be swallowed without trace when sent
1561
- // mid-turn. The routing decision folds BOTH busy signals, and every parsed
1562
- // shape maps to a visible action (menu / queue / apply — never "do nothing").
1563
- // ---------------------------------------------------------------------------
1564
- describe("#3177 typed /model never silently swallowed mid-turn", () => {
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
- it("is idle only when BOTH signals are clear", () => {
1580
- expect(isModelCommandBusy({ currentTurnActive: false, turnInFlight: false })).toBe(false);
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
- describe("planModelCommand routes every shape to a visible action", () => {
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
- it("QUEUES a set while busy by the turn atom (ack, not silent inject)", () => {
1590
- const d = planModelCommand({ kind: "set", model: "opus" }, busyByAtom);
1591
- expect(d).toEqual({ kind: "queue", target: "opus" });
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
- it("QUEUES a set while busy by the delivery-machine/approval gate ONLY — the swallow case", () => {
1595
- // Sabotage-verify: revert the fix (gate on currentTurnActive alone) and
1596
- // this flips to { kind: 'apply' } → the direct-inject swallow returns.
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
- describe("resolveStaleAwareBusy — phantom 'active turn' fix (#3262)", () => {
1627
- const HARD_TTL = 10 * 60_000;
1628
-
1629
- it("APPLIES a set when the turn atom is DANGLING (older than the hard TTL)", () => {
1630
- // A currentTurn atom whose turn_end never fired: non-null but its
1631
- // liveness marker is far older than the ceiling. Discounted to idle,
1632
- // and flagged for the gateway to clear.
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
- describe("modelCommandReceiptLine — the durable, greppable entry trace", () => {
1738
- it("stamps agent, kind, arg, and busy for a typed set", () => {
1739
- const line = modelCommandReceiptLine("finn", { kind: "set", model: "opus" }, true);
1740
- expect(line).toBe("telegram gateway: gw /model received agent=finn kind=set arg=opus busy=true");
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
  });