switchroom 0.15.1 → 0.15.2

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.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * `/model` Telegram command — parser + handler coverage.
3
+ *
4
+ * The headline guarantees:
5
+ *
6
+ * 1. The bare `/model` form NEVER reaches the inject primitive —
7
+ * with no argument claude renders an interactive picker modal
8
+ * that Telegram can't drive (no arrows, no Esc), so injecting it
9
+ * would wedge the pane (the /rate-limit-options class of wedge).
10
+ * 2. The argument is shape-gated before it's typed into the tmux
11
+ * pane: one token, no whitespace, no shell/control smuggling.
12
+ * 3. The set path injects exactly `/model <name>` (claude's own
13
+ * REPL verb — already on the inject allowlist) and relays the
14
+ * captured output, with the session-only persistence caveat.
15
+ */
16
+ import { describe, it, expect } from "vitest";
17
+ import {
18
+ parseModelCommand,
19
+ handleModelCommand,
20
+ isValidModelArg,
21
+ MODEL_ALIASES,
22
+ type ModelCommandDeps,
23
+ } from "../gateway/model-command.js";
24
+ import type { InjectResult } from "../../src/agents/inject.js";
25
+
26
+ function okResult(output: string): InjectResult {
27
+ return {
28
+ outcome: "ok",
29
+ output,
30
+ truncated: false,
31
+ command: "/model",
32
+ meta: { description: "Open model picker", expectsOutput: true },
33
+ };
34
+ }
35
+
36
+ function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
37
+ const calls: Array<{ agent: string; command: string }> = [];
38
+ const deps: ModelCommandDeps = {
39
+ inject: async (agent, command) => {
40
+ calls.push({ agent, command });
41
+ return okResult("⏺ Set model to sonnet");
42
+ },
43
+ getAgentName: () => "klanker",
44
+ getConfiguredModel: () => "claude-sonnet-4-6",
45
+ escapeHtml: (s) =>
46
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"),
47
+ preBlock: (s) => `<pre>${s}</pre>`,
48
+ ...overrides,
49
+ };
50
+ return { deps, calls };
51
+ }
52
+
53
+ describe("parseModelCommand", () => {
54
+ it("returns null for non-/model text", () => {
55
+ expect(parseModelCommand("/auth list")).toBeNull();
56
+ expect(parseModelCommand("model sonnet")).toBeNull();
57
+ expect(parseModelCommand("/modelx sonnet")).toBeNull();
58
+ });
59
+
60
+ it("bare /model (and @botname form) parses as show", () => {
61
+ expect(parseModelCommand("/model")).toEqual({ kind: "show" });
62
+ expect(parseModelCommand("/model@klanker_bot")).toEqual({ kind: "show" });
63
+ expect(parseModelCommand("/model ")).toEqual({ kind: "show" });
64
+ });
65
+
66
+ it("single valid token parses as set", () => {
67
+ expect(parseModelCommand("/model sonnet")).toEqual({ kind: "set", model: "sonnet" });
68
+ expect(parseModelCommand("/model@bot claude-opus-4-8")).toEqual({
69
+ kind: "set",
70
+ model: "claude-opus-4-8",
71
+ });
72
+ // 1m-context variant ids carry brackets
73
+ expect(parseModelCommand("/model claude-sonnet-4-6[1m]")).toEqual({
74
+ kind: "set",
75
+ model: "claude-sonnet-4-6[1m]",
76
+ });
77
+ });
78
+
79
+ it("/model help parses as help", () => {
80
+ expect(parseModelCommand("/model help")).toEqual({ kind: "help" });
81
+ });
82
+
83
+ it("rejects multi-token args (no second token can ride into the pane)", () => {
84
+ const p = parseModelCommand("/model sonnet; rm -rf /");
85
+ expect(p?.kind).toBe("help");
86
+ });
87
+
88
+ it("rejects shell/control smuggling shapes", () => {
89
+ for (const bad of [
90
+ "/model $(reboot)",
91
+ "/model `id`",
92
+ "/model -opus", // leading dash — looks like a flag
93
+ "/model sonnet\nEnter",
94
+ "/model ../../etc/passwd",
95
+ "/model a|b",
96
+ ]) {
97
+ const p = parseModelCommand(bad);
98
+ expect(p?.kind, `should reject: ${bad}`).toBe("help");
99
+ }
100
+ });
101
+ });
102
+
103
+ describe("isValidModelArg", () => {
104
+ it("accepts aliases and full ids", () => {
105
+ for (const good of [...MODEL_ALIASES, "claude-opus-4-8", "claude-haiku-4-5-20251001", "claude-sonnet-4-6[1m]"]) {
106
+ expect(isValidModelArg(good), good).toBe(true);
107
+ }
108
+ });
109
+ it("rejects whitespace, metacharacters, and over-long strings", () => {
110
+ for (const bad of ["", " ", "a b", "a;b", "a/b", "-x", "a".repeat(120), "a\tb", "a\nb"]) {
111
+ expect(isValidModelArg(bad), JSON.stringify(bad)).toBe(false);
112
+ }
113
+ });
114
+ });
115
+
116
+ describe("handleModelCommand — show / help never inject (picker-wedge guard)", () => {
117
+ it("show renders configured model + switch options without injecting", async () => {
118
+ const { deps, calls } = makeDeps();
119
+ const reply = await handleModelCommand({ kind: "show" }, deps);
120
+ expect(calls.length).toBe(0);
121
+ expect(reply.text).toContain("claude-sonnet-4-6");
122
+ expect(reply.text).toContain("/model opus");
123
+ expect(reply.text).toContain("switchroom.yaml");
124
+ });
125
+
126
+ it("show falls back to 'default' when no model configured", async () => {
127
+ const { deps, calls } = makeDeps({ getConfiguredModel: () => null });
128
+ const reply = await handleModelCommand({ kind: "show" }, deps);
129
+ expect(calls.length).toBe(0);
130
+ expect(reply.text).toContain("<code>default</code>");
131
+ });
132
+
133
+ it("help never injects", async () => {
134
+ const { deps, calls } = makeDeps();
135
+ const reply = await handleModelCommand({ kind: "help", reason: "nope" }, deps);
136
+ expect(calls.length).toBe(0);
137
+ expect(reply.text).toContain("nope");
138
+ });
139
+ });
140
+
141
+ describe("handleModelCommand — set", () => {
142
+ it("injects exactly `/model <name>` once and relays output + persistence note", async () => {
143
+ const { deps, calls } = makeDeps();
144
+ const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
145
+ expect(calls).toEqual([{ agent: "klanker", command: "/model opus" }]);
146
+ expect(reply.text).toContain("<pre>⏺ Set model to sonnet</pre>");
147
+ expect(reply.text).toContain("Session-only");
148
+ expect(reply.html).toBe(true);
149
+ });
150
+
151
+ it("re-gates the model arg at the seam (caller bypassing the parser)", async () => {
152
+ const { deps, calls } = makeDeps();
153
+ const reply = await handleModelCommand({ kind: "set", model: "a b; reboot" }, deps);
154
+ expect(calls.length).toBe(0);
155
+ expect(reply.text).toContain("not a valid model name");
156
+ });
157
+
158
+ it("ok_no_output explains the empty capture", async () => {
159
+ const { deps } = makeDeps({
160
+ inject: async () => ({
161
+ outcome: "ok_no_output",
162
+ output: "",
163
+ truncated: false,
164
+ command: "/model",
165
+ meta: { description: "Open model picker", expectsOutput: true },
166
+ }),
167
+ });
168
+ const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
169
+ expect(reply.text).toContain("no response captured");
170
+ });
171
+
172
+ it("session_missing failure surfaces the tmux-supervisor hint", async () => {
173
+ const { deps } = makeDeps({
174
+ inject: async () => ({
175
+ outcome: "failed",
176
+ output: "",
177
+ truncated: false,
178
+ command: "/model",
179
+ meta: null,
180
+ errorCode: "session_missing",
181
+ errorMessage: "tmux session not found",
182
+ }),
183
+ });
184
+ const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
185
+ expect(reply.text).toContain("tmux session not found");
186
+ expect(reply.text).toContain("tmux supervisor");
187
+ });
188
+
189
+ it("inject throwing is surfaced, not propagated", async () => {
190
+ const { deps } = makeDeps({
191
+ inject: async () => {
192
+ throw new Error("boom");
193
+ },
194
+ });
195
+ const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
196
+ expect(reply.text).toContain("boom");
197
+ });
198
+ });
199
+
200
+ describe("inject allowlist contract", () => {
201
+ it("/model stays on the inject allowlist (the set path depends on it)", async () => {
202
+ const { INJECT_COMMANDS } = await import("../../src/agents/inject.js");
203
+ expect(INJECT_COMMANDS.has("/model")).toBe(true);
204
+ });
205
+ });
@@ -254,7 +254,7 @@ export const switchroomHelpCommandNames = [
254
254
  // Agents
255
255
  "agents", "agentstart", "stop", "restart", "logs", "memory",
256
256
  // Auth & config — consolidated onto the `/auth` dashboard.
257
- "auth",
257
+ "auth", "model",
258
258
  "topics", "update", "version",
259
259
  "permissions", "grant", "dangerous", "vault", "doctor",
260
260
  "commands",
@@ -299,6 +299,10 @@ export const TELEGRAM_MENU_COMMANDS = [
299
299
  // /memory, /hooks). Requires the tmux supervisor (the default — refused
300
300
  // when the agent has experimental.legacy_pty=true).
301
301
  { command: "inject", description: "Inject a Claude Code slash command (e.g. /cost)" },
302
+ // /model — show or switch the Claude model (session-scoped; rides the
303
+ // same inject primitive as `/inject /model` but with a typed argument,
304
+ // so it never opens the undriveable no-arg picker modal).
305
+ { command: "model", description: "Show or switch the Claude model" },
302
306
  { command: "doctor", description: "Health check (deps, services, MCP)" },
303
307
  { command: "usage", description: "Pro/Max plan quota (5h + 7d windows)" },
304
308
  // Vault — secrets + capability grants. /vault is a top-level command
@@ -358,6 +362,8 @@ export function switchroomHelpText(agentName: string): string {
358
362
  `<code>/auth list [agent]</code> — list account slots and health`,
359
363
  `<code>/auth use [agent] &lt;slot&gt;</code> — switch active slot and restart`,
360
364
  `<code>/auth rm [agent] &lt;slot&gt; [--force]</code> — remove a slot`,
365
+ `<code>/model</code> — show the configured Claude model`,
366
+ `<code>/model &lt;name&gt;</code> — switch the live session's model (opus · sonnet · haiku or a full id; until restart)`,
361
367
  `<code>/topics</code> — topic-to-agent mappings`,
362
368
  `<code>/permissions [agent]</code> — show agent permissions`,
363
369
  `<code>/grant &lt;tool&gt;</code> — grant a tool permission`,