switchroom 0.16.4 → 0.16.6
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 +94 -87
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +451 -378
- package/dist/host-control/main.js +157 -157
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +10 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +296 -198
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/gateway.ts +26 -1
- package/telegram-plugin/gateway/model-command.ts +140 -9
- package/telegram-plugin/tests/model-command.test.ts +162 -0
|
@@ -276,6 +276,7 @@ import {
|
|
|
276
276
|
buildModelMenu,
|
|
277
277
|
handleModelMenuCallback,
|
|
278
278
|
MODEL_CALLBACK_PREFIX,
|
|
279
|
+
MODEL_CALLBACK_HEADER,
|
|
279
280
|
type ModelMenuDeps,
|
|
280
281
|
type ModelCommandDeps,
|
|
281
282
|
type ModelMenuReply,
|
|
@@ -3317,6 +3318,23 @@ function releaseTurnBufferGate(key: string, endingTurn?: CurrentTurn): void {
|
|
|
3317
3318
|
* Idempotent: a second purge is a no-op `.delete()` on a key already
|
|
3318
3319
|
* gone — handlers that already purge elsewhere are unharmed.
|
|
3319
3320
|
*/
|
|
3321
|
+
function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
|
|
3322
|
+
try {
|
|
3323
|
+
const rec =
|
|
3324
|
+
JSON.stringify({
|
|
3325
|
+
ts: Math.floor(endedAt / 1000),
|
|
3326
|
+
agent: process.env.SWITCHROOM_AGENT_NAME ?? 'unknown',
|
|
3327
|
+
duration_ms: turn.startedAt > 0 ? endedAt - turn.startedAt : 0,
|
|
3328
|
+
tools: turn.toolCallCount ?? 0,
|
|
3329
|
+
status: turn.finalAnswerDelivered ? 'complete' : 'no_reply',
|
|
3330
|
+
turn_id: turn.turnId,
|
|
3331
|
+
}) + '\n'
|
|
3332
|
+
appendFileSync('/state/agent/turns.jsonl', rec)
|
|
3333
|
+
} catch {
|
|
3334
|
+
// best-effort — never let metrics emission break turn teardown
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3320
3338
|
function endCurrentTurnAtomic(turn: CurrentTurn): void {
|
|
3321
3339
|
// PR-4e — keyed liveness + keyed clear (leak-close-at-origin). Flag-OFF: the
|
|
3322
3340
|
// guard is `currentTurn === turn` and the clear nulls the singleton, verbatim.
|
|
@@ -3331,9 +3349,11 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
|
|
|
3331
3349
|
// Status-surface observability: one line at every turn CLEAR (with how far
|
|
3332
3350
|
// the turn got), plus a DEGRADED warning when the turn did tool work but the
|
|
3333
3351
|
// live feed never opened because its sends failed (the resume-400 signature).
|
|
3352
|
+
const turnEndedAt = Date.now()
|
|
3334
3353
|
process.stderr.write(
|
|
3335
|
-
`telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn,
|
|
3354
|
+
`telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn, turnEndedAt)}\n`,
|
|
3336
3355
|
)
|
|
3356
|
+
emitTurnRecord(turn, turnEndedAt)
|
|
3337
3357
|
const degraded = detectStatusSurfaceDegraded(turn)
|
|
3338
3358
|
if (degraded != null) {
|
|
3339
3359
|
process.stderr.write(
|
|
@@ -20702,6 +20722,11 @@ bot.on('callback_query:data', async ctx => {
|
|
|
20702
20722
|
// drive behind the pane lock. A callback can only be answered once, so
|
|
20703
20723
|
// the rich result (what was set / why it failed) is conveyed by the
|
|
20704
20724
|
// message edit — which now ALWAYS keeps the menu buttons.
|
|
20725
|
+
// Header rows are section labels — informational, no model switch.
|
|
20726
|
+
if (data === MODEL_CALLBACK_HEADER) {
|
|
20727
|
+
await ctx.answerCallbackQuery({ text: 'Tap a model in this section to switch' }).catch(() => {})
|
|
20728
|
+
return
|
|
20729
|
+
}
|
|
20705
20730
|
await ctx.answerCallbackQuery({ text: 'Switching…' }).catch(() => {})
|
|
20706
20731
|
try {
|
|
20707
20732
|
const outcome = await handleModelMenuCallback(data, modelDeps)
|
|
@@ -233,6 +233,55 @@ export interface ModelMenuReply {
|
|
|
233
233
|
export const MODEL_CALLBACK_PREFIX = 'mdl:'
|
|
234
234
|
const MODEL_CALLBACK_SELECT = 'mdl:s:'
|
|
235
235
|
export const MODEL_CALLBACK_REFRESH = 'mdl:r'
|
|
236
|
+
/** Callback prefix for sr-* (LiteLLM non-Anthropic) model selection. */
|
|
237
|
+
export const MODEL_CALLBACK_SR = 'mdl:sr:'
|
|
238
|
+
/** Callback for section-header rows — shows an informational toast, no action. */
|
|
239
|
+
export const MODEL_CALLBACK_HEADER = 'mdl:h'
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Friendly display names for sr-* synthetic model names. An sr-* model in
|
|
243
|
+
* LiteLLM has no entry in `model_group_settings.*.forward_client_headers_to_llm_api`
|
|
244
|
+
* so the Anthropic OAuth credential is NEVER forwarded — safe to route to
|
|
245
|
+
* OpenRouter. Names here are display-only; the raw `sr-*` id is what gets
|
|
246
|
+
* injected into the agent's session. See reference/rfcs/litellm-max-subscription-invariants.md § I6.
|
|
247
|
+
*/
|
|
248
|
+
export const SR_MODEL_LABELS: Record<string, string> = {
|
|
249
|
+
'sr-gemini-2.5-pro': 'Gemini 2.5 Pro',
|
|
250
|
+
'sr-gemini-2.5-flash': 'Gemini 2.5 Flash',
|
|
251
|
+
'sr-deepseek-r1': 'DeepSeek R1',
|
|
252
|
+
'sr-deepseek-v3': 'DeepSeek V3',
|
|
253
|
+
'sr-glm-5': 'GLM-5',
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function srFriendlyLabel(srName: string): string {
|
|
257
|
+
return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Split picker-discovered options into native Claude options and sr-*
|
|
262
|
+
* (LiteLLM non-Anthropic) options. Options with "/" in the label or
|
|
263
|
+
* other non-native prefixes (e.g., "openrouter/...", "gpt-4") are
|
|
264
|
+
* silently dropped — they're internal LiteLLM routing paths, not
|
|
265
|
+
* user-facing switching targets.
|
|
266
|
+
*/
|
|
267
|
+
export function classifyDiscoveredOptions(options: ModelPickerOption[]): {
|
|
268
|
+
claude: ModelPickerOption[]
|
|
269
|
+
sr: ModelPickerOption[]
|
|
270
|
+
} {
|
|
271
|
+
return {
|
|
272
|
+
// Native Claude picker labels start with an uppercase letter (e.g.
|
|
273
|
+
// "Default (recommended)", "Opus", "Sonnet") or with "claude-" for full
|
|
274
|
+
// model IDs. This excludes sr-* names, internal routing paths
|
|
275
|
+
// ("openrouter/..."), and non-Claude models exposed by GATEWAY_MODEL_DISCOVERY
|
|
276
|
+
// ("gpt-4", "gpt-4o", "voyage-law-2", etc.) — those are LiteLLM internals
|
|
277
|
+
// not meant as user-facing switching targets.
|
|
278
|
+
claude: options.filter(
|
|
279
|
+
(o) => !o.label.startsWith('sr-') && !o.label.includes('/') &&
|
|
280
|
+
(/^[A-Z]/.test(o.label) || o.label.startsWith('claude-')),
|
|
281
|
+
),
|
|
282
|
+
sr: options.filter((o) => o.label.startsWith('sr-')),
|
|
283
|
+
}
|
|
284
|
+
}
|
|
236
285
|
|
|
237
286
|
export function modelSelectCallbackData(label: string): string {
|
|
238
287
|
// Identity is the label's hash, not its index — a tap re-discovers
|
|
@@ -249,15 +298,38 @@ function busyReply(deps: Pick<ModelMenuDeps, 'escapeHtml'>): ModelMenuReply {
|
|
|
249
298
|
}
|
|
250
299
|
}
|
|
251
300
|
|
|
252
|
-
function
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
301
|
+
function headerRow(label: string): ModelMenuKeyboardButton[] {
|
|
302
|
+
return [{ text: label, callback_data: MODEL_CALLBACK_HEADER }]
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function menuKeyboard(
|
|
306
|
+
claudeOptions: ModelPickerOption[],
|
|
307
|
+
srOptions: ModelPickerOption[],
|
|
308
|
+
): ModelMenuKeyboardButton[][] {
|
|
309
|
+
const hasBothGroups = claudeOptions.length > 0 && srOptions.length > 0
|
|
310
|
+
const rows: ModelMenuKeyboardButton[][] = []
|
|
311
|
+
|
|
312
|
+
if (hasBothGroups) rows.push(headerRow('── Claude (Max / Pro subscription) ──'))
|
|
313
|
+
for (const o of claudeOptions) {
|
|
314
|
+
rows.push([{
|
|
257
315
|
text: o.current ? `✅ ${o.label}` : o.label,
|
|
258
316
|
callback_data: modelSelectCallbackData(o.label),
|
|
259
|
-
}
|
|
260
|
-
|
|
317
|
+
}])
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// sr-* models are non-Anthropic (routed via LiteLLM → OpenRouter).
|
|
321
|
+
// Selection uses text-inject rather than cursor-nav — more reliable
|
|
322
|
+
// when the picker has many models (GATEWAY_MODEL_DISCOVERY=1).
|
|
323
|
+
if (srOptions.length > 0) {
|
|
324
|
+
rows.push(headerRow('── OpenRouter / external ──'))
|
|
325
|
+
for (const o of srOptions) {
|
|
326
|
+
rows.push([{
|
|
327
|
+
text: `🌐 ${srFriendlyLabel(o.label)}`,
|
|
328
|
+
callback_data: `${MODEL_CALLBACK_SR}${o.label}`,
|
|
329
|
+
}])
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
261
333
|
rows.push([{ text: '🔄 Refresh', callback_data: MODEL_CALLBACK_REFRESH }])
|
|
262
334
|
return rows
|
|
263
335
|
}
|
|
@@ -292,7 +364,8 @@ export async function buildModelMenu(
|
|
|
292
364
|
// or a prior session switch). Labelling the ✔ row "Now:" was misleading —
|
|
293
365
|
// it could read "Opus 4.8" while the live session is on Fable. Call it what
|
|
294
366
|
// it is, and tell the operator a switch applies to the live session.
|
|
295
|
-
const
|
|
367
|
+
const { claude: claudeOptions, sr: srOptions } = classifyDiscoveredOptions(discovered.options)
|
|
368
|
+
const current = claudeOptions.find((o) => o.current)
|
|
296
369
|
const lines: string[] = [`<b>Model — ${deps.escapeHtml(deps.getAgentName())}</b>`]
|
|
297
370
|
if (discovered.dismissFailed) {
|
|
298
371
|
lines.push('⚠️ <i>The picker may still be open on the agent pane — check it before switching.</i>')
|
|
@@ -305,9 +378,12 @@ export async function buildModelMenu(
|
|
|
305
378
|
}
|
|
306
379
|
if (quota) lines.push(`Quota: ${deps.escapeHtml(quota)}`)
|
|
307
380
|
lines.push('', 'Tap a model to switch the <b>live session</b>:')
|
|
381
|
+
if (srOptions.length > 0) {
|
|
382
|
+
lines.push('Claude models use your Max/Pro subscription. 🌐 models are billed separately via OpenRouter.')
|
|
383
|
+
}
|
|
308
384
|
lines.push(PERSIST_NOTE)
|
|
309
385
|
|
|
310
|
-
return { text: lines.join('\n'), html: true, keyboard: menuKeyboard(
|
|
386
|
+
return { text: lines.join('\n'), html: true, keyboard: menuKeyboard(claudeOptions, srOptions) }
|
|
311
387
|
}
|
|
312
388
|
|
|
313
389
|
export interface ModelCallbackOutcome {
|
|
@@ -346,6 +422,61 @@ export async function handleModelMenuCallback(
|
|
|
346
422
|
if (data === MODEL_CALLBACK_REFRESH) {
|
|
347
423
|
return { answer: 'Refreshed', reply: await buildModelMenu(deps) }
|
|
348
424
|
}
|
|
425
|
+
|
|
426
|
+
if (data === MODEL_CALLBACK_HEADER) {
|
|
427
|
+
// Section-header row — the gateway handles this with a direct answerCallbackQuery
|
|
428
|
+
// before calling this function, so this branch is dead in practice. Guard
|
|
429
|
+
// for callers that skip gateway.ts (tests, future refactors).
|
|
430
|
+
return { answer: 'Tap a model in this section to switch', reply: { text: '', html: true }, toastOnly: true }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// sr-* model tap: text-inject `/model sr-<name>` rather than cursor-nav.
|
|
434
|
+
// Text-inject is more reliable when the picker has many models; sr-* names
|
|
435
|
+
// are safe (no entry in model_group_settings → no OAuth forwarding). See I6.
|
|
436
|
+
if (data.startsWith(MODEL_CALLBACK_SR)) {
|
|
437
|
+
const srName = data.slice(MODEL_CALLBACK_SR.length)
|
|
438
|
+
if (!isValidModelArg(srName)) {
|
|
439
|
+
return { answer: 'Invalid model name', reply: await buildModelMenu(deps) }
|
|
440
|
+
}
|
|
441
|
+
if (deps.isBusy()) {
|
|
442
|
+
return {
|
|
443
|
+
answer: '⏳ Agent is mid-turn — tap again when it’s idle',
|
|
444
|
+
reply: busyReply(deps),
|
|
445
|
+
toastOnly: true,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
let srResult: InjectResult
|
|
449
|
+
try {
|
|
450
|
+
srResult = await deps.inject(deps.getAgentName(), `/model ${srName}`)
|
|
451
|
+
} catch (err) {
|
|
452
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
453
|
+
return {
|
|
454
|
+
answer: 'Switch failed',
|
|
455
|
+
reply: await menuWithBanner(deps, `❌ Switch to <b>${deps.escapeHtml(srName)}</b> failed: ${deps.escapeHtml(msg)}`),
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (srResult.outcome === 'ok') {
|
|
459
|
+
const friendlyName = srFriendlyLabel(srName)
|
|
460
|
+
const confirmation =
|
|
461
|
+
srResult.output
|
|
462
|
+
.split('\n')
|
|
463
|
+
.map((l) => l.trim())
|
|
464
|
+
.find((l) => /set model|switched/i.test(l)) ?? `Switched to ${friendlyName} (session)`
|
|
465
|
+
return {
|
|
466
|
+
answer: confirmation,
|
|
467
|
+
reply: await menuWithBanner(deps, `✅ ${deps.escapeHtml(confirmation)}`),
|
|
468
|
+
selectedModel: srName,
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
answer: 'Switch failed',
|
|
473
|
+
reply: await menuWithBanner(
|
|
474
|
+
deps,
|
|
475
|
+
`❌ Switch to <b>${deps.escapeHtml(srFriendlyLabel(srName))}</b> failed — agent may be mid-turn`,
|
|
476
|
+
),
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
349
480
|
if (!data.startsWith(MODEL_CALLBACK_SELECT)) {
|
|
350
481
|
return { answer: 'Unknown action', reply: await buildModelMenu(deps) }
|
|
351
482
|
}
|
|
@@ -253,7 +253,11 @@ import {
|
|
|
253
253
|
handleModelMenuCallback,
|
|
254
254
|
modelSelectCallbackData,
|
|
255
255
|
sessionModelFromConfirmation,
|
|
256
|
+
classifyDiscoveredOptions,
|
|
256
257
|
MODEL_CALLBACK_REFRESH,
|
|
258
|
+
MODEL_CALLBACK_HEADER,
|
|
259
|
+
MODEL_CALLBACK_SR,
|
|
260
|
+
SR_MODEL_LABELS,
|
|
257
261
|
type ModelMenuDeps,
|
|
258
262
|
} from "../gateway/model-command.js";
|
|
259
263
|
import { labelTag } from "../../src/agents/model-picker.js";
|
|
@@ -422,3 +426,161 @@ describe("sessionModelFromConfirmation", () => {
|
|
|
422
426
|
expect(out.reply.keyboard).toBeDefined();
|
|
423
427
|
});
|
|
424
428
|
});
|
|
429
|
+
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
// Ship D — sr-* (LiteLLM non-Anthropic) model support
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
const OPTIONS_WITH_SR = [
|
|
435
|
+
{ index: 1, label: "Default (recommended)", detail: "Opus 4.8 with 1M context", current: false },
|
|
436
|
+
{ index: 2, label: "Sonnet", detail: "Sonnet 4.6", current: true },
|
|
437
|
+
{ index: 3, label: "sr-gemini-2.5-pro", detail: "", current: false },
|
|
438
|
+
{ index: 4, label: "sr-deepseek-r1", detail: "", current: false },
|
|
439
|
+
// internal path — should be filtered out
|
|
440
|
+
{ index: 5, label: "openrouter/google/gemini-2.5-pro", detail: "", current: false },
|
|
441
|
+
// bare OpenAI models from GATEWAY_MODEL_DISCOVERY — should also be filtered out
|
|
442
|
+
{ index: 6, label: "gpt-4", detail: "", current: false },
|
|
443
|
+
{ index: 7, label: "gpt-4o", detail: "", current: false },
|
|
444
|
+
{ index: 8, label: "voyage-law-2", detail: "", current: false },
|
|
445
|
+
// full claude ID — should be in claude bucket
|
|
446
|
+
{ index: 9, label: "claude-opus-4-8", detail: "", current: false },
|
|
447
|
+
];
|
|
448
|
+
|
|
449
|
+
describe("classifyDiscoveredOptions", () => {
|
|
450
|
+
it("puts native Claude options in claude, sr-* in sr, drops others", () => {
|
|
451
|
+
const { claude, sr } = classifyDiscoveredOptions(OPTIONS_WITH_SR);
|
|
452
|
+
expect(claude.map((o) => o.label)).toEqual([
|
|
453
|
+
"Default (recommended)", "Sonnet", "claude-opus-4-8",
|
|
454
|
+
]);
|
|
455
|
+
expect(sr.map((o) => o.label)).toEqual(["sr-gemini-2.5-pro", "sr-deepseek-r1"]);
|
|
456
|
+
// openrouter/*, gpt-*, voyage-* not present in either bucket
|
|
457
|
+
const all = [...claude, ...sr];
|
|
458
|
+
expect(all.find((o) => o.label.includes("openrouter"))).toBeUndefined();
|
|
459
|
+
expect(all.find((o) => o.label.startsWith("gpt-"))).toBeUndefined();
|
|
460
|
+
expect(all.find((o) => o.label.startsWith("voyage-"))).toBeUndefined();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("handles a list with no sr-* models", () => {
|
|
464
|
+
const { claude, sr } = classifyDiscoveredOptions(OPTIONS);
|
|
465
|
+
expect(claude).toHaveLength(3);
|
|
466
|
+
expect(sr).toHaveLength(0);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe("SR_MODEL_LABELS", () => {
|
|
471
|
+
it("has friendly names for the standard sr-* models", () => {
|
|
472
|
+
expect(SR_MODEL_LABELS["sr-gemini-2.5-pro"]).toBe("Gemini 2.5 Pro");
|
|
473
|
+
expect(SR_MODEL_LABELS["sr-deepseek-r1"]).toBe("DeepSeek R1");
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
describe("buildModelMenu — with sr-* models", () => {
|
|
478
|
+
function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
|
|
479
|
+
return makeMenuDeps({
|
|
480
|
+
discover: async () => ({
|
|
481
|
+
ok: true as const,
|
|
482
|
+
options: OPTIONS_WITH_SR,
|
|
483
|
+
currentLabel: "Sonnet",
|
|
484
|
+
}),
|
|
485
|
+
...overrides,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
it("shows 🌐 buttons for sr-* models, normal buttons for claude models", async () => {
|
|
490
|
+
const { deps } = makeMenuDepsWithSr();
|
|
491
|
+
const menu = await buildModelMenu(deps);
|
|
492
|
+
expect(menu.keyboard).toBeDefined();
|
|
493
|
+
const allButtons = menu.keyboard!.flat();
|
|
494
|
+
// 🌐 buttons for sr-*
|
|
495
|
+
expect(allButtons.find((b) => b.text === "🌐 Gemini 2.5 Pro")).toBeDefined();
|
|
496
|
+
expect(allButtons.find((b) => b.text === "🌐 DeepSeek R1")).toBeDefined();
|
|
497
|
+
// Regular buttons for Claude models
|
|
498
|
+
expect(allButtons.find((b) => b.text === "Default (recommended)")).toBeDefined();
|
|
499
|
+
// openrouter/* not shown at all
|
|
500
|
+
expect(allButtons.find((b) => b.text.includes("openrouter"))).toBeUndefined();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("sr-* buttons use mdl:sr: callback prefix", async () => {
|
|
504
|
+
const { deps } = makeMenuDepsWithSr();
|
|
505
|
+
const menu = await buildModelMenu(deps);
|
|
506
|
+
const srButton = menu.keyboard!.flat().find((b) => b.text === "🌐 Gemini 2.5 Pro");
|
|
507
|
+
expect(srButton?.callback_data).toBe(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("shows section header rows when both claude and sr-* models present", async () => {
|
|
511
|
+
const { deps } = makeMenuDepsWithSr();
|
|
512
|
+
const menu = await buildModelMenu(deps);
|
|
513
|
+
const allButtons = menu.keyboard!.flat();
|
|
514
|
+
const headers = allButtons.filter((b) => b.callback_data === MODEL_CALLBACK_HEADER);
|
|
515
|
+
expect(headers.length).toBe(2);
|
|
516
|
+
expect(headers[0].text).toContain("Claude");
|
|
517
|
+
expect(headers[1].text).toContain("OpenRouter");
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("no section headers when only claude models (no sr-*)", async () => {
|
|
521
|
+
const { deps } = makeMenuDeps();
|
|
522
|
+
const menu = await buildModelMenu(deps);
|
|
523
|
+
const allButtons = (menu.keyboard ?? []).flat();
|
|
524
|
+
const headers = allButtons.filter((b) => b.callback_data === MODEL_CALLBACK_HEADER);
|
|
525
|
+
expect(headers.length).toBe(0);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("header-row tap returns toastOnly without inject or model change", async () => {
|
|
529
|
+
const { deps, injectCalls } = makeMenuDepsWithSr();
|
|
530
|
+
const out = await handleModelMenuCallback(MODEL_CALLBACK_HEADER, deps);
|
|
531
|
+
expect(out.toastOnly).toBe(true);
|
|
532
|
+
expect(out.selectedModel).toBeUndefined();
|
|
533
|
+
expect(injectCalls).toHaveLength(0);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("shows subscription/OpenRouter legend when sr-* models are present", async () => {
|
|
537
|
+
const { deps } = makeMenuDepsWithSr();
|
|
538
|
+
const menu = await buildModelMenu(deps);
|
|
539
|
+
expect(menu.text).toContain("Max/Pro subscription");
|
|
540
|
+
expect(menu.text).toContain("OpenRouter");
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("no legend when no sr-* models in picker", async () => {
|
|
544
|
+
const { deps } = makeMenuDeps();
|
|
545
|
+
const menu = await buildModelMenu(deps);
|
|
546
|
+
expect(menu.text).not.toContain("OpenRouter");
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
describe("handleModelMenuCallback — sr-* selection", () => {
|
|
551
|
+
function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
|
|
552
|
+
return makeMenuDeps({
|
|
553
|
+
discover: async () => ({
|
|
554
|
+
ok: true as const,
|
|
555
|
+
options: OPTIONS_WITH_SR,
|
|
556
|
+
currentLabel: "Sonnet",
|
|
557
|
+
}),
|
|
558
|
+
...overrides,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
it("sr-* tap uses inject path, not cursor nav", async () => {
|
|
563
|
+
const { deps, calls, injectCalls } = makeMenuDepsWithSr();
|
|
564
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
|
|
565
|
+
// inject was called with the raw /model command
|
|
566
|
+
expect(injectCalls).toContainEqual({ agent: "klanker", command: "/model sr-gemini-2.5-pro" });
|
|
567
|
+
// select (cursor nav) was NOT called
|
|
568
|
+
expect(calls.select).toHaveLength(0);
|
|
569
|
+
expect(out.answer).toContain("Set model to sonnet");
|
|
570
|
+
expect(out.selectedModel).toBe("sr-gemini-2.5-pro");
|
|
571
|
+
expect(out.reply.keyboard).toBeDefined();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("sr-* tap while busy returns toast-only with no inject", async () => {
|
|
575
|
+
const { deps, injectCalls } = makeMenuDepsWithSr({ isBusy: () => true });
|
|
576
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
|
|
577
|
+
expect(out.toastOnly).toBe(true);
|
|
578
|
+
expect(injectCalls).toHaveLength(0);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("rejects malformed sr-* callback data", async () => {
|
|
582
|
+
const { deps } = makeMenuDepsWithSr();
|
|
583
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}bad name with spaces`, deps);
|
|
584
|
+
expect(out.answer).toBe("Invalid model name");
|
|
585
|
+
});
|
|
586
|
+
});
|