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.
- package/bin/handoff-briefing.sh +15 -2
- package/dist/agent-scheduler/index.js +111 -7
- package/dist/auth-broker/index.js +154 -73
- package/dist/cli/autoaccept-poll.js +8 -3
- package/dist/cli/drive-write-pretool.mjs +8 -3
- package/dist/cli/ms-365-write-pretool.mjs +158 -11
- package/dist/cli/notion-write-pretool.mjs +103 -4
- package/dist/cli/switchroom.js +2712 -2219
- package/dist/host-control/main.js +110 -70
- package/dist/vault/approvals/kernel-server.js +116 -70
- package/dist/vault/broker/server.js +314 -202
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +105 -34
- package/telegram-plugin/dist/bridge/bridge.js +71 -47
- package/telegram-plugin/dist/gateway/gateway.js +1128 -666
- package/telegram-plugin/dist/server.js +89 -64
- package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
- package/telegram-plugin/gateway/forward-origin.ts +9 -1
- package/telegram-plugin/gateway/gateway.ts +656 -388
- package/telegram-plugin/gateway/model-command.ts +331 -602
- package/telegram-plugin/gateway/session-model-file.ts +40 -0
- package/telegram-plugin/gateway/turn-record-status.ts +45 -0
- package/telegram-plugin/gateway/unhandled-message.ts +177 -0
- package/telegram-plugin/history.ts +153 -23
- package/telegram-plugin/llm-error-present.ts +24 -0
- package/telegram-plugin/model-unavailable.ts +55 -0
- package/telegram-plugin/operator-events.ts +113 -0
- package/telegram-plugin/pending-user-notice.ts +88 -0
- package/telegram-plugin/shared/local-time.ts +99 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
- package/telegram-plugin/tests/forward-origin.test.ts +30 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
- package/telegram-plugin/tests/history.test.ts +88 -0
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
- package/telegram-plugin/tests/local-time.test.ts +135 -0
- package/telegram-plugin/tests/model-command.test.ts +427 -1512
- package/telegram-plugin/tests/session-model-file.test.ts +23 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
- package/telegram-plugin/tier-downgrade.ts +4 -3
- package/telegram-plugin/turn-flush-safety.ts +25 -1
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
- package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
- 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
|
|
7
|
+
* 1. The bare `/model` form renders the dashboard; it never applies a switch.
|
|
8
|
+
* 2. The argument is shape-gated before it becomes a `claude --model` token.
|
|
9
|
+
* 3. EVERY switch — typed Claude→Claude, Claude→sr-*, sr-*→Claude, the
|
|
10
|
+
* Fable/alias button, and the picker SELECT — routes through the carrier
|
|
11
|
+
* relaunch (`scheduleModelRelaunch` / `scheduleModelDefaultRelaunch`). No
|
|
12
|
+
* inject, no cursor-nav select, no scrape, no optimistic `/status` record.
|
|
5
13
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* would wedge the pane (the /rate-limit-options class of wedge).
|
|
10
|
-
* 2. The argument is shape-gated before it's typed into the tmux
|
|
11
|
-
* pane: one token, no whitespace, no shell/control smuggling.
|
|
12
|
-
* 3. The set path injects exactly `/model <name>` (claude's own
|
|
13
|
-
* REPL verb — already on the inject allowlist) and relays the
|
|
14
|
-
* captured output, with the session-only persistence caveat.
|
|
14
|
+
* These tests FAIL on the OLD behavior on purpose: a Claude→Claude switch that
|
|
15
|
+
* called `inject`, a picker tap that called `select`, or any reply carrying an
|
|
16
|
+
* `optimistic` / scrape-derived `selectedModel` would not satisfy them.
|
|
15
17
|
*/
|
|
16
|
-
import { describe, it, expect
|
|
18
|
+
import { describe, it, expect } from "vitest";
|
|
17
19
|
import {
|
|
18
20
|
parseModelCommand,
|
|
19
21
|
planModelCommand,
|
|
@@ -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,
|
|
56
|
+
return { deps, relaunchCalls, defaultRelaunchCalls, restartCalls };
|
|
65
57
|
}
|
|
66
58
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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("
|
|
133
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
|
423
|
-
it("
|
|
424
|
-
expect(isSrModel("sr-
|
|
425
|
-
expect(isSrModel("
|
|
426
|
-
expect(
|
|
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("
|
|
437
|
-
expect(isClaudeModel("sr-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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(
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
expect(reply.
|
|
460
|
-
expect(reply.
|
|
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("
|
|
464
|
-
const { deps,
|
|
465
|
-
|
|
466
|
-
});
|
|
467
|
-
const reply = await handleModelCommand({ kind: "set", model: "claude-opus-4-8" }, deps);
|
|
468
|
-
expect(calls).toHaveLength(0);
|
|
469
|
-
expect(restartCalls).toHaveLength(0);
|
|
470
|
-
expect(relaunchCalls).toHaveLength(1);
|
|
471
|
-
expect(relaunchCalls[0].model).toBe("claude-opus-4-8");
|
|
472
|
-
expect(reply.text).toContain("sr-deepseek-r1");
|
|
473
|
-
expect(reply.text).toContain("30s");
|
|
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("
|
|
477
|
-
const { deps,
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
|
|
481
|
-
// Normal inject path: still injects, no restart, no relaunch
|
|
482
|
-
expect(calls).toHaveLength(1);
|
|
483
|
-
expect(restartCalls).toHaveLength(0);
|
|
484
|
-
expect(relaunchCalls).toHaveLength(0);
|
|
485
|
-
expect(reply.text).toContain("Set model to sonnet");
|
|
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("
|
|
489
|
-
const { deps,
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
});
|
|
493
|
-
const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
|
|
494
|
-
expect(calls).toHaveLength(0);
|
|
495
|
-
expect(reply.text).toContain("Could not schedule restart");
|
|
496
|
-
expect(reply.text).toContain("hostd unreachable");
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
it("reports 'restart already in flight' honestly on a debounced dispatch (no silent no-op)", async () => {
|
|
500
|
-
const { deps } = makeDeps({
|
|
501
|
-
getActiveSessionModel: () => "sr-deepseek-r1",
|
|
502
|
-
scheduleModelRelaunch: async () => {
|
|
503
|
-
const e = new Error("a restart is already in flight — try again in ~15s");
|
|
504
|
-
(e as { code?: string }).code = "restart_in_flight";
|
|
505
|
-
throw e;
|
|
506
|
-
},
|
|
507
|
-
});
|
|
508
|
-
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
509
|
-
expect(reply.text).toContain("restart is already in flight");
|
|
510
|
-
expect(reply.text).toContain("15s");
|
|
511
|
-
// Never a false success.
|
|
512
|
-
expect(reply.text).not.toContain("Set model to");
|
|
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("
|
|
516
|
-
const { deps,
|
|
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(
|
|
521
|
-
expect(restartCalls).toHaveLength(0);
|
|
522
|
-
expect(relaunchCalls).toHaveLength(0);
|
|
159
|
+
expect(relaunchCalls).toEqual([expect.objectContaining({ model: "opus" })]);
|
|
523
160
|
});
|
|
524
|
-
});
|
|
525
161
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
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("
|
|
533
|
-
expect(reply.selectedModel).toBeUndefined();
|
|
167
|
+
expect(reply.text).toContain("Reverting to the configured default");
|
|
534
168
|
});
|
|
535
169
|
|
|
536
|
-
it("
|
|
537
|
-
const { deps, relaunchCalls } = makeDeps({ isBusy: () => true });
|
|
538
|
-
const reply = await handleModelCommand({ kind: "set", model: "
|
|
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("
|
|
544
|
-
|
|
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
|
-
|
|
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(
|
|
595
|
-
expect(reply.
|
|
184
|
+
expect(calls).toBe(1);
|
|
185
|
+
expect(reply.text).toContain("~15s");
|
|
596
186
|
});
|
|
597
187
|
|
|
598
|
-
it("
|
|
188
|
+
it("a generic dispatch failure surfaces an honest error", async () => {
|
|
599
189
|
const { deps } = makeDeps({
|
|
600
|
-
|
|
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
|
-
//
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
789
|
-
const
|
|
790
|
-
getActiveSessionModel: () => "sr-deepseek-v3",
|
|
791
|
-
});
|
|
792
|
-
await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
793
|
-
expect(calls).toHaveLength(0);
|
|
794
|
-
expect(restartCalls).toHaveLength(0);
|
|
795
|
-
expect(relaunchCalls).toHaveLength(1);
|
|
796
|
-
expect(relaunchCalls[0].model).toBe("opus");
|
|
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
|
-
|
|
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
|
|
830
|
-
const
|
|
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 {
|
|
263
|
+
return {
|
|
264
|
+
deps,
|
|
265
|
+
calls,
|
|
266
|
+
relaunchCalls: base.relaunchCalls,
|
|
267
|
+
defaultRelaunchCalls: base.defaultRelaunchCalls,
|
|
268
|
+
};
|
|
847
269
|
}
|
|
848
270
|
|
|
849
|
-
describe("
|
|
850
|
-
it("
|
|
851
|
-
|
|
852
|
-
const menu = await buildModelMenu(deps);
|
|
853
|
-
expect(calls.discover).toBe(1);
|
|
854
|
-
expect(menu.text).toContain("**Sonnet**");
|
|
855
|
-
expect(menu.text).toContain("29% / 5h · 33% / 7d");
|
|
856
|
-
expect(menu.keyboard).toBeDefined();
|
|
857
|
-
// 3 scraped option rows + static Fable row + refresh row
|
|
858
|
-
// (no external row here — discoverSrModels returns [] and the default
|
|
859
|
-
// makeMenuDeps has no SR seed override, but externalModelNames seeds from
|
|
860
|
-
// SR_MODEL_ALIASES, so the External row IS present — see dedicated tests).
|
|
861
|
-
expect(menu.keyboard![1][0].text).toBe("✅ Sonnet");
|
|
862
|
-
expect(menu.keyboard![0][0].text).toBe("Default (recommended)");
|
|
863
|
-
// Refresh is always the last row.
|
|
864
|
-
expect(menu.keyboard![menu.keyboard!.length - 1][0].callback_data).toBe(
|
|
865
|
-
MODEL_CALLBACK_REFRESH,
|
|
866
|
-
);
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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("
|
|
917
|
-
it("
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
expect(out.answer).toContain("Set model to Haiku");
|
|
922
|
-
expect(out.reply.text).toContain("✅");
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
it("stale tag (options changed) → never selects, re-renders menu", async () => {
|
|
926
|
-
const { deps, calls } = makeMenuDeps();
|
|
927
|
-
const staleTag = `mdl:s:${labelTag("Removed Model")}`;
|
|
928
|
-
const out = await handleModelMenuCallback(staleTag, deps);
|
|
929
|
-
expect(calls.select).toEqual([]);
|
|
930
|
-
expect(out.answer).toContain("refreshed");
|
|
931
|
-
expect(out.reply.keyboard).toBeDefined();
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
946
|
-
|
|
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(
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
expect(out.
|
|
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("
|
|
955
|
-
const { deps } = makeMenuDeps(
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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("
|
|
967
|
-
const { deps } = makeMenuDeps(
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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("
|
|
980
|
-
//
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
});
|
|
985
|
-
const out = await handleModelMenuCallback(modelSelectCallbackData("Default (recommended)"), deps);
|
|
986
|
-
expect(out.reply.text).toContain("✅");
|
|
987
|
-
// Crucially, NOTHING is recorded — no display label leaks into the override.
|
|
988
|
-
expect(out.selectedModel).toBeUndefined();
|
|
989
|
-
expect(out.reply.text).not.toContain("Default (recommended)");
|
|
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("
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
});
|
|
998
|
-
const out = await handleModelMenuCallback(modelSelectCallbackData("Default (recommended)"), deps);
|
|
999
|
-
expect(out.selectedModel).toBe("Opus 4.8");
|
|
1000
|
-
// "Default (recommended)" yields no --model token → carrier boots the config default.
|
|
1001
|
-
expect(out.selectedModelToken).toBeUndefined();
|
|
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("
|
|
1005
|
-
const { deps } = makeMenuDeps(
|
|
1006
|
-
|
|
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
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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("
|
|
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
|
-
|
|
373
|
+
scheduleModelRelaunch: async () => { calls++; throw restartInFlight(); },
|
|
1043
374
|
});
|
|
1044
375
|
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
|
|
1045
|
-
expect(
|
|
1046
|
-
expect(out.
|
|
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("
|
|
1052
|
-
const { deps } = makeMenuDeps(
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
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("
|
|
1061
|
-
const { deps } = makeMenuDeps(
|
|
1062
|
-
|
|
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("
|
|
1071
|
-
it("
|
|
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
|
|
1089
|
-
expect(out.answer).toBe("Refreshed");
|
|
397
|
+
const menu = await buildModelMenu(deps);
|
|
1090
398
|
expect(calls.discover).toBe(1);
|
|
1091
|
-
expect(
|
|
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
|
-
|
|
1115
|
-
|
|
1116
|
-
const
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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("
|
|
1129
|
-
const {
|
|
1130
|
-
|
|
1131
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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("
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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
|
-
|
|
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("
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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("
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
expect(
|
|
1317
|
-
expect(
|
|
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
|
-
|
|
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("
|
|
1384
|
-
it("
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
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("
|
|
1412
|
-
const {
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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("
|
|
1500
|
-
|
|
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("
|
|
1504
|
-
const {
|
|
1505
|
-
|
|
1506
|
-
expect(
|
|
1507
|
-
expect(
|
|
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
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1561
|
-
//
|
|
1562
|
-
//
|
|
1563
|
-
//
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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
|
-
|
|
1585
|
-
const
|
|
1586
|
-
|
|
1587
|
-
|
|
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
|
-
|
|
1627
|
-
const
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
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
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
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
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
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
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
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
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
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
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
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
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
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
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
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
|
});
|