switchroom 0.18.28 → 0.18.30

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.
Files changed (48) hide show
  1. package/bin/handoff-briefing.sh +15 -2
  2. package/dist/agent-scheduler/index.js +111 -7
  3. package/dist/auth-broker/index.js +154 -73
  4. package/dist/cli/autoaccept-poll.js +8 -3
  5. package/dist/cli/drive-write-pretool.mjs +8 -3
  6. package/dist/cli/ms-365-write-pretool.mjs +158 -11
  7. package/dist/cli/notion-write-pretool.mjs +103 -4
  8. package/dist/cli/switchroom.js +2712 -2219
  9. package/dist/host-control/main.js +110 -70
  10. package/dist/vault/approvals/kernel-server.js +116 -70
  11. package/dist/vault/broker/server.js +314 -202
  12. package/package.json +3 -3
  13. package/profiles/_base/start.sh.hbs +105 -34
  14. package/telegram-plugin/dist/bridge/bridge.js +71 -47
  15. package/telegram-plugin/dist/gateway/gateway.js +1128 -666
  16. package/telegram-plugin/dist/server.js +89 -64
  17. package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
  18. package/telegram-plugin/gateway/forward-origin.ts +9 -1
  19. package/telegram-plugin/gateway/gateway.ts +656 -388
  20. package/telegram-plugin/gateway/model-command.ts +331 -602
  21. package/telegram-plugin/gateway/session-model-file.ts +40 -0
  22. package/telegram-plugin/gateway/turn-record-status.ts +45 -0
  23. package/telegram-plugin/gateway/unhandled-message.ts +177 -0
  24. package/telegram-plugin/history.ts +153 -23
  25. package/telegram-plugin/llm-error-present.ts +24 -0
  26. package/telegram-plugin/model-unavailable.ts +55 -0
  27. package/telegram-plugin/operator-events.ts +113 -0
  28. package/telegram-plugin/pending-user-notice.ts +88 -0
  29. package/telegram-plugin/shared/local-time.ts +99 -0
  30. package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
  31. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
  32. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +30 -3
  34. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
  35. package/telegram-plugin/tests/history.test.ts +88 -0
  36. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
  37. package/telegram-plugin/tests/local-time.test.ts +135 -0
  38. package/telegram-plugin/tests/model-command.test.ts +427 -1512
  39. package/telegram-plugin/tests/session-model-file.test.ts +23 -0
  40. package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
  41. package/telegram-plugin/tier-downgrade.ts +4 -3
  42. package/telegram-plugin/turn-flush-safety.ts +25 -1
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
  44. package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
  45. package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
  46. package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
  47. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
  48. package/vendor/hindsight-memory/tests/test_content.py +63 -7
@@ -1,19 +1,21 @@
1
1
  /**
2
- * `/model` Telegram command — parser + handler coverage.
2
+ * `/model` Telegram command — parser + handler coverage (rev 5, DETERMINISTIC
3
+ * SWITCH). This suite encodes the CURRENT contract after the inject-into-tmux +
4
+ * terminal-scrape path was RETIRED (reference/rfcs/session-model-stickiness.md
5
+ * §0.05):
3
6
  *
4
- * The 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,
@@ -26,30 +28,19 @@ import {
26
28
  isClaudeModel,
27
29
  isBusyRefusalText,
28
30
  isOfflineTrustedModelToken,
31
+ classifyModelSwitchConfirmation,
32
+ parseModelSwitchTarget,
33
+ modelFamilyToken,
29
34
  MODEL_ALIASES,
30
35
  type ModelCommandDeps,
31
36
  } 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
37
 
38
+ /** A recorder-backed ModelCommandDeps. The inject/select deps are GONE (rev 5). */
44
39
  function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
45
- const calls: Array<{ agent: string; command: string }> = [];
46
- const restartCalls: string[] = [];
47
40
  const relaunchCalls: Array<{ model: string; reason: string }> = [];
41
+ const defaultRelaunchCalls: string[] = [];
42
+ const restartCalls: string[] = [];
48
43
  const deps: ModelCommandDeps = {
49
- inject: async (agent, command) => {
50
- calls.push({ agent, command });
51
- return okResult("⏺ Set model to sonnet");
52
- },
53
44
  getAgentName: () => "klanker",
54
45
  getConfiguredModel: () => "claude-sonnet-5",
55
46
  escapeHtml: (s) =>
@@ -59,765 +50,195 @@ function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
59
50
  isBusy: () => false,
60
51
  scheduleRestart: async (reason) => { restartCalls.push(reason); },
61
52
  scheduleModelRelaunch: async (model, reason) => { relaunchCalls.push({ model, reason }); },
53
+ scheduleModelDefaultRelaunch: async (reason) => { defaultRelaunchCalls.push(reason); },
62
54
  ...overrides,
63
55
  };
64
- return { deps, calls, restartCalls, relaunchCalls };
56
+ return { deps, relaunchCalls, defaultRelaunchCalls, restartCalls };
65
57
  }
66
58
 
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
- });
59
+ /** An error shaped like scheduleRestart's 15s-debounce throw. */
60
+ function restartInFlight(): Error {
61
+ const e = new Error("a restart is already in flight — try again in ~15s");
62
+ (e as { code?: string }).code = "restart_in_flight";
63
+ return e;
64
+ }
73
65
 
74
- it("bare /model (and @botname form) parses as show", () => {
66
+ describe("parseModelCommand", () => {
67
+ it("bare /model → show", () => {
75
68
  expect(parseModelCommand("/model")).toEqual({ kind: "show" });
76
- expect(parseModelCommand("/model@klanker_bot")).toEqual({ kind: "show" });
77
- expect(parseModelCommand("/model ")).toEqual({ kind: "show" });
78
69
  });
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
- });
70
+ it("/model opus → set opus", () => {
71
+ expect(parseModelCommand("/model opus")).toEqual({ kind: "set", model: "opus" });
91
72
  });
92
-
93
- it("/model help parses as help", () => {
73
+ it("/model help → help", () => {
94
74
  expect(parseModelCommand("/model help")).toEqual({ kind: "help" });
95
75
  });
96
-
97
- it("rejects multi-token args (no second token can ride into the pane)", () => {
98
- const p = parseModelCommand("/model sonnet; rm -rf /");
76
+ it("two args → help with reason", () => {
77
+ const p = parseModelCommand("/model opus sonnet");
99
78
  expect(p?.kind).toBe("help");
100
79
  });
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
- }
114
- });
115
- });
116
-
117
- 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);
130
- }
80
+ it("invalid arg → help with reason", () => {
81
+ const p = parseModelCommand("/model bad name!");
82
+ expect(p?.kind).toBe("help");
131
83
  });
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);
135
- }
84
+ it("non-/model text → null", () => {
85
+ expect(parseModelCommand("hello")).toBeNull();
136
86
  });
137
87
  });
138
88
 
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);
89
+ describe("isValidModelArg", () => {
90
+ it("accepts aliases + full ids + sr-*", () => {
91
+ for (const a of ["opus", "sonnet", "haiku", "fable", "claude-opus-4-8", "sr-glm-5", "sr-gpt-5.5"]) {
92
+ expect(isValidModelArg(a)).toBe(true);
151
93
  }
152
94
  });
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 });
95
+ it("rejects whitespace / control / shell smuggling", () => {
96
+ for (const bad of ["a b", "opus\n", "opus;rm", "", " "]) {
97
+ expect(isValidModelArg(bad)).toBe(false);
158
98
  }
159
99
  });
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
100
  });
421
101
 
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
- }
102
+ describe("isSrModel / isClaudeModel", () => {
103
+ it("classifies", () => {
104
+ expect(isSrModel("sr-glm-5")).toBe(true);
105
+ expect(isSrModel("opus")).toBe(false);
106
+ expect(isClaudeModel("opus")).toBe(true);
435
107
  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);
108
+ expect(isClaudeModel("fable")).toBe(true);
109
+ expect(isClaudeModel("sr-glm-5")).toBe(false);
439
110
  });
440
111
  });
441
112
 
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
- });
113
+ // ─── The headline rev-5 contract: every switch relaunches, nothing injects ───
114
+
115
+ describe("handleModelCommand — set — deterministic carrier relaunch (rev 5)", () => {
116
+ it("Claude→Claude `/model opus` (idle) calls scheduleModelRelaunch('opus') EXACTLY ONCE", async () => {
117
+ // FAILS on old code: Claude→Claude went through the inject+scrape path and
118
+ // NEVER called scheduleModelRelaunch.
119
+ const { deps, relaunchCalls, defaultRelaunchCalls } = makeDeps();
447
120
  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
121
  expect(relaunchCalls).toHaveLength(1);
455
122
  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);
123
+ expect(defaultRelaunchCalls).toHaveLength(0);
124
+ expect(reply.text).toContain("relaunching the session");
125
+ // No optimistic / scrape-derived fields on the reply (the retired-path guard).
126
+ expect((reply as Record<string, unknown>).selectedModel).toBeUndefined();
127
+ expect((reply as Record<string, unknown>).optimistic).toBeUndefined();
128
+ });
129
+
130
+ it("Claude→Claude with a full id relaunches on that id", async () => {
131
+ const { deps, relaunchCalls } = makeDeps();
132
+ await handleModelCommand({ kind: "set", model: "claude-opus-4-8" }, deps);
133
+ expect(relaunchCalls).toEqual([
134
+ expect.objectContaining({ model: "claude-opus-4-8" }),
135
+ ]);
461
136
  });
462
137
 
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");
138
+ it("sr-* typed `/model sr-glm-5` relaunches once (regression guard)", async () => {
139
+ const { deps, relaunchCalls } = makeDeps();
140
+ await handleModelCommand({ kind: "set", model: "sr-glm-5" }, deps);
141
+ expect(relaunchCalls).toEqual([expect.objectContaining({ model: "sr-glm-5" })]);
474
142
  });
475
143
 
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");
144
+ it("non-Claude token passes the RAW token through (accepted, not picker-validated)", async () => {
145
+ const { deps, relaunchCalls } = makeDeps();
146
+ await handleModelCommand({ kind: "set", model: "sr-gpt-5.5" }, deps);
147
+ expect(relaunchCalls[0].model).toBe("sr-gpt-5.5");
486
148
  });
487
149
 
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");
150
+ it("short alias expands before relaunch (`flash` → `sr-gemini-2.5-flash`)", async () => {
151
+ const { deps, relaunchCalls } = makeDeps();
152
+ await handleModelCommand({ kind: "set", model: "flash" }, deps);
153
+ expect(relaunchCalls[0].model).toBe("sr-gemini-2.5-flash");
513
154
  });
514
155
 
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
- });
156
+ it("sr-*→Claude relaunches on the Claude token (no special inject path)", async () => {
157
+ const { deps, relaunchCalls } = makeDeps({ getActiveSessionModel: () => "sr-deepseek-v3" });
519
158
  await handleModelCommand({ kind: "set", model: "opus" }, deps);
520
- expect(calls).toHaveLength(1);
521
- expect(restartCalls).toHaveLength(0);
522
- expect(relaunchCalls).toHaveLength(0);
159
+ expect(relaunchCalls).toEqual([expect.objectContaining({ model: "opus" })]);
523
160
  });
524
- });
525
161
 
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);
162
+ it("`/model default` calls scheduleModelDefaultRelaunch, NOT scheduleModelRelaunch", async () => {
163
+ const { deps, relaunchCalls, defaultRelaunchCalls } = makeDeps();
164
+ const reply = await handleModelCommand({ kind: "set", model: "default" }, deps);
165
+ expect(defaultRelaunchCalls).toHaveLength(1);
531
166
  expect(relaunchCalls).toHaveLength(0);
532
- expect(reply.text).toContain("mid-turn");
533
- expect(reply.selectedModel).toBeUndefined();
167
+ expect(reply.text).toContain("Reverting to the configured default");
534
168
  });
535
169
 
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);
170
+ it("busy session refuses without scheduling anything", async () => {
171
+ const { deps, relaunchCalls, defaultRelaunchCalls } = makeDeps({ isBusy: () => true });
172
+ const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
539
173
  expect(relaunchCalls).toHaveLength(0);
174
+ expect(defaultRelaunchCalls).toHaveLength(0);
540
175
  expect(reply.text).toContain("mid-turn");
541
176
  });
542
177
 
543
- it("reports a claude error output as an HONEST failure, not a switch", async () => {
544
- 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"),
572
- });
573
- 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);
582
- });
583
-
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.
178
+ it("debounce: a restart_in_flight throw yields the ~15s copy, not a double-dispatch", async () => {
179
+ let calls = 0;
589
180
  const { deps } = makeDeps({
590
- inject: async () =>
591
- okResult("⎿ Set model to Opus 4.8 and saved as your default for new sessions"),
181
+ scheduleModelRelaunch: async () => { calls++; throw restartInFlight(); },
592
182
  });
593
183
  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");
184
+ expect(calls).toBe(1);
185
+ expect(reply.text).toContain("~15s");
596
186
  });
597
187
 
598
- it("does NOT record an override when the confirmation is 'Kept model as' (no change)", async () => {
188
+ it("a generic dispatch failure surfaces an honest error", async () => {
599
189
  const { deps } = makeDeps({
600
- inject: async () => okResult("⏺ Kept model as Opus 4.8 (default)"),
190
+ scheduleModelRelaunch: async () => { throw new Error("hostd down"); },
601
191
  });
602
192
  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
193
  expect(reply.text).toContain("Could not schedule model switch");
663
- expect(reply.text).toContain("carrier write failed");
664
194
  });
665
- });
666
195
 
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);
196
+ it("the reply type carries no optimistic/selectedModel field on ANY set outcome", async () => {
197
+ // The retired-contract guard (F3): the fields were DELETED from the reply,
198
+ // so an unapplied switch can no longer be optimistically recorded.
199
+ const { deps } = makeDeps();
200
+ for (const model of ["opus", "sr-glm-5", "default", "fable"]) {
201
+ const reply = await handleModelCommand({ kind: "set", model }, deps);
202
+ expect(Object.prototype.hasOwnProperty.call(reply, "selectedModel")).toBe(false);
203
+ expect(Object.prototype.hasOwnProperty.call(reply, "optimistic")).toBe(false);
694
204
  }
695
205
  });
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
- });
713
-
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 });
717
- }
718
- });
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
- });
731
-
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
206
  });
738
207
 
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");
208
+ describe("handleModelCommand — show / help never schedule a switch", () => {
209
+ it("show renders the configured model + options, schedules nothing", async () => {
210
+ const { deps, relaunchCalls, defaultRelaunchCalls } = makeDeps();
211
+ const reply = await handleModelCommand({ kind: "show" }, deps);
212
+ expect(reply.text).toContain("claude-sonnet-5");
213
+ expect(relaunchCalls).toHaveLength(0);
214
+ expect(defaultRelaunchCalls).toHaveLength(0);
786
215
  });
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");
216
+ it("help lists the aliases including fable", async () => {
217
+ const reply = await handleModelCommand({ kind: "help" }, makeDeps().deps);
218
+ for (const a of MODEL_ALIASES) expect(reply.text).toContain(a);
797
219
  });
798
220
  });
799
221
 
222
+ // ─── Menu / callback deterministic relaunch ─────────────────────────────────
223
+
800
224
  import {
801
225
  buildModelMenu,
802
226
  handleModelMenuCallback,
803
227
  modelSelectCallbackData,
804
- sessionModelFromConfirmation,
228
+ isRecognizedSwitchToken,
805
229
  classifyDiscoveredOptions,
230
+ canonicalClaudeToken,
806
231
  MODEL_CALLBACK_REFRESH,
807
- MODEL_CALLBACK_HEADER,
808
232
  MODEL_CALLBACK_SR,
809
233
  MODEL_CALLBACK_ALIAS,
810
234
  MODEL_CALLBACK_PAGE_EXTERNAL,
811
- MODEL_CALLBACK_PAGE_MAIN,
812
235
  SR_MODEL_LABELS,
813
236
  SR_MODEL_ALIASES,
814
237
  EXTRA_CLAUDE_ALIASES,
238
+ expandSrAlias,
815
239
  externalModelNames,
816
- isSrToClaudeTransition,
817
- optimisticModelRecordLabel,
818
240
  type ModelMenuDeps,
819
241
  } from "../gateway/model-command.js";
820
- import { labelTag } from "../../src/agents/model-picker.js";
821
242
 
822
243
  const OPTIONS = [
823
244
  { index: 1, label: "Default (recommended)", detail: "Opus 4.8 with 1M context", current: false },
@@ -825,924 +246,418 @@ const OPTIONS = [
825
246
  { index: 3, label: "Haiku", detail: "Haiku 4.5 · Fastest", current: false },
826
247
  ];
827
248
 
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 = {
249
+ function makeMenuDeps(overrides: Partial<ModelMenuDeps & ModelCommandDeps> = {}) {
250
+ const base = makeDeps();
251
+ const calls = { discover: 0 };
252
+ const deps: ModelMenuDeps & ModelCommandDeps = {
832
253
  ...base.deps,
833
254
  discover: async () => {
834
255
  calls.discover++;
835
256
  return { ok: true as const, options: OPTIONS, currentLabel: "Sonnet" };
836
257
  },
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
258
  isBusy: () => false,
842
259
  getQuotaBrief: async () => "29% / 5h · 33% / 7d",
843
260
  discoverSrModels: async () => [],
844
261
  ...overrides,
845
262
  };
846
- return { deps, calls, injectCalls: base.calls };
263
+ return {
264
+ deps,
265
+ calls,
266
+ relaunchCalls: base.relaunchCalls,
267
+ defaultRelaunchCalls: base.defaultRelaunchCalls,
268
+ };
847
269
  }
848
270
 
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
- );
271
+ describe("modelSelectCallbackData — embeds the canonical token (rev 5)", () => {
272
+ it("an alias row carries the alias token", () => {
273
+ expect(modelSelectCallbackData("Haiku")).toBe("mdl:s:haiku");
867
274
  });
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
- }
275
+ it("a full-id row carries the id", () => {
276
+ expect(modelSelectCallbackData("claude-opus-4-8")).toBe("mdl:s:claude-opus-4-8");
877
277
  });
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:");
278
+ it("the Default row carries the `default` sentinel", () => {
279
+ expect(modelSelectCallbackData("Default (recommended)")).toBe("mdl:s:default");
902
280
  });
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:");
281
+ it("N2: an UNMAPPED Claude row does NOT collapse to the `default` sentinel", () => {
282
+ // A label whose first word is neither a known alias nor claude-* nor default
283
+ // must not masquerade as a default-revert; it carries an empty (rejected) suffix.
284
+ expect(modelSelectCallbackData("Sparkle 9 (preview)")).toBe("mdl:s:");
285
+ expect(modelSelectCallbackData("Sparkle 9 (preview)")).not.toBe("mdl:s:default");
913
286
  });
914
287
  });
915
288
 
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();
289
+ describe("isRecognizedSwitchToken (N1 allowlist)", () => {
290
+ it("accepts default, sr-*, aliases, and claude-* ids", () => {
291
+ for (const t of ["default", "sr-glm-5", "opus", "fable", "claude-opus-4-8"]) {
292
+ expect(isRecognizedSwitchToken(t)).toBe(true);
293
+ }
932
294
  });
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();
295
+ it("rejects a stale 8-hex labelTag and other garbage", () => {
296
+ for (const t of ["1a2b3c4d", "", "deadbeef", "Sparkle9"]) {
297
+ expect(isRecognizedSwitchToken(t)).toBe(false);
298
+ }
943
299
  });
300
+ });
944
301
 
945
- it("busy agent → toastOnly refusal that leaves the menu untouched", async () => {
946
- const { deps, calls } = makeMenuDeps({ isBusy: () => true });
302
+ describe("handleModelMenuCallback — every switch tap relaunches (rev 5)", () => {
303
+ it("picker SELECT `mdl:s:haiku` relaunches on 'haiku' and never scrapes/selects", async () => {
304
+ // FAILS on old code: the SELECT branch called deps.select and re-discovered
305
+ // the picker; here there is no select dep at all.
306
+ const { deps, relaunchCalls, calls } = makeMenuDeps();
947
307
  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);
308
+ expect(relaunchCalls).toEqual([expect.objectContaining({ model: "haiku" })]);
309
+ // The switch path does NOT drive discovery.
310
+ expect(calls.discover).toBe(0);
311
+ expect(out.reply.text).toContain("relaunching");
312
+ expect(Object.prototype.hasOwnProperty.call(out, "selectedModel")).toBe(false);
313
+ expect(Object.prototype.hasOwnProperty.call(out, "selectedModelToken")).toBe(false);
314
+ });
315
+
316
+ it("N1: a stale SELECT token (old 8-hex labelTag) does NOT relaunch — re-renders instead", async () => {
317
+ // A menu rendered by the OLD gateway carries `mdl:s:<hex>`. The hex passes the
318
+ // loose shape gate but is not a recognized model token; relaunching onto it
319
+ // would write a garbage carrier that --fallback-model silently masks. The
320
+ // handler must re-render, not schedule anything.
321
+ const { deps, relaunchCalls, defaultRelaunchCalls } = makeMenuDeps();
322
+ const out = await handleModelMenuCallback("mdl:s:1a2b3c4d", deps);
323
+ expect(relaunchCalls).toHaveLength(0);
324
+ expect(defaultRelaunchCalls).toHaveLength(0);
325
+ expect(out.answer).toContain("menu refreshed");
952
326
  });
953
327
 
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();
328
+ it("N1: an empty SELECT suffix (unmapped row) does NOT relaunch", async () => {
329
+ const { deps, relaunchCalls, defaultRelaunchCalls } = makeMenuDeps();
330
+ await handleModelMenuCallback("mdl:s:", deps);
331
+ expect(relaunchCalls).toHaveLength(0);
332
+ expect(defaultRelaunchCalls).toHaveLength(0);
964
333
  });
965
334
 
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
- });
970
- 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");
335
+ it("picker SELECT of the Default row routes to the default relaunch", async () => {
336
+ const { deps, relaunchCalls, defaultRelaunchCalls } = makeMenuDeps();
337
+ await handleModelMenuCallback("mdl:s:default", deps);
338
+ expect(defaultRelaunchCalls).toHaveLength(1);
339
+ expect(relaunchCalls).toHaveLength(0);
977
340
  });
978
341
 
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)");
342
+ it("Fable button `mdl:alias:fable` relaunches on 'fable' (never injects)", async () => {
343
+ // FAILS on old code: the alias branch injected `/model fable` and scraped.
344
+ const { deps, relaunchCalls } = makeMenuDeps();
345
+ await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
346
+ expect(relaunchCalls).toEqual([expect.objectContaining({ model: "fable" })]);
990
347
  });
991
348
 
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();
349
+ it("Default alias button routes to the default relaunch", async () => {
350
+ const { deps, relaunchCalls, defaultRelaunchCalls } = makeMenuDeps();
351
+ await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}default`, deps);
352
+ expect(defaultRelaunchCalls).toHaveLength(1);
353
+ expect(relaunchCalls).toHaveLength(0);
1002
354
  });
1003
355
 
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");
356
+ it("sr-* tap `mdl:sr:sr-glm-5` relaunches on the sr id", async () => {
357
+ const { deps, relaunchCalls } = makeMenuDeps();
358
+ await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-glm-5`, deps);
359
+ expect(relaunchCalls).toEqual([expect.objectContaining({ model: "sr-glm-5" })]);
1011
360
  });
1012
- });
1013
361
 
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");
362
+ it("a switch tap while mid-turn refuses (toastOnly) and schedules nothing", async () => {
363
+ const { deps, relaunchCalls } = makeMenuDeps({ isBusy: () => true });
364
+ const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}opus`, deps);
365
+ expect(out.toastOnly).toBe(true);
366
+ expect(out.busyRefusal).toBe(true);
367
+ expect(relaunchCalls).toHaveLength(0);
1038
368
  });
1039
369
 
1040
- it("ok with no confirmation line (banner-only) → optimistic record", async () => {
370
+ it("restart_in_flight on a menu tap yields the ~15s copy, one dispatch", async () => {
371
+ let calls = 0;
1041
372
  const { deps } = makeMenuDeps({
1042
- inject: async () => okResult("⠋ extending Claude Fable 5 access…"),
373
+ scheduleModelRelaunch: async () => { calls++; throw restartInFlight(); },
1043
374
  });
1044
375
  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");
376
+ expect(calls).toBe(1);
377
+ expect(out.reply.text).toContain("~15s");
1049
378
  });
1050
379
 
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");
380
+ it("Refresh re-renders without scheduling", async () => {
381
+ const { deps, relaunchCalls } = makeMenuDeps();
382
+ const out = await handleModelMenuCallback(MODEL_CALLBACK_REFRESH, deps);
383
+ expect(relaunchCalls).toHaveLength(0);
384
+ expect(out.answer).toBe("Refreshed");
1058
385
  });
1059
386
 
1060
- it("genuine confirmation via the button still records the display name", async () => {
1061
- const { deps } = makeMenuDeps({
1062
- inject: async () => okResult("⎿ Set model to Fable 5 for this session"),
1063
- });
1064
- const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
1065
- expect(out.selectedModel).toBe("Fable 5");
1066
- expect(out.selectedModelToken).toBe("fable");
387
+ it("page-nav swaps the keyboard without scheduling", async () => {
388
+ const { deps, relaunchCalls } = makeMenuDeps();
389
+ await handleModelMenuCallback(MODEL_CALLBACK_PAGE_EXTERNAL, deps);
390
+ expect(relaunchCalls).toHaveLength(0);
1067
391
  });
1068
392
  });
1069
393
 
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();
1084
- });
1085
-
1086
- it("mdl:r re-renders the dashboard", async () => {
394
+ describe("buildModelMenu — render only (discovery is not the switch path)", () => {
395
+ it("renders current model, quota, and one button per option", async () => {
1087
396
  const { deps, calls } = makeMenuDeps();
1088
- const out = await handleModelMenuCallback(MODEL_CALLBACK_REFRESH, deps);
1089
- expect(out.answer).toBe("Refreshed");
397
+ const menu = await buildModelMenu(deps);
1090
398
  expect(calls.discover).toBe(1);
1091
- expect(out.reply.keyboard).toBeDefined();
399
+ expect(menu.text).toContain("**Sonnet**");
400
+ expect(menu.text).toContain("29% / 5h · 33% / 7d");
401
+ expect(menu.keyboard).toBeDefined();
402
+ // SELECT rows now carry canonical tokens, not label hashes.
403
+ const selectRows = menu.keyboard!.flat().map((b) => b.callback_data).filter((d) => d.startsWith("mdl:s:"));
404
+ expect(selectRows).toContain("mdl:s:default");
405
+ expect(selectRows).toContain("mdl:s:haiku");
1092
406
  });
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
407
 
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();
408
+ it("every callback_data fits Telegram's 64-byte cap", async () => {
409
+ const { deps } = makeMenuDeps();
410
+ const menu = await buildModelMenu(deps);
411
+ for (const row of menu.keyboard!) {
412
+ for (const btn of row) {
413
+ expect(Buffer.byteLength(btn.callback_data, "utf-8")).toBeLessThanOrEqual(64);
414
+ }
415
+ }
1126
416
  });
1127
417
 
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);
418
+ it("busy agent → static keyboard, no discovery", async () => {
419
+ const { deps, calls } = makeMenuDeps({ isBusy: () => true });
420
+ const menu = await buildModelMenu(deps);
421
+ expect(calls.discover).toBe(0);
422
+ const data = menu.keyboard!.flat().map((b) => b.callback_data);
423
+ expect(data).toContain("mdl:alias:opus");
424
+ expect(data).toContain("mdl:alias:default");
1132
425
  });
1133
426
  });
1134
427
 
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
- });
428
+ // ─── Preserved pure-logic helpers ───────────────────────────────────────────
1140
429
 
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");
430
+ describe("canonicalClaudeToken", () => {
431
+ it("aliases → alias, ids → id, Default → null", () => {
432
+ expect(canonicalClaudeToken("Opus")).toBe("opus");
433
+ expect(canonicalClaudeToken("claude-opus-4-8")).toBe("claude-opus-4-8");
434
+ expect(canonicalClaudeToken("Default (recommended)")).toBeNull();
1154
435
  });
1155
436
  });
1156
437
 
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
- }
438
+ describe("SR_MODEL_ALIASES / expandSrAlias", () => {
439
+ it("expands known aliases, passes others through", () => {
440
+ expect(expandSrAlias("flash")).toBe("sr-gemini-2.5-flash");
441
+ expect(expandSrAlias("opus")).toBe("opus");
442
+ expect(expandSrAlias("sr-glm-5")).toBe("sr-glm-5");
1182
443
  });
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());
444
+ it("every alias target is a sr-* id", () => {
445
+ for (const target of Object.values(SR_MODEL_ALIASES)) expect(isSrModel(target)).toBe(true);
1191
446
  });
1192
447
  });
1193
448
 
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");
449
+ describe("classifyDiscoveredOptions", () => {
450
+ it("splits native Claude rows from sr-* rows and drops routing paths", () => {
451
+ const { claude, sr } = classifyDiscoveredOptions([
452
+ { index: 1, label: "Opus", current: false },
453
+ { index: 2, label: "sr-glm-5", current: false },
454
+ { index: 3, label: "openrouter/foo", current: false },
455
+ { index: 4, label: "gpt-4o", current: false },
456
+ ]);
457
+ expect(claude.map((o) => o.label)).toEqual(["Opus"]);
458
+ expect(sr.map((o) => o.label)).toEqual(["sr-glm-5"]);
1261
459
  });
1262
460
  });
1263
461
 
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');
462
+ describe("externalModelNames / SR_MODEL_LABELS / EXTRA_CLAUDE_ALIASES", () => {
463
+ it("seeds from the curated alias targets and merges discovered sr-*", () => {
464
+ const names = externalModelNames(["sr-gpt-oss-20b"]);
465
+ expect(names).toContain("sr-gemini-2.5-flash");
466
+ expect(names).toContain("sr-gpt-oss-20b");
1289
467
  });
1290
-
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);
468
+ it("Fable is offered as an extra Claude alias", () => {
469
+ expect(EXTRA_CLAUDE_ALIASES.some((a) => a.alias === "fable")).toBe(true);
1301
470
  });
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");
471
+ it("labels are display-only strings", () => {
472
+ expect(SR_MODEL_LABELS["sr-glm-5"]).toBeTypeOf("string");
1307
473
  });
1308
474
  });
1309
475
 
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);
476
+ describe("isOfflineTrustedModelToken (#3042 blocker 2a)", () => {
477
+ it("trusts static aliases + curated sr targets, refuses arbitrary ids", () => {
478
+ expect(isOfflineTrustedModelToken("opus")).toBe(true);
479
+ expect(isOfflineTrustedModelToken("OPUS")).toBe(true);
480
+ expect(isOfflineTrustedModelToken("flash")).toBe(true);
481
+ expect(isOfflineTrustedModelToken("sr-gemini-2.5-flash")).toBe(true);
482
+ expect(isOfflineTrustedModelToken("claude-random-9")).toBe(false);
483
+ expect(isOfflineTrustedModelToken("sr-obscure-xyz")).toBe(false);
1353
484
  });
1354
485
  });
1355
486
 
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");
487
+ describe("isBusyRefusalText (#3039)", () => {
488
+ it("matches the mid-turn refusal copy", () => {
489
+ expect(isBusyRefusalText("⏳ The agent is mid-turn — …")).toBe(true);
490
+ expect(isBusyRefusalText("done")).toBe(false);
1380
491
  });
1381
492
  });
1382
493
 
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();
1386
- 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
- );
1398
- });
1399
-
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.
1405
- const { deps } = makeMenuDeps();
1406
- 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);
494
+ describe("#3177 — routing decision helpers", () => {
495
+ it("isModelCommandBusy folds both signals", () => {
496
+ expect(isModelCommandBusy({ currentTurnActive: true, turnInFlight: false })).toBe(true);
497
+ expect(isModelCommandBusy({ currentTurnActive: false, turnInFlight: true })).toBe(true);
498
+ expect(isModelCommandBusy({ currentTurnActive: false, turnInFlight: false })).toBe(false);
1409
499
  });
1410
500
 
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
- }),
501
+ it("planModelCommand routes each shape", () => {
502
+ const idle = { currentTurnActive: false, turnInFlight: false, menuEnabled: true };
503
+ expect(planModelCommand({ kind: "show" }, idle)).toEqual({ kind: "menu" });
504
+ expect(planModelCommand({ kind: "set", model: "opus" }, idle)).toEqual({
505
+ kind: "apply",
506
+ parsed: { kind: "set", model: "opus" },
507
+ });
508
+ const busy = { currentTurnActive: true, turnInFlight: false, menuEnabled: true };
509
+ expect(planModelCommand({ kind: "set", model: "flash" }, busy)).toEqual({
510
+ kind: "queue",
511
+ target: "sr-gemini-2.5-flash",
1421
512
  });
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()) {
1435
- expect(Buffer.byteLength(btn.callback_data, "utf-8")).toBeLessThanOrEqual(64);
1436
- }
1437
- }
1438
- });
1439
- });
1440
-
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");
1461
- });
1462
- });
1463
-
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
- });
1475
-
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);
1485
- });
1486
- });
1487
-
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("✅");
1497
513
  });
1498
514
 
1499
- it("EXTRA_CLAUDE_ALIASES contains fable", () => {
1500
- expect(EXTRA_CLAUDE_ALIASES.some((a) => a.alias === "fable" && a.label === "Fable")).toBe(true);
515
+ it("resolveStaleAwareBusy discounts a stale turn atom", () => {
516
+ const out = resolveStaleAwareBusy({
517
+ currentTurnActive: true,
518
+ turnAgeMs: 10 * 60_000,
519
+ machineInTurn: false,
520
+ oldestPendingApprovalAgeMs: null,
521
+ hardTtlMs: 5 * 60_000,
522
+ });
523
+ expect(out.currentTurnActive).toBe(false);
524
+ expect(out.clearStaleTurn).toBe(true);
1501
525
  });
1502
526
 
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");
527
+ it("modelCommandReceiptLine is stable + greppable", () => {
528
+ const line = modelCommandReceiptLine("klanker", { kind: "set", model: "opus" }, false);
529
+ expect(line).toContain("gw /model received");
530
+ expect(line).toContain("agent=klanker");
531
+ expect(line).toContain("arg=opus");
1508
532
  });
1509
533
  });
1510
534
 
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);
1518
- });
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);
535
+ describe("parseModelSwitchTarget", () => {
536
+ it("extracts the target from a menu relaunch reason", () => {
537
+ expect(
538
+ parseModelSwitchTarget("user: /model fable (session-only relaunch, menu)"),
539
+ ).toBe("fable");
1524
540
  });
1525
- });
1526
-
1527
-
1528
- 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);
541
+ it("extracts the target from a typed relaunch reason", () => {
542
+ expect(
543
+ parseModelSwitchTarget("user: /model sr-gemini-2.5-flash (session-only relaunch)"),
544
+ ).toBe("sr-gemini-2.5-flash");
1535
545
  });
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);
546
+ it("extracts the default sentinel from a revert reason", () => {
547
+ expect(parseModelSwitchTarget("user: /model default (revert relaunch)")).toBe("default");
1542
548
  });
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
- }
1551
- 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);
549
+ it("returns null for a non-/model reason", () => {
550
+ expect(parseModelSwitchTarget("cli: restart")).toBeNull();
1556
551
  });
1557
552
  });
1558
553
 
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
- });
1578
-
1579
- it("is idle only when BOTH signals are clear", () => {
1580
- expect(isModelCommandBusy({ currentTurnActive: false, turnInFlight: false })).toBe(false);
554
+ describe("classifyModelSwitchConfirmation", () => {
555
+ // The regression this fix closes: a /model fable apply-boot that reverted to
556
+ // the configured default (opus) must be reported as NOT-applied (⚠️), not as a
557
+ // green "✅ Now running opus (the configured default)". Under the OLD inline
558
+ // logic (isApplyBoot ? applied : green-default) this exact case produced the
559
+ // misleading green card — this asserts the corrected outcome.
560
+ it("flags a non-default switch that reverted to the configured default as not-applied", () => {
561
+ const out = classifyModelSwitchConfirmation({
562
+ reason: "user: /model fable (session-only relaunch, menu)",
563
+ launched: "opus",
564
+ configured: "opus",
1581
565
  });
566
+ expect(out).toEqual({ kind: "not-applied", target: "fable", revertedTo: "opus" });
1582
567
  });
1583
568
 
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 };
1587
- 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" });
1592
- });
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 });
569
+ it("reports an applied switch when the launched model differs from the default", () => {
570
+ const out = classifyModelSwitchConfirmation({
571
+ reason: "user: /model fable (session-only relaunch, menu)",
572
+ launched: "fable",
573
+ configured: "opus",
1623
574
  });
575
+ expect(out).toEqual({ kind: "applied", launched: "fable" });
1624
576
  });
1625
577
 
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" });
578
+ it("reports the default (green) for an intended /model default revert", () => {
579
+ const out = classifyModelSwitchConfirmation({
580
+ reason: "user: /model default (revert relaunch)",
581
+ launched: "opus",
582
+ configured: "opus",
1666
583
  });
584
+ expect(out).toEqual({ kind: "default", launched: "opus" });
585
+ });
1667
586
 
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" } });
587
+ it("reports the default (green) when the operator asked for the configured model itself", () => {
588
+ // /model opus while configured=opus is a SUCCESS, not a silent revert.
589
+ const out = classifyModelSwitchConfirmation({
590
+ reason: "user: /model opus (session-only relaunch)",
591
+ launched: "opus",
592
+ configured: "opus",
1683
593
  });
594
+ expect(out).toEqual({ kind: "default", launched: "opus" });
595
+ });
1684
596
 
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" });
597
+ it("is case-insensitive on the target-vs-launched comparison", () => {
598
+ const out = classifyModelSwitchConfirmation({
599
+ reason: "user: /model OPUS (session-only relaunch)",
600
+ launched: "opus",
601
+ configured: "opus",
1699
602
  });
603
+ expect(out).toEqual({ kind: "default", launched: "opus" });
604
+ });
1700
605
 
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);
606
+ it("treats an empty launched value (unreadable .active-session-model) as a revert to the default", () => {
607
+ const out = classifyModelSwitchConfirmation({
608
+ reason: "user: /model fable (session-only relaunch, menu)",
609
+ launched: "",
610
+ configured: "opus",
1711
611
  });
612
+ expect(out).toEqual({ kind: "not-applied", target: "fable", revertedTo: "opus" });
613
+ });
1712
614
 
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 });
615
+ // LOW-1: an ALIAS target must family-match its RESOLVED full-id revert, so a
616
+ // `/model sonnet` on a `claude-sonnet-5`-default agent (start.sh wrote the full
617
+ // id to .active-session-model on a config-changed / proxy-down revert path) is
618
+ // NOT falsely reported as "sonnet didn't apply". This is the exact case the
619
+ // reviewer flagged; a naive string compare returns not-applied here.
620
+ it("family-matches an alias target to its resolved full-id default (sonnet ≡ claude-sonnet-5)", () => {
621
+ const out = classifyModelSwitchConfirmation({
622
+ reason: "user: /model sonnet (session-only relaunch)",
623
+ launched: "claude-sonnet-5",
624
+ configured: "claude-sonnet-5",
1722
625
  });
626
+ expect(out).toEqual({ kind: "default", launched: "claude-sonnet-5" });
627
+ });
1723
628
 
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);
629
+ it("still flags a genuinely different-family switch that reverted to the full-id default", () => {
630
+ // /model opus reverting to a claude-sonnet-5 default is a REAL failed switch.
631
+ const out = classifyModelSwitchConfirmation({
632
+ reason: "user: /model opus (session-only relaunch)",
633
+ launched: "claude-sonnet-5",
634
+ configured: "claude-sonnet-5",
1734
635
  });
636
+ expect(out).toEqual({ kind: "not-applied", target: "opus", revertedTo: "claude-sonnet-5" });
1735
637
  });
1736
638
 
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");
639
+ it("family-matches a full-id target to the aliased default (claude-opus-4-8 ≡ opus)", () => {
640
+ const out = classifyModelSwitchConfirmation({
641
+ reason: "user: /model claude-opus-4-8 (session-only relaunch)",
642
+ launched: "opus",
643
+ configured: "opus",
1741
644
  });
645
+ expect(out).toEqual({ kind: "default", launched: "opus" });
646
+ });
647
+ });
1742
648
 
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
- });
649
+ describe("modelFamilyToken", () => {
650
+ it("reduces a claude full id to its family, dropping version + date", () => {
651
+ expect(modelFamilyToken("claude-sonnet-5")).toBe("sonnet");
652
+ expect(modelFamilyToken("claude-opus-4-8")).toBe("opus");
653
+ expect(modelFamilyToken("claude-haiku-4-5-20251001")).toBe("haiku");
654
+ });
655
+ it("passes a bare alias through, lowercased", () => {
656
+ expect(modelFamilyToken("Sonnet")).toBe("sonnet");
657
+ expect(modelFamilyToken("fable")).toBe("fable");
658
+ });
659
+ it("keeps sr-* routing ids verbatim (no claude- prefix)", () => {
660
+ expect(modelFamilyToken("sr-glm-5")).toBe("sr-glm-5");
661
+ expect(modelFamilyToken("sr-gemini-2.5-flash")).toBe("sr-gemini-2.5-flash");
1747
662
  });
1748
663
  });