switchroom 0.16.7 → 0.16.9

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.
@@ -18,6 +18,8 @@ import {
18
18
  parseModelCommand,
19
19
  handleModelCommand,
20
20
  isValidModelArg,
21
+ isSrModel,
22
+ isClaudeModel,
21
23
  MODEL_ALIASES,
22
24
  type ModelCommandDeps,
23
25
  } from "../gateway/model-command.js";
@@ -35,6 +37,7 @@ function okResult(output: string): InjectResult {
35
37
 
36
38
  function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
37
39
  const calls: Array<{ agent: string; command: string }> = [];
40
+ const restartCalls: string[] = [];
38
41
  const deps: ModelCommandDeps = {
39
42
  inject: async (agent, command) => {
40
43
  calls.push({ agent, command });
@@ -45,9 +48,11 @@ function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
45
48
  escapeHtml: (s) =>
46
49
  s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"),
47
50
  preBlock: (s) => `<pre>${s}</pre>`,
51
+ getActiveSessionModel: () => null,
52
+ scheduleRestart: async (reason) => { restartCalls.push(reason); },
48
53
  ...overrides,
49
54
  };
50
- return { deps, calls };
55
+ return { deps, calls, restartCalls };
51
56
  }
52
57
 
53
58
  describe("parseModelCommand", () => {
@@ -237,6 +242,97 @@ describe("handleModelCommand — set", () => {
237
242
  });
238
243
  });
239
244
 
245
+ describe("isSrModel / isClaudeModel helpers", () => {
246
+ it("isSrModel is true only for sr-* names", () => {
247
+ expect(isSrModel("sr-gemini-2.5-pro")).toBe(true);
248
+ expect(isSrModel("sr-deepseek-r1")).toBe(true);
249
+ expect(isSrModel("claude-sonnet-4-6")).toBe(false);
250
+ expect(isSrModel("sonnet")).toBe(false);
251
+ expect(isSrModel("")).toBe(false);
252
+ });
253
+
254
+ it("isClaudeModel is true for aliases and claude-* ids", () => {
255
+ for (const alias of MODEL_ALIASES) {
256
+ expect(isClaudeModel(alias), alias).toBe(true);
257
+ }
258
+ expect(isClaudeModel("claude-opus-4-8")).toBe(true);
259
+ expect(isClaudeModel("claude-sonnet-4-6[1m]")).toBe(true);
260
+ expect(isClaudeModel("sr-gemini-2.5-pro")).toBe(false);
261
+ expect(isClaudeModel("gpt-4")).toBe(false);
262
+ });
263
+ });
264
+
265
+ describe("handleModelCommand — sr-* → Claude graceful restart", () => {
266
+ it("schedules restart instead of injecting when session is on sr-* and target is Claude alias", async () => {
267
+ const { deps, calls, restartCalls } = makeDeps({
268
+ getActiveSessionModel: () => "sr-gemini-2.5-pro",
269
+ });
270
+ const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
271
+ // Must NOT inject
272
+ expect(calls).toHaveLength(0);
273
+ // Must schedule a restart
274
+ expect(restartCalls).toHaveLength(1);
275
+ expect(restartCalls[0]).toContain("opus");
276
+ expect(restartCalls[0]).toContain("sr-to-claude");
277
+ // Reply mentions the sr-* model and ~30s
278
+ expect(reply.text).toContain("sr-gemini-2.5-pro");
279
+ expect(reply.text).toContain("30s");
280
+ expect(reply.html).toBe(true);
281
+ });
282
+
283
+ it("schedules restart when session is on sr-* and target is a full claude-* id", async () => {
284
+ const { deps, calls, restartCalls } = makeDeps({
285
+ getActiveSessionModel: () => "sr-deepseek-r1",
286
+ });
287
+ const reply = await handleModelCommand({ kind: "set", model: "claude-opus-4-8" }, deps);
288
+ expect(calls).toHaveLength(0);
289
+ expect(restartCalls).toHaveLength(1);
290
+ expect(reply.text).toContain("sr-deepseek-r1");
291
+ expect(reply.text).toContain("30s");
292
+ });
293
+
294
+ it("does NOT restart when switching between Claude models (no sr-* session)", async () => {
295
+ const { deps, calls, restartCalls } = makeDeps({
296
+ getActiveSessionModel: () => "Opus 4.8",
297
+ });
298
+ const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
299
+ // Normal inject path: still injects, no restart
300
+ expect(calls).toHaveLength(1);
301
+ expect(restartCalls).toHaveLength(0);
302
+ expect(reply.text).toContain("Set model to sonnet");
303
+ });
304
+
305
+ it("does NOT restart when switching from Claude to sr-* (no session override)", async () => {
306
+ const { deps, calls, restartCalls } = makeDeps({
307
+ getActiveSessionModel: () => null,
308
+ });
309
+ await handleModelCommand({ kind: "set", model: "sr-gemini-2.5-pro" }, deps);
310
+ // sr-* is not a Claude model — no restart
311
+ expect(restartCalls).toHaveLength(0);
312
+ expect(calls).toHaveLength(1);
313
+ });
314
+
315
+ it("surfaces scheduleRestart failures without propagating the error", async () => {
316
+ const { deps, calls } = makeDeps({
317
+ getActiveSessionModel: () => "sr-deepseek-r1",
318
+ scheduleRestart: async () => { throw new Error("hostd unreachable"); },
319
+ });
320
+ const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
321
+ expect(calls).toHaveLength(0);
322
+ expect(reply.text).toContain("Could not schedule restart");
323
+ expect(reply.text).toContain("hostd unreachable");
324
+ });
325
+
326
+ it("null session model (no prior override) still uses normal inject path for Claude target", async () => {
327
+ const { deps, calls, restartCalls } = makeDeps({
328
+ getActiveSessionModel: () => null,
329
+ });
330
+ await handleModelCommand({ kind: "set", model: "opus" }, deps);
331
+ expect(calls).toHaveLength(1);
332
+ expect(restartCalls).toHaveLength(0);
333
+ });
334
+ });
335
+
240
336
  describe("inject allowlist contract", () => {
241
337
  it("/model stays on the inject allowlist (the set path depends on it)", async () => {
242
338
  const { INJECT_COMMANDS } = await import("../../src/agents/inject.js");
@@ -258,6 +354,7 @@ import {
258
354
  MODEL_CALLBACK_HEADER,
259
355
  MODEL_CALLBACK_SR,
260
356
  SR_MODEL_LABELS,
357
+ isSrToClaudeTransition,
261
358
  type ModelMenuDeps,
262
359
  } from "../gateway/model-command.js";
263
360
  import { labelTag } from "../../src/agents/model-picker.js";
@@ -578,3 +675,29 @@ describe("handleModelMenuCallback — sr-* selection", () => {
578
675
  expect(out.answer).toBe("Invalid model name");
579
676
  });
580
677
  });
678
+
679
+ // ---------------------------------------------------------------------------
680
+ // isSrToClaudeTransition helper (used by gateway callback handler)
681
+ // ---------------------------------------------------------------------------
682
+
683
+ describe("isSrToClaudeTransition", () => {
684
+ it("true when prev is sr-* and next is not sr-*", () => {
685
+ expect(isSrToClaudeTransition("sr-gemini-2.5-pro", "Haiku 4.5")).toBe(true);
686
+ expect(isSrToClaudeTransition("sr-deepseek-r1", "Fable 5")).toBe(true);
687
+ expect(isSrToClaudeTransition("sr-deepseek-r1", "claude-opus-4-8")).toBe(true);
688
+ });
689
+
690
+ it("false when prev is not sr-* (Claude → Claude)", () => {
691
+ expect(isSrToClaudeTransition("Opus 4.8", "Haiku 4.5")).toBe(false);
692
+ expect(isSrToClaudeTransition(null, "Sonnet")).toBe(false);
693
+ expect(isSrToClaudeTransition(undefined, "Sonnet")).toBe(false);
694
+ });
695
+
696
+ it("false when prev is sr-* but next is also sr-* (sr-* → sr-*)", () => {
697
+ expect(isSrToClaudeTransition("sr-gemini-2.5-pro", "sr-deepseek-r1")).toBe(false);
698
+ });
699
+
700
+ it("false when switching to sr-* from Claude (Claude → sr-*)", () => {
701
+ expect(isSrToClaudeTransition("Sonnet", "sr-gemini-2.5-pro")).toBe(false);
702
+ });
703
+ });
@@ -57,7 +57,12 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
57
57
  const openrouterHeader = flat.find(
58
58
  (b) => b.text.includes("OpenRouter") && b.callbackData === "mdl:h",
59
59
  );
60
- const srButton = flat.find((b) => b.callbackData?.startsWith("mdl:sr:"));
60
+ // Prefer a fast non-reasoning model for the E2E test; reasoning models
61
+ // (deepseek-r1, o1, o3) take 2-5 min per response and hit the silence poke.
62
+ const srButton =
63
+ flat.find((b) => b.callbackData?.startsWith("mdl:sr:") && /flash/.test(b.callbackData)) ??
64
+ flat.find((b) => b.callbackData?.startsWith("mdl:sr:") && !/r1|o1|o3|thinking/.test(b.callbackData)) ??
65
+ flat.find((b) => b.callbackData?.startsWith("mdl:sr:"));
61
66
 
62
67
  if (!srButton) {
63
68
  console.log("No sr-* buttons in menu — agent not LiteLLM-enabled or no sr-* models registered. Skipping.");
@@ -86,8 +91,10 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
86
91
  expect((kbAfter ?? []).flat().length, "menu keeps buttons after sr-* tap").toBeGreaterThan(0);
87
92
 
88
93
  // ── 3. Send a quick message to generate a LiteLLM-routed turn ──
94
+ // Use 120s — fast models (gemini-flash, deepseek-v3) respond in <10s,
95
+ // but the request still has to go through the model switch inject + proxy.
89
96
  await sc.sendDM("Just reply with the word OK.");
90
- await sc.expectMessage(/ok/i, { from: "bot", timeout: 60_000 });
97
+ await sc.expectMessage(/ok/i, { from: "bot", timeout: 120_000 });
91
98
 
92
99
  // ── 4. LiteLLM spend attribution ────────────────────────────────
93
100
  if (spendBefore >= 0) {
@@ -97,14 +104,19 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
97
104
  expect(spendAfter, `agent:${AGENT} log_count increased after turn`).toBeGreaterThan(spendBefore);
98
105
  }
99
106
 
100
- // ── Restore: switch back to configured model ────────────────────
107
+ // ── Restore: switch back to a Claude subscription model ─────────
108
+ // Re-fetch keyboard so we have the latest state after sr-* switch.
109
+ // Look for any Claude model (mdl:s:) button — those are Max/Pro
110
+ // subscription models and don't trigger slow reasoning. If graceful
111
+ // restart is implemented, pressing a claude button fires a restart
112
+ // (not a deepseek/gemini inject), so no secondary LiteLLM calls.
101
113
  const currentKb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
102
- const restoreBtn = (currentKb ?? []).flat().find(
103
- (b) => b.callbackData?.startsWith("mdl:s:") && /Default|Sonnet|claude-sonnet/i.test(b.text),
104
- );
114
+ const restoreBtn = (currentKb ?? []).flat().find((b) => b.callbackData?.startsWith("mdl:s:"));
105
115
  if (restoreBtn?.callbackData) {
106
116
  await sc.driver.pressButton(sc.botUserId, menu.messageId, restoreBtn.callbackData);
107
- await new Promise((r) => setTimeout(r, 4_000));
117
+ // Wait for the restart to complete (graceful restart PR #2619) or for
118
+ // the in-place inject to propagate — 15s is sufficient for either path.
119
+ await new Promise((r) => setTimeout(r, 15_000));
108
120
  }
109
121
 
110
122
  console.log(`✅ sr-* model switch (${srName}) verified end-to-end through LiteLLM`);
@@ -112,7 +124,7 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
112
124
  await sc.tearDown();
113
125
  }
114
126
  },
115
- 180_000,
127
+ 210_000,
116
128
  );
117
129
 
118
130
  it(
@@ -121,9 +133,11 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
121
133
  const sc = await spinUp({ agent: AGENT });
122
134
  try {
123
135
  await sc.sendDM("/model");
136
+ // 60s — test 2 runs after test 1's restore restart, which takes ~15s.
137
+ // If the restart is still finishing when /model lands, it may be queued.
124
138
  const menu = await sc.expectMessage(/Default \(new sessions\):/i, {
125
139
  from: "bot",
126
- timeout: 30_000,
140
+ timeout: 60_000,
127
141
  });
128
142
  const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
129
143
  const flat = (kb ?? []).flat();
@@ -142,6 +156,6 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
142
156
  await sc.tearDown();
143
157
  }
144
158
  },
145
- 60_000,
159
+ 120_000,
146
160
  );
147
161
  });