switchroom 0.15.2 → 0.15.4

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.
@@ -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,250 @@ 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
+ // claude's ✔ marks the DEFAULT FOR NEW SESSIONS, which is a different axis
283
+ // from the model the agent is running right now (set via --model at launch
284
+ // or a prior session switch). Labelling the ✔ row "Now:" was misleading —
285
+ // it could read "Opus 4.8" while the live session is on Fable. Call it what
286
+ // it is, and tell the operator a switch applies to the live session.
287
+ const current = discovered.options.find((o) => o.current)
288
+ const lines: string[] = [`<b>Model — ${deps.escapeHtml(deps.getAgentName())}</b>`]
289
+ if (discovered.dismissFailed) {
290
+ lines.push('⚠️ <i>The picker may still be open on the agent pane — check it before switching.</i>')
291
+ }
292
+ if (current) {
293
+ const detail = current.detail ? ` · ${deps.escapeHtml(current.detail)}` : ''
294
+ lines.push(`Default (new sessions): <b>${deps.escapeHtml(current.label)}</b>${detail}`)
295
+ } else {
296
+ lines.push('Default (new sessions): <i>unknown (no ✔ row in picker)</i>')
297
+ }
298
+ if (quota) lines.push(`Quota: ${deps.escapeHtml(quota)}`)
299
+ lines.push('', 'Tap a model to switch the <b>live session</b>:')
300
+ lines.push(PERSIST_NOTE)
301
+
302
+ return { text: lines.join('\n'), html: true, keyboard: menuKeyboard(discovered.options) }
303
+ }
304
+
305
+ export interface ModelCallbackOutcome {
306
+ /**
307
+ * When true, the caller should ONLY show the toast (`answer`) and leave
308
+ * the existing menu message untouched — used for the mid-turn refusal so
309
+ * the menu keeps its buttons and the operator can simply tap again when
310
+ * the agent goes idle, instead of the menu collapsing to a button-less
311
+ * "try again" line (which read as "nothing happened").
312
+ */
313
+ toastOnly?: boolean
314
+ /**
315
+ * On a successful session switch, the live model name now running (parsed
316
+ * from claude's confirmation, e.g. "Fable 5"). The gateway records this as
317
+ * the session-model override so `/status` reflects what's actually running.
318
+ * Absent on every non-switch outcome.
319
+ */
320
+ selectedModel?: string
321
+ /** Short toast for answerCallbackQuery. */
322
+ answer: string
323
+ /** Replacement dashboard (message edit). */
324
+ reply: ModelMenuReply
325
+ }
326
+
327
+ /**
328
+ * Handle a `mdl:*` callback tap. `mdl:r` re-renders the dashboard;
329
+ * `mdl:s:<tag>` re-discovers the picker, resolves the tag back to a
330
+ * live label, and applies it session-only. A tag that no longer
331
+ * matches (claude updated its options since render) re-renders the
332
+ * menu instead of guessing.
333
+ */
334
+ export async function handleModelMenuCallback(
335
+ data: string,
336
+ deps: ModelMenuDeps & ModelCommandDeps,
337
+ ): Promise<ModelCallbackOutcome> {
338
+ if (data === MODEL_CALLBACK_REFRESH) {
339
+ return { answer: 'Refreshed', reply: await buildModelMenu(deps) }
340
+ }
341
+ if (!data.startsWith(MODEL_CALLBACK_SELECT)) {
342
+ return { answer: 'Unknown action', reply: await buildModelMenu(deps) }
343
+ }
344
+ // Mid-turn: refuse WITHOUT touching the message. Driving the picker types
345
+ // into claude's input box, which mid-turn would queue "/model" as user
346
+ // text. toastOnly keeps the menu (and its buttons) exactly as-is so the
347
+ // operator just taps again when the agent is idle — no button-less
348
+ // "try again" line that read as a dead menu.
349
+ if (deps.isBusy()) {
350
+ return {
351
+ answer: '⏳ Agent is mid-turn — tap again when it’s idle',
352
+ reply: busyReply(deps),
353
+ toastOnly: true,
354
+ }
355
+ }
356
+
357
+ const tag = data.slice(MODEL_CALLBACK_SELECT.length)
358
+ const discovered = await deps.discover(deps.getAgentName())
359
+ if (!discovered.ok) {
360
+ // Keep the menu interactive: re-render (falls back to v1 text if even
361
+ // the show path can't discover) with the failure as a banner.
362
+ return {
363
+ answer: 'Picker unavailable',
364
+ reply: await menuWithBanner(
365
+ deps,
366
+ `❌ Could not open the model picker: ${deps.escapeHtml(discovered.reason)}`,
367
+ ),
368
+ }
369
+ }
370
+ const target = discovered.options.find((o) => labelTag(o.label) === tag)
371
+ if (!target) {
372
+ // Options changed since the menu rendered — never guess; re-render.
373
+ const fresh = await buildModelMenu(deps)
374
+ return { answer: 'Model list changed — menu refreshed', reply: fresh }
375
+ }
376
+ // NOTE: do NOT short-circuit when target.current is set. The picker's ✔
377
+ // marks claude's DEFAULT FOR NEW SESSIONS, which is a DIFFERENT axis from
378
+ // the model the live session is running (set by --model at launch). Tapping
379
+ // the ✔ row to apply that model to the live session is a legitimate switch
380
+ // — e.g. an agent launched on Fable tapping "Default (Opus)". Skipping it
381
+ // here was the "tapped Default, nothing happened" bug. Always drive the
382
+ // selection; claude harmlessly answers "Kept model as X" if it's already
383
+ // the session model.
384
+ const result = await deps.select(deps.getAgentName(), target.label)
385
+ if (!result.ok) {
386
+ // Switch failed but the agent is reachable — keep the menu so the
387
+ // operator can retry, with the reason as a banner.
388
+ return {
389
+ answer: 'Switch failed — see the menu',
390
+ reply: await menuWithBanner(
391
+ deps,
392
+ `❌ Switch to <b>${deps.escapeHtml(target.label)}</b> failed: ${deps.escapeHtml(result.reason)}`,
393
+ ),
394
+ }
395
+ }
396
+
397
+ return {
398
+ answer: deps.escapeHtml(result.confirmation),
399
+ reply: await menuWithBanner(deps, `✅ ${deps.escapeHtml(result.confirmation)}`),
400
+ selectedModel: sessionModelFromConfirmation(result.confirmation) ?? target.label,
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Pull the model NAME out of claude's session-switch confirmation so it can
406
+ * be shown in `/status` as the live session model. claude phrases it as
407
+ * "Set model to <name> for this session only" (or "Switched to <name>").
408
+ * Returns null when the confirmation doesn't carry a recognizable name (the
409
+ * caller falls back to the tapped picker label).
410
+ */
411
+ export function sessionModelFromConfirmation(confirmation: string): string | null {
412
+ const m = /(?:Set model to|Switched to)\s+(.+?)(?:\s+for (?:this|the) session|\s*\(|\s*$)/i.exec(
413
+ confirmation.trim(),
414
+ )
415
+ const name = m?.[1]?.trim()
416
+ return name && name.length > 0 ? name : null
417
+ }
418
+
419
+ /**
420
+ * Re-render the live menu with a one-line banner on top. Used by every
421
+ * post-tap outcome (success, already-default, failure) so the menu ALWAYS
422
+ * keeps its buttons and the operator can act again — the consistent
423
+ * "status line + interactive menu" shape the other dashboards use. Falls
424
+ * back to the banner alone if the menu can't be rebuilt right now.
425
+ */
426
+ async function menuWithBanner(
427
+ deps: ModelMenuDeps & ModelCommandDeps,
428
+ banner: string,
429
+ ): Promise<ModelMenuReply> {
430
+ const fresh = await buildModelMenu(deps)
431
+ return {
432
+ text: [banner, '', fresh.text].join('\n'),
433
+ html: true,
434
+ ...(fresh.keyboard ? { keyboard: fresh.keyboard } : {}),
435
+ }
436
+ }
@@ -285,6 +285,107 @@ describe('inbound-spool — bounded escalation (promise always resolved)', () =>
285
285
  })
286
286
  })
287
287
 
288
+ describe('inbound-spool — give-up notice coalescing (2026-06-09 marko spam)', () => {
289
+ // Helper: drive a sweep, return the list of postNotice flags per dropped entry.
290
+ function sweepFlags(s: ReturnType<typeof createInboundSpool>): boolean[] {
291
+ const flags: boolean[] = []
292
+ s.sweepEscalations((_e, { postNotice }) => flags.push(postNotice))
293
+ return flags
294
+ }
295
+
296
+ it('a burst of undeliverable entries in one chat posts exactly ONE notice', () => {
297
+ const fs = fakeFs()
298
+ let t = 0
299
+ const s = createInboundSpool({
300
+ path: PATH, fs, now: () => t,
301
+ escalateAfterMs: 100, escalateNoticeCooldownMs: 10_000,
302
+ })
303
+ // Three synthetics, same chat, distinct ids (fresh ts → distinct spoolId,
304
+ // the exact churn shape that produced the spam).
305
+ s.put('marko', msg({ messageId: 0, ts: 1, meta: { source: 'cron' } }))
306
+ s.put('marko', msg({ messageId: 0, ts: 2, meta: { source: 'cron' } }))
307
+ s.put('marko', msg({ messageId: 0, ts: 3, meta: { source: 'cron' } }))
308
+ t = 1000 // all older than the 100ms bound
309
+ const flags = sweepFlags(s)
310
+ expect(flags.length).toBe(3) // all three dropped (promise retracted)
311
+ expect(flags.filter(Boolean).length).toBe(1) // ONE notice posted
312
+ expect(s.liveCount()).toBe(0)
313
+ })
314
+
315
+ it('distinct chats each get their own notice', () => {
316
+ const fs = fakeFs()
317
+ let t = 0
318
+ const s = createInboundSpool({ path: PATH, fs, now: () => t, escalateAfterMs: 100 })
319
+ s.put('marko', msg({ chatId: 'A', messageId: 1 }))
320
+ s.put('marko', msg({ chatId: 'B', messageId: 2 }))
321
+ t = 1000
322
+ expect(sweepFlags(s).filter(Boolean).length).toBe(2)
323
+ })
324
+
325
+ it('same chat, different forum topics are coalesced independently', () => {
326
+ const fs = fakeFs()
327
+ let t = 0
328
+ const s = createInboundSpool({ path: PATH, fs, now: () => t, escalateAfterMs: 100 })
329
+ s.put('marko', msg({ chatId: 'A', messageId: 1, meta: { threadId: '3' } }))
330
+ s.put('marko', msg({ chatId: 'A', messageId: 2, meta: { threadId: '4' } }))
331
+ t = 1000
332
+ expect(sweepFlags(s).filter(Boolean).length).toBe(2)
333
+ })
334
+
335
+ it('THE BUG: the coalescing window survives a restart — a re-aged synthetic does not re-spam', () => {
336
+ const fs = fakeFs()
337
+ let t = 0
338
+ const opts = { escalateAfterMs: 100, escalateNoticeCooldownMs: 60_000 }
339
+ // Boot 1: one synthetic ages out → posts the notice.
340
+ const s1 = createInboundSpool({ path: PATH, fs, now: () => t, ...opts })
341
+ s1.put('marko', msg({ messageId: 0, ts: 1, meta: { source: 'cron' } }))
342
+ t = 1000
343
+ expect(sweepFlags(s1)).toEqual([true])
344
+ // Restart. A NEW synthetic (fresh ts → fresh id) lands and ages out within
345
+ // the cooldown. Pre-fix this re-posted every cycle across restarts.
346
+ t = 5000
347
+ const s2 = createInboundSpool({ path: PATH, fs, now: () => t, ...opts })
348
+ s2.put('marko', msg({ messageId: 0, ts: 2, meta: { source: 'cron' } }))
349
+ t = 6000
350
+ expect(sweepFlags(s2)).toEqual([false]) // dropped, but notice SUPPRESSED
351
+ })
352
+
353
+ it('compaction preserves the coalescing window (a post-compaction restart does not re-spam)', () => {
354
+ const fs = fakeFs()
355
+ let t = 0
356
+ // Tiny compact threshold so the next append triggers a rewrite.
357
+ const opts = { escalateAfterMs: 100, escalateNoticeCooldownMs: 60_000, compactAtBytes: 1 }
358
+ const s1 = createInboundSpool({ path: PATH, fs, now: () => t, ...opts })
359
+ s1.put('marko', msg({ messageId: 0, ts: 1, meta: { source: 'cron' } }))
360
+ t = 1000
361
+ expect(sweepFlags(s1)).toEqual([true]) // posts + appends esc; compaction runs
362
+ // After compaction the file must still carry the esc record → a restart
363
+ // hydrates the window → a new re-aged synthetic stays suppressed.
364
+ t = 5000
365
+ const s2 = createInboundSpool({ path: PATH, fs, now: () => t, ...opts })
366
+ s2.put('marko', msg({ messageId: 0, ts: 2, meta: { source: 'cron' } }))
367
+ t = 6000
368
+ expect(sweepFlags(s2)).toEqual([false])
369
+ })
370
+
371
+ it('re-notifies after the burst goes quiet for longer than the cooldown', () => {
372
+ const fs = fakeFs()
373
+ let t = 0
374
+ const s = createInboundSpool({
375
+ path: PATH, fs, now: () => t,
376
+ escalateAfterMs: 100, escalateNoticeCooldownMs: 1000,
377
+ })
378
+ s.put('marko', msg({ messageId: 0, ts: 1, meta: { source: 'cron' } }))
379
+ t = 200
380
+ expect(sweepFlags(s)).toEqual([true]) // first notice
381
+ // Quiet gap longer than the cooldown, then a new stuck synthetic.
382
+ t = 5000
383
+ s.put('marko', msg({ messageId: 0, ts: 2, meta: { source: 'cron' } }))
384
+ t = 5200
385
+ expect(sweepFlags(s)).toEqual([true]) // genuinely new situation → re-notify
386
+ })
387
+ })
388
+
288
389
  describe('inbound-spool — robustness', () => {
289
390
  it('a failing appendFileSync does not throw and keeps in-memory live state', () => {
290
391
  const fs = fakeFs()
@@ -203,3 +203,182 @@ 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
+ sessionModelFromConfirmation,
216
+ MODEL_CALLBACK_REFRESH,
217
+ type ModelMenuDeps,
218
+ } from "../gateway/model-command.js";
219
+ import { labelTag } from "../../src/agents/model-picker.js";
220
+
221
+ const OPTIONS = [
222
+ { index: 1, label: "Default (recommended)", detail: "Opus 4.8 with 1M context", current: false },
223
+ { index: 2, label: "Sonnet", detail: "Sonnet 4.6 · Efficient", current: true },
224
+ { index: 3, label: "Haiku", detail: "Haiku 4.5 · Fastest", current: false },
225
+ ];
226
+
227
+ function makeMenuDeps(overrides: Partial<ModelMenuDeps> = {}) {
228
+ const calls = { discover: 0, select: [] as string[] };
229
+ const base = makeDeps(); // v1 deps (inject/getConfiguredModel/escapeHtml/preBlock)
230
+ const deps = {
231
+ ...base.deps,
232
+ discover: async () => {
233
+ calls.discover++;
234
+ return { ok: true as const, options: OPTIONS, currentLabel: "Sonnet" };
235
+ },
236
+ select: async (_a: string, label: string) => {
237
+ calls.select.push(label);
238
+ return { ok: true as const, confirmation: `Set model to ${label} for this session` };
239
+ },
240
+ isBusy: () => false,
241
+ getQuotaBrief: async () => "29% / 5h · 33% / 7d",
242
+ ...overrides,
243
+ };
244
+ return { deps, calls, injectCalls: base.calls };
245
+ }
246
+
247
+ describe("buildModelMenu", () => {
248
+ it("renders current model, quota brief, and one button per discovered option", async () => {
249
+ const { deps, calls } = makeMenuDeps();
250
+ const menu = await buildModelMenu(deps);
251
+ expect(calls.discover).toBe(1);
252
+ expect(menu.text).toContain("<b>Sonnet</b>");
253
+ expect(menu.text).toContain("29% / 5h · 33% / 7d");
254
+ expect(menu.keyboard).toBeDefined();
255
+ // 3 option rows + refresh row
256
+ expect(menu.keyboard!.length).toBe(4);
257
+ expect(menu.keyboard![1][0].text).toBe("✅ Sonnet");
258
+ expect(menu.keyboard![0][0].text).toBe("Default (recommended)");
259
+ expect(menu.keyboard![3][0].callback_data).toBe(MODEL_CALLBACK_REFRESH);
260
+ });
261
+
262
+ it("every callback_data fits Telegram's 64-byte cap", async () => {
263
+ const { deps } = makeMenuDeps();
264
+ const menu = await buildModelMenu(deps);
265
+ for (const row of menu.keyboard!) {
266
+ for (const btn of row) {
267
+ expect(Buffer.byteLength(btn.callback_data, "utf-8")).toBeLessThanOrEqual(64);
268
+ }
269
+ }
270
+ });
271
+
272
+ it("busy agent → no discovery, no keyboard, explanatory text", async () => {
273
+ const { deps, calls } = makeMenuDeps({ isBusy: () => true });
274
+ const menu = await buildModelMenu(deps);
275
+ expect(calls.discover).toBe(0);
276
+ expect(menu.keyboard).toBeUndefined();
277
+ expect(menu.text).toContain("mid-turn");
278
+ });
279
+
280
+ it("discovery failure → static v1 fallback with the reason, no keyboard", async () => {
281
+ const { deps } = makeMenuDeps({
282
+ discover: async () => ({ ok: false as const, reason: "tmux session not found" }),
283
+ });
284
+ const menu = await buildModelMenu(deps);
285
+ expect(menu.keyboard).toBeUndefined();
286
+ expect(menu.text).toContain("picker unavailable");
287
+ expect(menu.text).toContain("Configured:");
288
+ });
289
+
290
+ it("quota failure never blocks the menu", async () => {
291
+ const { deps } = makeMenuDeps({
292
+ getQuotaBrief: async () => {
293
+ throw new Error("broker down");
294
+ },
295
+ });
296
+ const menu = await buildModelMenu(deps);
297
+ expect(menu.keyboard).toBeDefined();
298
+ expect(menu.text).not.toContain("Quota:");
299
+ });
300
+ });
301
+
302
+ describe("handleModelMenuCallback", () => {
303
+ it("mdl:s:<tag> selects by re-discovered label", async () => {
304
+ const { deps, calls } = makeMenuDeps();
305
+ const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
306
+ expect(calls.select).toEqual(["Haiku"]);
307
+ expect(out.answer).toContain("Set model to Haiku");
308
+ expect(out.reply.text).toContain("✅");
309
+ });
310
+
311
+ it("stale tag (options changed) → never selects, re-renders menu", async () => {
312
+ const { deps, calls } = makeMenuDeps();
313
+ const staleTag = `mdl:s:${labelTag("Removed Model")}`;
314
+ const out = await handleModelMenuCallback(staleTag, deps);
315
+ expect(calls.select).toEqual([]);
316
+ expect(out.answer).toContain("refreshed");
317
+ expect(out.reply.keyboard).toBeDefined();
318
+ });
319
+
320
+ it("tapping the ✔ (default) row STILL drives a switch — ✔ is the new-session default, not the live session model", async () => {
321
+ // OPTIONS marks "Sonnet" current (the ✔). An agent launched on a
322
+ // different model must still be able to apply the ✔ row to its live
323
+ // session — skipping it was the "tapped Default, nothing happened" bug.
324
+ const { deps, calls } = makeMenuDeps();
325
+ const out = await handleModelMenuCallback(modelSelectCallbackData("Sonnet"), deps);
326
+ expect(calls.select).toEqual(["Sonnet"]);
327
+ expect(out.reply.text).toContain("✅");
328
+ expect(out.reply.keyboard).toBeDefined();
329
+ });
330
+
331
+ it("busy agent → toastOnly refusal that leaves the menu untouched", async () => {
332
+ const { deps, calls } = makeMenuDeps({ isBusy: () => true });
333
+ const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
334
+ expect(calls.select).toEqual([]);
335
+ expect(out.answer).toContain("mid-turn");
336
+ // toastOnly tells the gateway to NOT edit the menu — buttons survive.
337
+ expect(out.toastOnly).toBe(true);
338
+ });
339
+
340
+ it("selection failure surfaces the reason AND keeps the menu so the operator can retry", async () => {
341
+ const { deps } = makeMenuDeps({
342
+ select: async () => ({ ok: false as const, reason: "cursor verification failed" }),
343
+ });
344
+ const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
345
+ expect(out.answer).toContain("failed");
346
+ expect(out.reply.text).toContain("cursor verification failed");
347
+ // The menu buttons are preserved — a failure no longer collapses the
348
+ // menu to a button-less error (the "nothing happened" bug).
349
+ expect(out.reply.keyboard).toBeDefined();
350
+ });
351
+
352
+ it("a successful switch banners the confirmation, keeps the menu, AND reports the live model for /status", async () => {
353
+ const { deps } = makeMenuDeps({
354
+ select: async () => ({ ok: true as const, confirmation: "Set model to Haiku 4.5 for this session only" }),
355
+ });
356
+ const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
357
+ expect(out.answer).toContain("Haiku 4.5");
358
+ expect(out.reply.text).toContain("✅");
359
+ expect(out.reply.text).toContain("Set model to Haiku 4.5");
360
+ expect(out.reply.keyboard).toBeDefined();
361
+ // The gateway records this so /status reflects the live session model.
362
+ expect(out.selectedModel).toBe("Haiku 4.5");
363
+ });
364
+ });
365
+
366
+ describe("sessionModelFromConfirmation", () => {
367
+ it("pulls the model name from claude's session-switch confirmation", () => {
368
+ expect(sessionModelFromConfirmation("Set model to Fable 5 for this session only")).toBe("Fable 5");
369
+ expect(sessionModelFromConfirmation("Set model to Opus 4.8 (1M context) for this session only")).toBe("Opus 4.8");
370
+ expect(sessionModelFromConfirmation("Switched to Haiku 4.5")).toBe("Haiku 4.5");
371
+ });
372
+ it("returns null when no recognizable name is present", () => {
373
+ expect(sessionModelFromConfirmation("Kept model as Opus 4.8 (default)")).toBeNull();
374
+ expect(sessionModelFromConfirmation("")).toBeNull();
375
+ });
376
+
377
+ it("mdl:r re-renders the dashboard", async () => {
378
+ const { deps, calls } = makeMenuDeps();
379
+ const out = await handleModelMenuCallback(MODEL_CALLBACK_REFRESH, deps);
380
+ expect(out.answer).toBe("Refreshed");
381
+ expect(calls.discover).toBe(1);
382
+ expect(out.reply.keyboard).toBeDefined();
383
+ });
384
+ });
@@ -82,6 +82,17 @@ describe("formatAgentLine", () => {
82
82
  const out = formatAgentLine({ ...baseMeta, topicName: "Planning", topicEmoji: "🗓" });
83
83
  expect(out).toContain("topic: 🗓 Planning");
84
84
  });
85
+ it("shows the live session model alongside the configured model when a /model switch is active", () => {
86
+ const out = formatAgentLine({ ...baseMeta, model: "claude-fable-5[1m]", sessionModel: "Opus 4.8 (1M context)" });
87
+ // Both surfaces present + agree: configured AND what's actually running.
88
+ expect(out).toContain("<code>claude-fable-5[1m]</code>");
89
+ expect(out).toContain("live session: <code>Opus 4.8 (1M context)</code>");
90
+ });
91
+ it("omits the session line when no override is active", () => {
92
+ expect(formatAgentLine({ ...baseMeta, sessionModel: null })).not.toContain("live session");
93
+ expect(formatAgentLine({ ...baseMeta, sessionModel: "" })).not.toContain("live session");
94
+ expect(formatAgentLine(baseMeta)).not.toContain("live session");
95
+ });
85
96
  it("omits topic when only emoji is set", () => {
86
97
  // topicName null → no topic chunk. Keeps the line clean.
87
98
  expect(formatAgentLine({ ...baseMeta, topicEmoji: "🗓" })).not.toContain("topic");