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.
- package/dist/auth-broker/index.js +73 -11
- package/dist/cli/switchroom.js +1448 -1415
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +33 -0
- package/profiles/default/CLAUDE.md.hbs +13 -4
- package/telegram-plugin/dist/gateway/gateway.js +461 -22
- package/telegram-plugin/gateway/gateway.ts +115 -13
- package/telegram-plugin/gateway/model-command.ts +193 -7
- package/telegram-plugin/tests/model-command.test.ts +144 -0
- package/telegram-plugin/uat/scenarios/jtbd-model-command-dm.test.ts +93 -0
|
@@ -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 {
|
|
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 —
|
|
13926
|
-
//
|
|
13927
|
-
//
|
|
13928
|
-
//
|
|
13929
|
-
//
|
|
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
|
-
|
|
13932
|
-
|
|
13933
|
-
|
|
13934
|
-
|
|
13935
|
-
|
|
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 <name></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)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
+
});
|