switchroom 0.15.2 → 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.
@@ -247,6 +247,7 @@ import {
247
247
  import {
248
248
  fetchQuota,
249
249
  formatQuotaBlock,
250
+ formatQuotaLine,
250
251
  type QuotaResult,
251
252
  } from '../quota-check.js'
252
253
  import {
@@ -258,7 +259,17 @@ import { DEFAULT_SLOT } from '../../src/auth/accounts.js'
258
259
  import { currentActiveSlot, type AuthCodeOutcome } from '../../src/auth/manager.js'
259
260
  import { injectSlashCommand as injectSlashCommandImpl } from '../../src/agents/inject.js'
260
261
  import { handleInjectCommand } from './inject-handler.js'
261
- import { parseModelCommand, handleModelCommand } from './model-command.js'
262
+ import {
263
+ parseModelCommand,
264
+ handleModelCommand,
265
+ buildModelMenu,
266
+ handleModelMenuCallback,
267
+ MODEL_CALLBACK_PREFIX,
268
+ type ModelMenuDeps,
269
+ type ModelCommandDeps,
270
+ type ModelMenuReply,
271
+ } from './model-command.js'
272
+ import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
262
273
  import { type BannerState } from '../slot-banner.js'
263
274
  import { refreshBanner } from '../slot-banner-driver.js'
264
275
  import { loadConfig as loadSwitchroomConfig, findConfigFile as findSwitchroomConfigFile } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
@@ -13922,19 +13933,39 @@ bot.command('inject', async ctx => {
13922
13933
  })
13923
13934
  })
13924
13935
 
13925
- // /model — show or switch the Claude model for this agent's live
13926
- // session. The argument form rides the same allowlisted inject
13927
- // primitive as /inject (claude's native `/model <name>` REPL command);
13928
- // the bare form never injects (the no-arg picker is an undriveable TUI
13929
- // modal from Telegram). Implementation in model-command.ts so it's
13936
+ // /model — model dashboard + switch for this agent's live session.
13937
+ // Bare form: drives claude's own /model picker (open → parse → Esc,
13938
+ // src/agents/model-picker.ts) to discover the live option list no
13939
+ // hardcoded model names and renders it as an inline-keyboard menu
13940
+ // with the current model + a brief quota line. A tap re-opens the
13941
+ // picker fresh and applies session-only (`s`). Kill-switch
13942
+ // SWITCHROOM_MODEL_MENU=0 reverts the bare form to the static v1
13943
+ // text. The typed argument form rides the allowlisted inject
13944
+ // primitive unchanged. Implementation in model-command.ts so it's
13930
13945
  // unit-testable without booting the bot.
13931
- bot.command('model', async ctx => {
13932
- if (!isAuthorizedSender(ctx)) return
13933
- const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
13934
- const parsed = parseModelCommand(text) ?? { kind: 'show' as const }
13935
- const reply = await handleModelCommand(parsed, {
13936
- inject: injectSlashCommandImpl,
13946
+ function buildModelDeps(): ModelMenuDeps & ModelCommandDeps {
13947
+ return {
13948
+ discover: (a) => discoverModels(a),
13949
+ select: (a, label) => selectModel(a, label),
13950
+ isBusy: () => currentTurn !== null,
13937
13951
  getAgentName: getMyAgentName,
13952
+ getQuotaBrief: async () => {
13953
+ // Broker-routed probe first (authoritative), local headers as
13954
+ // fallback — same ladder as the boot card / legacy /usage.
13955
+ try {
13956
+ const probed = await probeQuotaForBootCard(getMyAgentName(), 4000)
13957
+ if (probed?.ok) return formatQuotaLine(probed.data)
13958
+ } catch { /* fall through */ }
13959
+ try {
13960
+ const agentDir = resolveAgentDirFromEnv()
13961
+ if (agentDir) {
13962
+ const local = await fetchQuota({ claudeConfigDir: join(agentDir, '.claude') })
13963
+ if (local.ok) return formatQuotaLine(local.data)
13964
+ }
13965
+ } catch { /* quota is garnish — never block the menu on it */ }
13966
+ return null
13967
+ },
13968
+ inject: injectSlashCommandImpl,
13938
13969
  getConfiguredModel: () => {
13939
13970
  type AgentListResp = { agents: Array<{ name: string; model?: string | null }> }
13940
13971
  const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
@@ -13942,7 +13973,30 @@ bot.command('model', async ctx => {
13942
13973
  },
13943
13974
  escapeHtml: escapeHtmlForTg,
13944
13975
  preBlock,
13945
- })
13976
+ }
13977
+ }
13978
+
13979
+ function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined {
13980
+ if (!reply.keyboard) return undefined
13981
+ const kb = new InlineKeyboard()
13982
+ for (const row of reply.keyboard) {
13983
+ for (const btn of row) kb.text(btn.text, btn.callback_data)
13984
+ kb.row()
13985
+ }
13986
+ return kb
13987
+ }
13988
+
13989
+ bot.command('model', async ctx => {
13990
+ if (!isAuthorizedSender(ctx)) return
13991
+ const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
13992
+ const parsed = parseModelCommand(text) ?? { kind: 'show' as const }
13993
+ const deps = buildModelDeps()
13994
+ if (parsed.kind === 'show' && process.env.SWITCHROOM_MODEL_MENU !== '0') {
13995
+ const menu = await buildModelMenu(deps)
13996
+ await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) })
13997
+ return
13998
+ }
13999
+ const reply = await handleModelCommand(parsed, deps)
13946
14000
  await switchroomReply(ctx, reply.text, { html: reply.html })
13947
14001
  })
13948
14002
 
@@ -18330,6 +18384,54 @@ bot.on('callback_query:data', async ctx => {
18330
18384
  return
18331
18385
  }
18332
18386
 
18387
+ // `mdl:*` — model-menu taps (/model dashboard). `mdl:s:<tag>`
18388
+ // selects a model by label-tag via a fresh picker discovery (never
18389
+ // a stale index); `mdl:r` re-renders. Strict allowFrom gate like
18390
+ // every other mutating callback family — a model switch changes the
18391
+ // fleet's quota burn profile.
18392
+ if (data.startsWith(MODEL_CALLBACK_PREFIX)) {
18393
+ const access = loadAccess()
18394
+ const senderId = String(ctx.from?.id ?? '')
18395
+ if (!access.allowFrom.includes(senderId)) {
18396
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' })
18397
+ return
18398
+ }
18399
+ // Kill-switch covers the callback family too — stale menus keep
18400
+ // their buttons after the flag flips, and the flag exists exactly
18401
+ // for "picker-driving is misbehaving" (#2263 review blocker 2).
18402
+ if (process.env.SWITCHROOM_MODEL_MENU === '0') {
18403
+ await ctx.answerCallbackQuery({ text: 'Model menu is disabled (SWITCHROOM_MODEL_MENU=0).' }).catch(() => {})
18404
+ await ctx
18405
+ .editMessageText('Model menu is disabled on this agent. Use <code>/model &lt;name&gt;</code>.', {
18406
+ parse_mode: 'HTML',
18407
+ reply_markup: { inline_keyboard: [] },
18408
+ })
18409
+ .catch(() => {})
18410
+ return
18411
+ }
18412
+ // Answer the callback IMMEDIATELY — the select path drives the
18413
+ // picker up to three times (multi-second); leaving the tap
18414
+ // spinning invites a double-tap, which queues a second drive
18415
+ // behind the pane lock and confuses the user. The final state is
18416
+ // conveyed by the message edit (a callback can only be answered
18417
+ // once).
18418
+ await ctx.answerCallbackQuery({ text: 'Working…' }).catch(() => {})
18419
+ try {
18420
+ const outcome = await handleModelMenuCallback(data, buildModelDeps())
18421
+ await ctx
18422
+ .editMessageText(outcome.reply.text, {
18423
+ parse_mode: 'HTML',
18424
+ reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
18425
+ })
18426
+ .catch(() => {})
18427
+ } catch (err) {
18428
+ process.stderr.write(
18429
+ `telegram gateway: model-menu callback failed: ${(err as Error)?.message ?? String(err)}\n`,
18430
+ )
18431
+ }
18432
+ return
18433
+ }
18434
+
18333
18435
  // `cn:cancel:<key>` — cancel a pending Microsoft connect flow (the
18334
18436
  // Cancel button on the /connect card). RFC #1873 Phase 2.
18335
18437
  if (data.startsWith('cn:')) {
@@ -2,13 +2,14 @@
2
2
  * Telegram `/model` command — show or switch the Claude model for this
3
3
  * agent's live session.
4
4
  *
5
- * `/model` (bare) shows the configured model and the switch options.
6
- * It deliberately NEVER injects the bare `/model` verb into the claude
7
- * pane: with no argument the CLI renders an interactive picker modal
8
- * that nothing on the Telegram side can drive (no arrow keys, no Esc),
9
- * which would wedge the pane the same TUI-modal class of wedge as
10
- * the /rate-limit-options incident. Only the argument form is ever
11
- * injected.
5
+ * `/model` (bare) renders the model dashboard: the live model, a brief
6
+ * quota line, and an inline-keyboard menu of the options claude's own
7
+ * `/model` picker offers (discovered live via `src/agents/model-picker.ts`
8
+ * opened, parsed, Esc'd; never hardcoded, so new models appear the
9
+ * moment the installed CLI offers them). A button tap re-opens the
10
+ * picker fresh, matches the row by label, and applies session-only.
11
+ * When discovery fails (agent mid-turn, CLI UI changed, kill-switched
12
+ * via SWITCHROOM_MODEL_MENU=0) it falls back to the static v1 text.
12
13
  *
13
14
  * `/model <alias|full-id>` types claude's own `/model <name>` into the
14
15
  * agent's tmux pane via the existing allowlisted inject primitive
@@ -24,6 +25,12 @@
24
25
  */
25
26
 
26
27
  import type { InjectResult } from '../../src/agents/inject.js'
28
+ import {
29
+ labelTag,
30
+ type DiscoverResult,
31
+ type SelectResult,
32
+ type ModelPickerOption,
33
+ } from '../../src/agents/model-picker.js'
27
34
 
28
35
  /**
29
36
  * Aliases the claude CLI resolves natively. Listed in help text only —
@@ -180,3 +187,182 @@ export async function handleModelCommand(
180
187
  html: true,
181
188
  }
182
189
  }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Picker-driven model menu (v2) — discovery, render, callback selection.
193
+ // ---------------------------------------------------------------------------
194
+
195
+ export interface ModelMenuDeps {
196
+ /** Live picker discovery — src/agents/model-picker.ts discoverModels. */
197
+ discover: (agent: string) => Promise<DiscoverResult>
198
+ /** Live picker selection by label — selectModel (session-only `s`). */
199
+ select: (agent: string, label: string) => Promise<SelectResult>
200
+ /**
201
+ * True while the agent is mid-turn. Driving the picker types into
202
+ * claude's input box; doing that mid-turn would queue "/model" as
203
+ * user text instead of opening the modal — refuse instead.
204
+ */
205
+ isBusy: () => boolean
206
+ getAgentName: () => string
207
+ /** One-line quota summary (e.g. "29% / 5h · 33% / 7d") or null. */
208
+ getQuotaBrief: () => Promise<string | null>
209
+ escapeHtml: (s: string) => string
210
+ }
211
+
212
+ /** Raw Telegram inline-keyboard shape (grammY accepts it verbatim). */
213
+ export interface ModelMenuKeyboardButton {
214
+ text: string
215
+ callback_data: string
216
+ }
217
+
218
+ export interface ModelMenuReply {
219
+ text: string
220
+ html: true
221
+ /** Rows of buttons; absent on the no-menu fallback. */
222
+ keyboard?: ModelMenuKeyboardButton[][]
223
+ }
224
+
225
+ export const MODEL_CALLBACK_PREFIX = 'mdl:'
226
+ const MODEL_CALLBACK_SELECT = 'mdl:s:'
227
+ export const MODEL_CALLBACK_REFRESH = 'mdl:r'
228
+
229
+ export function modelSelectCallbackData(label: string): string {
230
+ // Identity is the label's hash, not its index — a tap re-discovers
231
+ // the picker and matches by tag, so a list that shifted between
232
+ // render and tap can never select the wrong row. 8 hex chars keeps
233
+ // callback_data tiny (well under Telegram's 64-byte cap).
234
+ return `${MODEL_CALLBACK_SELECT}${labelTag(label)}`
235
+ }
236
+
237
+ function busyReply(deps: Pick<ModelMenuDeps, 'escapeHtml'>): ModelMenuReply {
238
+ return {
239
+ text: '⏳ The agent is mid-turn — the model picker needs an idle prompt. Try again in a moment.',
240
+ html: true,
241
+ }
242
+ }
243
+
244
+ function menuKeyboard(options: ModelPickerOption[]): ModelMenuKeyboardButton[][] {
245
+ // One option per row (labels + ✔ render cleanly at full width on
246
+ // mobile), refresh on a trailing row.
247
+ const rows: ModelMenuKeyboardButton[][] = options.map((o) => [
248
+ {
249
+ text: o.current ? `✅ ${o.label}` : o.label,
250
+ callback_data: modelSelectCallbackData(o.label),
251
+ },
252
+ ])
253
+ rows.push([{ text: '🔄 Refresh', callback_data: MODEL_CALLBACK_REFRESH }])
254
+ return rows
255
+ }
256
+
257
+ /**
258
+ * Build the `/model` dashboard: live model + quota brief + tap menu.
259
+ * Returns a keyboard-less fallback (v1-shaped static text) when the
260
+ * picker can't be driven right now — the command never hard-fails.
261
+ */
262
+ export async function buildModelMenu(
263
+ deps: ModelMenuDeps & ModelCommandDeps,
264
+ ): Promise<ModelMenuReply> {
265
+ if (deps.isBusy()) return busyReply(deps)
266
+
267
+ const [discovered, quota] = await Promise.all([
268
+ deps.discover(deps.getAgentName()),
269
+ deps.getQuotaBrief().catch(() => null),
270
+ ])
271
+
272
+ if (!discovered.ok) {
273
+ // Graceful static fallback — same content as the v1 show path,
274
+ // with the discovery failure surfaced.
275
+ const v1 = await handleModelCommand({ kind: 'show' }, deps)
276
+ return {
277
+ text: [`<i>(picker unavailable: ${deps.escapeHtml(discovered.reason)})</i>`, v1.text].join('\n'),
278
+ html: true,
279
+ }
280
+ }
281
+
282
+ const current = discovered.options.find((o) => o.current)
283
+ const lines: string[] = [`<b>Model — ${deps.escapeHtml(deps.getAgentName())}</b>`]
284
+ if (discovered.dismissFailed) {
285
+ lines.push('⚠️ <i>The picker may still be open on the agent pane — check it before switching.</i>')
286
+ }
287
+ if (current) {
288
+ const detail = current.detail ? ` · ${deps.escapeHtml(current.detail)}` : ''
289
+ lines.push(`Now: <b>${deps.escapeHtml(current.label)}</b>${detail}`)
290
+ } else {
291
+ lines.push('Now: <i>unknown (no ✔ row in picker)</i>')
292
+ }
293
+ if (quota) lines.push(`Quota: ${deps.escapeHtml(quota)}`)
294
+ lines.push('', 'Tap to switch (applies to the live session):')
295
+ lines.push(PERSIST_NOTE)
296
+
297
+ return { text: lines.join('\n'), html: true, keyboard: menuKeyboard(discovered.options) }
298
+ }
299
+
300
+ export interface ModelCallbackOutcome {
301
+ /** Short toast for answerCallbackQuery. */
302
+ answer: string
303
+ /** Replacement dashboard (message edit). */
304
+ reply: ModelMenuReply
305
+ }
306
+
307
+ /**
308
+ * Handle a `mdl:*` callback tap. `mdl:r` re-renders the dashboard;
309
+ * `mdl:s:<tag>` re-discovers the picker, resolves the tag back to a
310
+ * live label, and applies it session-only. A tag that no longer
311
+ * matches (claude updated its options since render) re-renders the
312
+ * menu instead of guessing.
313
+ */
314
+ export async function handleModelMenuCallback(
315
+ data: string,
316
+ deps: ModelMenuDeps & ModelCommandDeps,
317
+ ): Promise<ModelCallbackOutcome> {
318
+ if (data === MODEL_CALLBACK_REFRESH) {
319
+ return { answer: 'Refreshed', reply: await buildModelMenu(deps) }
320
+ }
321
+ if (!data.startsWith(MODEL_CALLBACK_SELECT)) {
322
+ return { answer: 'Unknown action', reply: await buildModelMenu(deps) }
323
+ }
324
+ if (deps.isBusy()) {
325
+ return { answer: 'Agent is mid-turn — try again shortly', reply: busyReply(deps) }
326
+ }
327
+
328
+ const tag = data.slice(MODEL_CALLBACK_SELECT.length)
329
+ const discovered = await deps.discover(deps.getAgentName())
330
+ if (!discovered.ok) {
331
+ return {
332
+ answer: 'Picker unavailable',
333
+ reply: {
334
+ text: `❌ Could not open the model picker: ${deps.escapeHtml(discovered.reason)}`,
335
+ html: true,
336
+ },
337
+ }
338
+ }
339
+ const target = discovered.options.find((o) => labelTag(o.label) === tag)
340
+ if (!target) {
341
+ // Options changed since the menu rendered — never guess; re-render.
342
+ const fresh = await buildModelMenu(deps)
343
+ return { answer: 'Model list changed — menu refreshed', reply: fresh }
344
+ }
345
+ if (target.current) {
346
+ const fresh = await buildModelMenu(deps)
347
+ return { answer: `Already on ${target.label}`, reply: fresh }
348
+ }
349
+
350
+ const result = await deps.select(deps.getAgentName(), target.label)
351
+ if (!result.ok) {
352
+ return {
353
+ answer: 'Switch failed',
354
+ reply: {
355
+ text: `❌ Switch to <b>${deps.escapeHtml(target.label)}</b> failed: ${deps.escapeHtml(result.reason)}`,
356
+ html: true,
357
+ },
358
+ }
359
+ }
360
+
361
+ const fresh = await buildModelMenu(deps)
362
+ const confirmed: ModelMenuReply = {
363
+ text: [`✅ ${deps.escapeHtml(result.confirmation)}`, '', fresh.text].join('\n'),
364
+ html: true,
365
+ ...(fresh.keyboard ? { keyboard: fresh.keyboard } : {}),
366
+ }
367
+ return { answer: result.confirmation, reply: confirmed }
368
+ }
@@ -203,3 +203,147 @@ describe("inject allowlist contract", () => {
203
203
  expect(INJECT_COMMANDS.has("/model")).toBe(true);
204
204
  });
205
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
+ });