switchroom 0.15.1 → 0.15.3
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/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +80 -13
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +1784 -1427
- package/dist/cli/ui/index.html +67 -1
- package/dist/host-control/main.js +5 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +33 -0
- package/profiles/default/CLAUDE.md.hbs +27 -0
- package/telegram-plugin/dist/gateway/gateway.js +576 -16
- package/telegram-plugin/gateway/gateway.ts +135 -4
- package/telegram-plugin/gateway/model-command.ts +368 -0
- package/telegram-plugin/tests/model-command.test.ts +349 -0
- package/telegram-plugin/uat/scenarios/jtbd-model-command-dm.test.ts +93 -0
- package/telegram-plugin/welcome-text.ts +7 -1
|
@@ -0,0 +1,349 @@
|
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">"),
|
|
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
|
+
});
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Picker-driven menu (v2) — buildModelMenu + handleModelMenuCallback
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
import {
|
|
212
|
+
buildModelMenu,
|
|
213
|
+
handleModelMenuCallback,
|
|
214
|
+
modelSelectCallbackData,
|
|
215
|
+
MODEL_CALLBACK_REFRESH,
|
|
216
|
+
type ModelMenuDeps,
|
|
217
|
+
} from "../gateway/model-command.js";
|
|
218
|
+
import { labelTag } from "../../src/agents/model-picker.js";
|
|
219
|
+
|
|
220
|
+
const OPTIONS = [
|
|
221
|
+
{ index: 1, label: "Default (recommended)", detail: "Opus 4.8 with 1M context", current: false },
|
|
222
|
+
{ index: 2, label: "Sonnet", detail: "Sonnet 4.6 · Efficient", current: true },
|
|
223
|
+
{ index: 3, label: "Haiku", detail: "Haiku 4.5 · Fastest", current: false },
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
function makeMenuDeps(overrides: Partial<ModelMenuDeps> = {}) {
|
|
227
|
+
const calls = { discover: 0, select: [] as string[] };
|
|
228
|
+
const base = makeDeps(); // v1 deps (inject/getConfiguredModel/escapeHtml/preBlock)
|
|
229
|
+
const deps = {
|
|
230
|
+
...base.deps,
|
|
231
|
+
discover: async () => {
|
|
232
|
+
calls.discover++;
|
|
233
|
+
return { ok: true as const, options: OPTIONS, currentLabel: "Sonnet" };
|
|
234
|
+
},
|
|
235
|
+
select: async (_a: string, label: string) => {
|
|
236
|
+
calls.select.push(label);
|
|
237
|
+
return { ok: true as const, confirmation: `Set model to ${label} for this session` };
|
|
238
|
+
},
|
|
239
|
+
isBusy: () => false,
|
|
240
|
+
getQuotaBrief: async () => "29% / 5h · 33% / 7d",
|
|
241
|
+
...overrides,
|
|
242
|
+
};
|
|
243
|
+
return { deps, calls, injectCalls: base.calls };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
describe("buildModelMenu", () => {
|
|
247
|
+
it("renders current model, quota brief, and one button per discovered option", async () => {
|
|
248
|
+
const { deps, calls } = makeMenuDeps();
|
|
249
|
+
const menu = await buildModelMenu(deps);
|
|
250
|
+
expect(calls.discover).toBe(1);
|
|
251
|
+
expect(menu.text).toContain("<b>Sonnet</b>");
|
|
252
|
+
expect(menu.text).toContain("29% / 5h · 33% / 7d");
|
|
253
|
+
expect(menu.keyboard).toBeDefined();
|
|
254
|
+
// 3 option rows + refresh row
|
|
255
|
+
expect(menu.keyboard!.length).toBe(4);
|
|
256
|
+
expect(menu.keyboard![1][0].text).toBe("✅ Sonnet");
|
|
257
|
+
expect(menu.keyboard![0][0].text).toBe("Default (recommended)");
|
|
258
|
+
expect(menu.keyboard![3][0].callback_data).toBe(MODEL_CALLBACK_REFRESH);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("every callback_data fits Telegram's 64-byte cap", async () => {
|
|
262
|
+
const { deps } = makeMenuDeps();
|
|
263
|
+
const menu = await buildModelMenu(deps);
|
|
264
|
+
for (const row of menu.keyboard!) {
|
|
265
|
+
for (const btn of row) {
|
|
266
|
+
expect(Buffer.byteLength(btn.callback_data, "utf-8")).toBeLessThanOrEqual(64);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("busy agent → no discovery, no keyboard, explanatory text", async () => {
|
|
272
|
+
const { deps, calls } = makeMenuDeps({ isBusy: () => true });
|
|
273
|
+
const menu = await buildModelMenu(deps);
|
|
274
|
+
expect(calls.discover).toBe(0);
|
|
275
|
+
expect(menu.keyboard).toBeUndefined();
|
|
276
|
+
expect(menu.text).toContain("mid-turn");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("discovery failure → static v1 fallback with the reason, no keyboard", async () => {
|
|
280
|
+
const { deps } = makeMenuDeps({
|
|
281
|
+
discover: async () => ({ ok: false as const, reason: "tmux session not found" }),
|
|
282
|
+
});
|
|
283
|
+
const menu = await buildModelMenu(deps);
|
|
284
|
+
expect(menu.keyboard).toBeUndefined();
|
|
285
|
+
expect(menu.text).toContain("picker unavailable");
|
|
286
|
+
expect(menu.text).toContain("Configured:");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("quota failure never blocks the menu", async () => {
|
|
290
|
+
const { deps } = makeMenuDeps({
|
|
291
|
+
getQuotaBrief: async () => {
|
|
292
|
+
throw new Error("broker down");
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
const menu = await buildModelMenu(deps);
|
|
296
|
+
expect(menu.keyboard).toBeDefined();
|
|
297
|
+
expect(menu.text).not.toContain("Quota:");
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("handleModelMenuCallback", () => {
|
|
302
|
+
it("mdl:s:<tag> selects by re-discovered label", async () => {
|
|
303
|
+
const { deps, calls } = makeMenuDeps();
|
|
304
|
+
const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
|
|
305
|
+
expect(calls.select).toEqual(["Haiku"]);
|
|
306
|
+
expect(out.answer).toContain("Set model to Haiku");
|
|
307
|
+
expect(out.reply.text).toContain("✅");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("stale tag (options changed) → never selects, re-renders menu", async () => {
|
|
311
|
+
const { deps, calls } = makeMenuDeps();
|
|
312
|
+
const staleTag = `mdl:s:${labelTag("Removed Model")}`;
|
|
313
|
+
const out = await handleModelMenuCallback(staleTag, deps);
|
|
314
|
+
expect(calls.select).toEqual([]);
|
|
315
|
+
expect(out.answer).toContain("refreshed");
|
|
316
|
+
expect(out.reply.keyboard).toBeDefined();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("tapping the current model is a no-op refresh", async () => {
|
|
320
|
+
const { deps, calls } = makeMenuDeps();
|
|
321
|
+
const out = await handleModelMenuCallback(modelSelectCallbackData("Sonnet"), deps);
|
|
322
|
+
expect(calls.select).toEqual([]);
|
|
323
|
+
expect(out.answer).toContain("Already on Sonnet");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("busy agent → never selects", async () => {
|
|
327
|
+
const { deps, calls } = makeMenuDeps({ isBusy: () => true });
|
|
328
|
+
const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
|
|
329
|
+
expect(calls.select).toEqual([]);
|
|
330
|
+
expect(out.answer).toContain("mid-turn");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("selection failure surfaces the reason", async () => {
|
|
334
|
+
const { deps } = makeMenuDeps({
|
|
335
|
+
select: async () => ({ ok: false as const, reason: "cursor verification failed" }),
|
|
336
|
+
});
|
|
337
|
+
const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
|
|
338
|
+
expect(out.answer).toBe("Switch failed");
|
|
339
|
+
expect(out.reply.text).toContain("cursor verification failed");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("mdl:r re-renders the dashboard", async () => {
|
|
343
|
+
const { deps, calls } = makeMenuDeps();
|
|
344
|
+
const out = await handleModelMenuCallback(MODEL_CALLBACK_REFRESH, deps);
|
|
345
|
+
expect(out.answer).toBe("Refreshed");
|
|
346
|
+
expect(calls.discover).toBe(1);
|
|
347
|
+
expect(out.reply.keyboard).toBeDefined();
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UAT — `/model` Telegram command (PR #2259, shipped v0.15.2).
|
|
3
|
+
*
|
|
4
|
+
* Serves: `reference/vision.md` outcome 2 (you hold the leash) — the
|
|
5
|
+
* operator can see and switch the agent's Claude model from Telegram
|
|
6
|
+
* without SSH. Session-scoped switch via claude's own `/model <name>`
|
|
7
|
+
* REPL verb injected into the tmux pane.
|
|
8
|
+
*
|
|
9
|
+
* Three assertions against a real agent over real Telegram:
|
|
10
|
+
*
|
|
11
|
+
* 1. Bare `/model` → shows the configured model (never opens claude's
|
|
12
|
+
* interactive picker — the reply must come from the gateway, fast,
|
|
13
|
+
* containing "Configured:").
|
|
14
|
+
* 2. `/model <valid-name>` → switch is injected; reply relays claude's
|
|
15
|
+
* response and carries the session-only persistence note.
|
|
16
|
+
* 3. `/model bogus-name` → still a reply (claude's inline error is
|
|
17
|
+
* relayed, or the empty-capture explanation) — never silence.
|
|
18
|
+
*
|
|
19
|
+
* The switch test sets the model to the SAME value the agent already
|
|
20
|
+
* runs (sonnet) so the canary doesn't leave the harness agent on a
|
|
21
|
+
* different model afterwards.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from "vitest";
|
|
25
|
+
import { spinUp } from "../harness.js";
|
|
26
|
+
|
|
27
|
+
const AGENT = "test-harness";
|
|
28
|
+
const REPLY_TIMEOUT_MS = 30_000;
|
|
29
|
+
|
|
30
|
+
describe("uat: /model command — show, switch, bad-name", () => {
|
|
31
|
+
it(
|
|
32
|
+
"bare /model shows the model dashboard (menu v2) or static fallback (v1)",
|
|
33
|
+
async () => {
|
|
34
|
+
const sc = await spinUp({ agent: AGENT });
|
|
35
|
+
try {
|
|
36
|
+
await sc.sendDM("/model");
|
|
37
|
+
// v2 (picker-driven menu): "Now: <model>"; v1 / fallback path:
|
|
38
|
+
// "Configured: <model>". Either proves the gateway handled the
|
|
39
|
+
// command rather than forwarding it to claude as plain text.
|
|
40
|
+
const shape = /Now:|Configured:/i;
|
|
41
|
+
const reply = await sc.expectMessage(shape, {
|
|
42
|
+
from: "bot",
|
|
43
|
+
timeout: REPLY_TIMEOUT_MS,
|
|
44
|
+
});
|
|
45
|
+
expect(reply.text).toMatch(shape);
|
|
46
|
+
// Persistence caveat present on both shapes
|
|
47
|
+
expect(reply.text).toMatch(/switchroom\.yaml/i);
|
|
48
|
+
} finally {
|
|
49
|
+
await sc.tearDown();
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
60_000,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
it(
|
|
56
|
+
"/model sonnet switches the live session (same-value, no net change)",
|
|
57
|
+
async () => {
|
|
58
|
+
const sc = await spinUp({ agent: AGENT });
|
|
59
|
+
try {
|
|
60
|
+
await sc.sendDM("/model sonnet");
|
|
61
|
+
// Accept either a relayed claude response or the explicit
|
|
62
|
+
// empty-capture explanation — both prove the command routed
|
|
63
|
+
// through the gateway handler (and neither is silence).
|
|
64
|
+
const reply = await sc.expectMessage(
|
|
65
|
+
/\/model sonnet|no response captured|Session-only/i,
|
|
66
|
+
{ from: "bot", timeout: REPLY_TIMEOUT_MS },
|
|
67
|
+
);
|
|
68
|
+
expect(reply.text.length).toBeGreaterThan(0);
|
|
69
|
+
} finally {
|
|
70
|
+
await sc.tearDown();
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
60_000,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
it(
|
|
77
|
+
"/model bogus-name still gets a reply (error relayed, never silence)",
|
|
78
|
+
async () => {
|
|
79
|
+
const sc = await spinUp({ agent: AGENT });
|
|
80
|
+
try {
|
|
81
|
+
await sc.sendDM("/model bogus-model-name-xyz");
|
|
82
|
+
const reply = await sc.expectMessage(/\S/, {
|
|
83
|
+
from: "bot",
|
|
84
|
+
timeout: REPLY_TIMEOUT_MS,
|
|
85
|
+
});
|
|
86
|
+
expect(reply.text.length).toBeGreaterThan(0);
|
|
87
|
+
} finally {
|
|
88
|
+
await sc.tearDown();
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
60_000,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
@@ -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] <slot></code> — switch active slot and restart`,
|
|
360
364
|
`<code>/auth rm [agent] <slot> [--force]</code> — remove a slot`,
|
|
365
|
+
`<code>/model</code> — show the configured Claude model`,
|
|
366
|
+
`<code>/model <name></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 <tool></code> — grant a tool permission`,
|