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.
- package/dist/cli/switchroom.js +1039 -608
- package/dist/host-control/main.js +5 -5
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +35 -0
- package/telegram-plugin/dist/gateway/gateway.js +91 -8
- package/telegram-plugin/gateway/gateway.ts +92 -3
- package/telegram-plugin/gateway/model-command.ts +73 -0
- package/telegram-plugin/permission-title.ts +5 -0
- package/telegram-plugin/tests/model-command.test.ts +124 -1
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +24 -10
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">"),
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
159
|
+
120_000,
|
|
146
160
|
);
|
|
147
161
|
});
|