switchroom 0.16.4 → 0.16.5
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 +450 -377
- 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 +277 -196
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/gateway.ts +20 -1
- package/telegram-plugin/gateway/model-command.ts +115 -4
- package/telegram-plugin/tests/model-command.test.ts +134 -0
|
@@ -3317,6 +3317,23 @@ function releaseTurnBufferGate(key: string, endingTurn?: CurrentTurn): void {
|
|
|
3317
3317
|
* Idempotent: a second purge is a no-op `.delete()` on a key already
|
|
3318
3318
|
* gone — handlers that already purge elsewhere are unharmed.
|
|
3319
3319
|
*/
|
|
3320
|
+
function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
|
|
3321
|
+
try {
|
|
3322
|
+
const rec =
|
|
3323
|
+
JSON.stringify({
|
|
3324
|
+
ts: Math.floor(endedAt / 1000),
|
|
3325
|
+
agent: process.env.SWITCHROOM_AGENT_NAME ?? 'unknown',
|
|
3326
|
+
duration_ms: turn.startedAt > 0 ? endedAt - turn.startedAt : 0,
|
|
3327
|
+
tools: turn.toolCallCount ?? 0,
|
|
3328
|
+
status: turn.finalAnswerDelivered ? 'complete' : 'no_reply',
|
|
3329
|
+
turn_id: turn.turnId,
|
|
3330
|
+
}) + '\n'
|
|
3331
|
+
appendFileSync('/state/agent/turns.jsonl', rec)
|
|
3332
|
+
} catch {
|
|
3333
|
+
// best-effort — never let metrics emission break turn teardown
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3320
3337
|
function endCurrentTurnAtomic(turn: CurrentTurn): void {
|
|
3321
3338
|
// PR-4e — keyed liveness + keyed clear (leak-close-at-origin). Flag-OFF: the
|
|
3322
3339
|
// guard is `currentTurn === turn` and the clear nulls the singleton, verbatim.
|
|
@@ -3331,9 +3348,11 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
|
|
|
3331
3348
|
// Status-surface observability: one line at every turn CLEAR (with how far
|
|
3332
3349
|
// the turn got), plus a DEGRADED warning when the turn did tool work but the
|
|
3333
3350
|
// live feed never opened because its sends failed (the resume-400 signature).
|
|
3351
|
+
const turnEndedAt = Date.now()
|
|
3334
3352
|
process.stderr.write(
|
|
3335
|
-
`telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn,
|
|
3353
|
+
`telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn, turnEndedAt)}\n`,
|
|
3336
3354
|
)
|
|
3355
|
+
emitTurnRecord(turn, turnEndedAt)
|
|
3337
3356
|
const degraded = detectStatusSurfaceDegraded(turn)
|
|
3338
3357
|
if (degraded != null) {
|
|
3339
3358
|
process.stderr.write(
|
|
@@ -233,6 +233,53 @@ 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
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Friendly display names for sr-* synthetic model names. An sr-* model in
|
|
241
|
+
* LiteLLM has no entry in `model_group_settings.*.forward_client_headers_to_llm_api`
|
|
242
|
+
* so the Anthropic OAuth credential is NEVER forwarded — safe to route to
|
|
243
|
+
* OpenRouter. Names here are display-only; the raw `sr-*` id is what gets
|
|
244
|
+
* injected into the agent's session. See reference/rfcs/litellm-max-subscription-invariants.md § I6.
|
|
245
|
+
*/
|
|
246
|
+
export const SR_MODEL_LABELS: Record<string, string> = {
|
|
247
|
+
'sr-gemini-2.5-pro': 'Gemini 2.5 Pro',
|
|
248
|
+
'sr-gemini-2.5-flash': 'Gemini 2.5 Flash',
|
|
249
|
+
'sr-deepseek-r1': 'DeepSeek R1',
|
|
250
|
+
'sr-deepseek-v3': 'DeepSeek V3',
|
|
251
|
+
'sr-glm-5': 'GLM-5',
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function srFriendlyLabel(srName: string): string {
|
|
255
|
+
return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Split picker-discovered options into native Claude options and sr-*
|
|
260
|
+
* (LiteLLM non-Anthropic) options. Options with "/" in the label or
|
|
261
|
+
* other non-native prefixes (e.g., "openrouter/...", "gpt-4") are
|
|
262
|
+
* silently dropped — they're internal LiteLLM routing paths, not
|
|
263
|
+
* user-facing switching targets.
|
|
264
|
+
*/
|
|
265
|
+
export function classifyDiscoveredOptions(options: ModelPickerOption[]): {
|
|
266
|
+
claude: ModelPickerOption[]
|
|
267
|
+
sr: ModelPickerOption[]
|
|
268
|
+
} {
|
|
269
|
+
return {
|
|
270
|
+
// Native Claude picker labels start with an uppercase letter (e.g.
|
|
271
|
+
// "Default (recommended)", "Opus", "Sonnet") or with "claude-" for full
|
|
272
|
+
// model IDs. This excludes sr-* names, internal routing paths
|
|
273
|
+
// ("openrouter/..."), and non-Claude models exposed by GATEWAY_MODEL_DISCOVERY
|
|
274
|
+
// ("gpt-4", "gpt-4o", "voyage-law-2", etc.) — those are LiteLLM internals
|
|
275
|
+
// not meant as user-facing switching targets.
|
|
276
|
+
claude: options.filter(
|
|
277
|
+
(o) => !o.label.startsWith('sr-') && !o.label.includes('/') &&
|
|
278
|
+
(/^[A-Z]/.test(o.label) || o.label.startsWith('claude-')),
|
|
279
|
+
),
|
|
280
|
+
sr: options.filter((o) => o.label.startsWith('sr-')),
|
|
281
|
+
}
|
|
282
|
+
}
|
|
236
283
|
|
|
237
284
|
export function modelSelectCallbackData(label: string): string {
|
|
238
285
|
// Identity is the label's hash, not its index — a tap re-discovers
|
|
@@ -249,15 +296,29 @@ function busyReply(deps: Pick<ModelMenuDeps, 'escapeHtml'>): ModelMenuReply {
|
|
|
249
296
|
}
|
|
250
297
|
}
|
|
251
298
|
|
|
252
|
-
function menuKeyboard(
|
|
299
|
+
function menuKeyboard(
|
|
300
|
+
claudeOptions: ModelPickerOption[],
|
|
301
|
+
srOptions: ModelPickerOption[],
|
|
302
|
+
): ModelMenuKeyboardButton[][] {
|
|
253
303
|
// One option per row (labels + ✔ render cleanly at full width on
|
|
254
304
|
// mobile), refresh on a trailing row.
|
|
255
|
-
const rows: ModelMenuKeyboardButton[][] =
|
|
305
|
+
const rows: ModelMenuKeyboardButton[][] = claudeOptions.map((o) => [
|
|
256
306
|
{
|
|
257
307
|
text: o.current ? `✅ ${o.label}` : o.label,
|
|
258
308
|
callback_data: modelSelectCallbackData(o.label),
|
|
259
309
|
},
|
|
260
310
|
])
|
|
311
|
+
// sr-* models are non-Anthropic (routed via LiteLLM → OpenRouter).
|
|
312
|
+
// Selection uses text-inject rather than cursor-nav — more reliable
|
|
313
|
+
// when the picker has many models (GATEWAY_MODEL_DISCOVERY=1).
|
|
314
|
+
for (const o of srOptions) {
|
|
315
|
+
rows.push([
|
|
316
|
+
{
|
|
317
|
+
text: `🌐 ${srFriendlyLabel(o.label)}`,
|
|
318
|
+
callback_data: `${MODEL_CALLBACK_SR}${o.label}`,
|
|
319
|
+
},
|
|
320
|
+
])
|
|
321
|
+
}
|
|
261
322
|
rows.push([{ text: '🔄 Refresh', callback_data: MODEL_CALLBACK_REFRESH }])
|
|
262
323
|
return rows
|
|
263
324
|
}
|
|
@@ -292,7 +353,8 @@ export async function buildModelMenu(
|
|
|
292
353
|
// or a prior session switch). Labelling the ✔ row "Now:" was misleading —
|
|
293
354
|
// it could read "Opus 4.8" while the live session is on Fable. Call it what
|
|
294
355
|
// it is, and tell the operator a switch applies to the live session.
|
|
295
|
-
const
|
|
356
|
+
const { claude: claudeOptions, sr: srOptions } = classifyDiscoveredOptions(discovered.options)
|
|
357
|
+
const current = claudeOptions.find((o) => o.current)
|
|
296
358
|
const lines: string[] = [`<b>Model — ${deps.escapeHtml(deps.getAgentName())}</b>`]
|
|
297
359
|
if (discovered.dismissFailed) {
|
|
298
360
|
lines.push('⚠️ <i>The picker may still be open on the agent pane — check it before switching.</i>')
|
|
@@ -305,9 +367,10 @@ export async function buildModelMenu(
|
|
|
305
367
|
}
|
|
306
368
|
if (quota) lines.push(`Quota: ${deps.escapeHtml(quota)}`)
|
|
307
369
|
lines.push('', 'Tap a model to switch the <b>live session</b>:')
|
|
370
|
+
if (srOptions.length > 0) lines.push('🌐 = non-Anthropic via LiteLLM (session only)')
|
|
308
371
|
lines.push(PERSIST_NOTE)
|
|
309
372
|
|
|
310
|
-
return { text: lines.join('\n'), html: true, keyboard: menuKeyboard(
|
|
373
|
+
return { text: lines.join('\n'), html: true, keyboard: menuKeyboard(claudeOptions, srOptions) }
|
|
311
374
|
}
|
|
312
375
|
|
|
313
376
|
export interface ModelCallbackOutcome {
|
|
@@ -346,6 +409,54 @@ export async function handleModelMenuCallback(
|
|
|
346
409
|
if (data === MODEL_CALLBACK_REFRESH) {
|
|
347
410
|
return { answer: 'Refreshed', reply: await buildModelMenu(deps) }
|
|
348
411
|
}
|
|
412
|
+
|
|
413
|
+
// sr-* model tap: text-inject `/model sr-<name>` rather than cursor-nav.
|
|
414
|
+
// Text-inject is more reliable when the picker has many models; sr-* names
|
|
415
|
+
// are safe (no entry in model_group_settings → no OAuth forwarding). See I6.
|
|
416
|
+
if (data.startsWith(MODEL_CALLBACK_SR)) {
|
|
417
|
+
const srName = data.slice(MODEL_CALLBACK_SR.length)
|
|
418
|
+
if (!isValidModelArg(srName)) {
|
|
419
|
+
return { answer: 'Invalid model name', reply: await buildModelMenu(deps) }
|
|
420
|
+
}
|
|
421
|
+
if (deps.isBusy()) {
|
|
422
|
+
return {
|
|
423
|
+
answer: '⏳ Agent is mid-turn — tap again when it’s idle',
|
|
424
|
+
reply: busyReply(deps),
|
|
425
|
+
toastOnly: true,
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
let srResult: InjectResult
|
|
429
|
+
try {
|
|
430
|
+
srResult = await deps.inject(deps.getAgentName(), `/model ${srName}`)
|
|
431
|
+
} catch (err) {
|
|
432
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
433
|
+
return {
|
|
434
|
+
answer: 'Switch failed',
|
|
435
|
+
reply: await menuWithBanner(deps, `❌ Switch to <b>${deps.escapeHtml(srName)}</b> failed: ${deps.escapeHtml(msg)}`),
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (srResult.outcome === 'ok') {
|
|
439
|
+
const friendlyName = srFriendlyLabel(srName)
|
|
440
|
+
const confirmation =
|
|
441
|
+
srResult.output
|
|
442
|
+
.split('\n')
|
|
443
|
+
.map((l) => l.trim())
|
|
444
|
+
.find((l) => /set model|switched/i.test(l)) ?? `Switched to ${friendlyName} (session)`
|
|
445
|
+
return {
|
|
446
|
+
answer: confirmation,
|
|
447
|
+
reply: await menuWithBanner(deps, `✅ ${deps.escapeHtml(confirmation)}`),
|
|
448
|
+
selectedModel: srName,
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
answer: 'Switch failed',
|
|
453
|
+
reply: await menuWithBanner(
|
|
454
|
+
deps,
|
|
455
|
+
`❌ Switch to <b>${deps.escapeHtml(srFriendlyLabel(srName))}</b> failed — agent may be mid-turn`,
|
|
456
|
+
),
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
349
460
|
if (!data.startsWith(MODEL_CALLBACK_SELECT)) {
|
|
350
461
|
return { answer: 'Unknown action', reply: await buildModelMenu(deps) }
|
|
351
462
|
}
|
|
@@ -253,7 +253,10 @@ import {
|
|
|
253
253
|
handleModelMenuCallback,
|
|
254
254
|
modelSelectCallbackData,
|
|
255
255
|
sessionModelFromConfirmation,
|
|
256
|
+
classifyDiscoveredOptions,
|
|
256
257
|
MODEL_CALLBACK_REFRESH,
|
|
258
|
+
MODEL_CALLBACK_SR,
|
|
259
|
+
SR_MODEL_LABELS,
|
|
257
260
|
type ModelMenuDeps,
|
|
258
261
|
} from "../gateway/model-command.js";
|
|
259
262
|
import { labelTag } from "../../src/agents/model-picker.js";
|
|
@@ -422,3 +425,134 @@ describe("sessionModelFromConfirmation", () => {
|
|
|
422
425
|
expect(out.reply.keyboard).toBeDefined();
|
|
423
426
|
});
|
|
424
427
|
});
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// Ship D — sr-* (LiteLLM non-Anthropic) model support
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
const OPTIONS_WITH_SR = [
|
|
434
|
+
{ index: 1, label: "Default (recommended)", detail: "Opus 4.8 with 1M context", current: false },
|
|
435
|
+
{ index: 2, label: "Sonnet", detail: "Sonnet 4.6", current: true },
|
|
436
|
+
{ index: 3, label: "sr-gemini-2.5-pro", detail: "", current: false },
|
|
437
|
+
{ index: 4, label: "sr-deepseek-r1", detail: "", current: false },
|
|
438
|
+
// internal path — should be filtered out
|
|
439
|
+
{ index: 5, label: "openrouter/google/gemini-2.5-pro", detail: "", current: false },
|
|
440
|
+
// bare OpenAI models from GATEWAY_MODEL_DISCOVERY — should also be filtered out
|
|
441
|
+
{ index: 6, label: "gpt-4", detail: "", current: false },
|
|
442
|
+
{ index: 7, label: "gpt-4o", detail: "", current: false },
|
|
443
|
+
{ index: 8, label: "voyage-law-2", detail: "", current: false },
|
|
444
|
+
// full claude ID — should be in claude bucket
|
|
445
|
+
{ index: 9, label: "claude-opus-4-8", detail: "", current: false },
|
|
446
|
+
];
|
|
447
|
+
|
|
448
|
+
describe("classifyDiscoveredOptions", () => {
|
|
449
|
+
it("puts native Claude options in claude, sr-* in sr, drops others", () => {
|
|
450
|
+
const { claude, sr } = classifyDiscoveredOptions(OPTIONS_WITH_SR);
|
|
451
|
+
expect(claude.map((o) => o.label)).toEqual([
|
|
452
|
+
"Default (recommended)", "Sonnet", "claude-opus-4-8",
|
|
453
|
+
]);
|
|
454
|
+
expect(sr.map((o) => o.label)).toEqual(["sr-gemini-2.5-pro", "sr-deepseek-r1"]);
|
|
455
|
+
// openrouter/*, gpt-*, voyage-* not present in either bucket
|
|
456
|
+
const all = [...claude, ...sr];
|
|
457
|
+
expect(all.find((o) => o.label.includes("openrouter"))).toBeUndefined();
|
|
458
|
+
expect(all.find((o) => o.label.startsWith("gpt-"))).toBeUndefined();
|
|
459
|
+
expect(all.find((o) => o.label.startsWith("voyage-"))).toBeUndefined();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("handles a list with no sr-* models", () => {
|
|
463
|
+
const { claude, sr } = classifyDiscoveredOptions(OPTIONS);
|
|
464
|
+
expect(claude).toHaveLength(3);
|
|
465
|
+
expect(sr).toHaveLength(0);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
describe("SR_MODEL_LABELS", () => {
|
|
470
|
+
it("has friendly names for the standard sr-* models", () => {
|
|
471
|
+
expect(SR_MODEL_LABELS["sr-gemini-2.5-pro"]).toBe("Gemini 2.5 Pro");
|
|
472
|
+
expect(SR_MODEL_LABELS["sr-deepseek-r1"]).toBe("DeepSeek R1");
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe("buildModelMenu — with sr-* models", () => {
|
|
477
|
+
function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
|
|
478
|
+
return makeMenuDeps({
|
|
479
|
+
discover: async () => ({
|
|
480
|
+
ok: true as const,
|
|
481
|
+
options: OPTIONS_WITH_SR,
|
|
482
|
+
currentLabel: "Sonnet",
|
|
483
|
+
}),
|
|
484
|
+
...overrides,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
it("shows 🌐 buttons for sr-* models, normal buttons for claude models", async () => {
|
|
489
|
+
const { deps } = makeMenuDepsWithSr();
|
|
490
|
+
const menu = await buildModelMenu(deps);
|
|
491
|
+
expect(menu.keyboard).toBeDefined();
|
|
492
|
+
const allButtons = menu.keyboard!.flat();
|
|
493
|
+
// 🌐 buttons for sr-*
|
|
494
|
+
expect(allButtons.find((b) => b.text === "🌐 Gemini 2.5 Pro")).toBeDefined();
|
|
495
|
+
expect(allButtons.find((b) => b.text === "🌐 DeepSeek R1")).toBeDefined();
|
|
496
|
+
// Regular buttons for Claude models
|
|
497
|
+
expect(allButtons.find((b) => b.text === "Default (recommended)")).toBeDefined();
|
|
498
|
+
// openrouter/* not shown at all
|
|
499
|
+
expect(allButtons.find((b) => b.text.includes("openrouter"))).toBeUndefined();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("sr-* buttons use mdl:sr: callback prefix", async () => {
|
|
503
|
+
const { deps } = makeMenuDepsWithSr();
|
|
504
|
+
const menu = await buildModelMenu(deps);
|
|
505
|
+
const srButton = menu.keyboard!.flat().find((b) => b.text === "🌐 Gemini 2.5 Pro");
|
|
506
|
+
expect(srButton?.callback_data).toBe(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("shows 🌐 = non-Anthropic legend when sr-* models are present", async () => {
|
|
510
|
+
const { deps } = makeMenuDepsWithSr();
|
|
511
|
+
const menu = await buildModelMenu(deps);
|
|
512
|
+
expect(menu.text).toContain("🌐 = non-Anthropic");
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("no legend when no sr-* models in picker", async () => {
|
|
516
|
+
const { deps } = makeMenuDeps();
|
|
517
|
+
const menu = await buildModelMenu(deps);
|
|
518
|
+
expect(menu.text).not.toContain("🌐 = non-Anthropic");
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
describe("handleModelMenuCallback — sr-* selection", () => {
|
|
523
|
+
function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
|
|
524
|
+
return makeMenuDeps({
|
|
525
|
+
discover: async () => ({
|
|
526
|
+
ok: true as const,
|
|
527
|
+
options: OPTIONS_WITH_SR,
|
|
528
|
+
currentLabel: "Sonnet",
|
|
529
|
+
}),
|
|
530
|
+
...overrides,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
it("sr-* tap uses inject path, not cursor nav", async () => {
|
|
535
|
+
const { deps, calls, injectCalls } = makeMenuDepsWithSr();
|
|
536
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
|
|
537
|
+
// inject was called with the raw /model command
|
|
538
|
+
expect(injectCalls).toContainEqual({ agent: "klanker", command: "/model sr-gemini-2.5-pro" });
|
|
539
|
+
// select (cursor nav) was NOT called
|
|
540
|
+
expect(calls.select).toHaveLength(0);
|
|
541
|
+
expect(out.answer).toContain("Set model to sonnet");
|
|
542
|
+
expect(out.selectedModel).toBe("sr-gemini-2.5-pro");
|
|
543
|
+
expect(out.reply.keyboard).toBeDefined();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("sr-* tap while busy returns toast-only with no inject", async () => {
|
|
547
|
+
const { deps, injectCalls } = makeMenuDepsWithSr({ isBusy: () => true });
|
|
548
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
|
|
549
|
+
expect(out.toastOnly).toBe(true);
|
|
550
|
+
expect(injectCalls).toHaveLength(0);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("rejects malformed sr-* callback data", async () => {
|
|
554
|
+
const { deps } = makeMenuDepsWithSr();
|
|
555
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}bad name with spaces`, deps);
|
|
556
|
+
expect(out.answer).toBe("Invalid model name");
|
|
557
|
+
});
|
|
558
|
+
});
|