switchroom 0.16.9 → 0.16.11

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.
@@ -51674,8 +51674,8 @@ import { existsSync, readFileSync } from "node:fs";
51674
51674
  import { dirname, join } from "node:path";
51675
51675
 
51676
51676
  // src/build-info.ts
51677
- var VERSION = "0.16.9";
51678
- var COMMIT_SHA = "1a926737";
51677
+ var VERSION = "0.16.11";
51678
+ var COMMIT_SHA = "7846a496";
51679
51679
 
51680
51680
  // src/cli/resolve-version.ts
51681
51681
  function readPackageVersion() {
@@ -22587,7 +22587,7 @@ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:f
22587
22587
  import { dirname as dirname4, join as join2 } from "node:path";
22588
22588
 
22589
22589
  // src/build-info.ts
22590
- var VERSION = "0.16.9";
22590
+ var VERSION = "0.16.11";
22591
22591
 
22592
22592
  // src/cli/resolve-version.ts
22593
22593
  function readPackageVersion() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.16.9",
4
- "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw \u2014 no API keys.",
3
+ "version": "0.16.11",
4
+ "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "switchroom": "./dist/cli/switchroom.js"
@@ -46034,10 +46034,11 @@ function parseModelCommand(text) {
46034
46034
  }
46035
46035
  var PERSIST_NOTE = "<i>Session-only \u2014 lasts until restart. To persist, set <code>model:</code> in switchroom.yaml and restart.</i>";
46036
46036
  function helpText2(deps, reason) {
46037
+ const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map((a) => `<code>${a}</code>`).join(" \u00b7 ");
46037
46038
  const lines = [];
46038
46039
  if (reason)
46039
46040
  lines.push(`\u26a0\ufe0f ${deps.escapeHtml(reason)}`);
46040
- lines.push("<b>/model</b> \u2014 show or switch the Claude model", "<code>/model</code> \u2014 show the configured model", `<code>/model &lt;name&gt;</code> \u2014 switch the live session (${MODEL_ALIASES.map((a) => `<code>${a}</code>`).join(" \u00b7 ")} or a full model id)`, PERSIST_NOTE);
46041
+ lines.push("<b>/model</b> \u2014 show or switch the Claude model", "<code>/model</code> \u2014 show the configured model", `<code>/model &lt;name&gt;</code> \u2014 switch the live session (${MODEL_ALIASES.map((a) => `<code>${a}</code>`).join(" \u00b7 ")} or a full model id)`, `<i>OpenRouter shortcuts:</i> ${srAliasExamples}`, PERSIST_NOTE);
46041
46042
  return { text: lines.join(`
46042
46043
  `), html: true };
46043
46044
  }
@@ -46047,11 +46048,13 @@ async function handleModelCommand(parsed, deps) {
46047
46048
  if (parsed.kind === "show") {
46048
46049
  const configured = deps.getConfiguredModel();
46049
46050
  const shown = configured && configured.length > 0 ? configured : "default";
46051
+ const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map((a) => `<code>/model ${a}</code>`).join(" \u00b7 ");
46050
46052
  return {
46051
46053
  text: [
46052
46054
  `<b>Model \u2014 ${deps.escapeHtml(deps.getAgentName())}</b>`,
46053
46055
  `Configured: <code>${deps.escapeHtml(shown)}</code>`,
46054
46056
  `Switch the live session: ${MODEL_ALIASES.map((a) => `<code>/model ${a}</code>`).join(" \u00b7 ")}`,
46057
+ `OpenRouter shortcuts: ${srAliasExamples}`,
46055
46058
  "or <code>/model &lt;full-model-id&gt;</code>",
46056
46059
  PERSIST_NOTE
46057
46060
  ].join(`
@@ -46062,10 +46065,11 @@ async function handleModelCommand(parsed, deps) {
46062
46065
  if (!isValidModelArg(parsed.model)) {
46063
46066
  return helpText2(deps, `not a valid model name: ${parsed.model}`);
46064
46067
  }
46068
+ const model = expandSrAlias(parsed.model);
46065
46069
  const currentSession = deps.getActiveSessionModel();
46066
- if (currentSession !== null && isSrModel(currentSession) && isClaudeModel(parsed.model)) {
46070
+ if (currentSession !== null && isSrModel(currentSession) && isClaudeModel(model)) {
46067
46071
  try {
46068
- await deps.scheduleRestart(`user: /model ${parsed.model} (sr-to-claude restart)`);
46072
+ await deps.scheduleRestart(`user: /model ${model} (sr-to-claude restart)`);
46069
46073
  } catch (err) {
46070
46074
  const msg = err instanceof Error ? err.message : String(err);
46071
46075
  return {
@@ -46082,10 +46086,10 @@ async function handleModelCommand(parsed, deps) {
46082
46086
  html: true
46083
46087
  };
46084
46088
  }
46085
- const verbHtml = `<code>/model ${deps.escapeHtml(parsed.model)}</code>`;
46089
+ const verbHtml = `<code>/model ${deps.escapeHtml(model)}</code>`;
46086
46090
  let result;
46087
46091
  try {
46088
- result = await deps.inject(deps.getAgentName(), `/model ${parsed.model}`);
46092
+ result = await deps.inject(deps.getAgentName(), `/model ${model}`);
46089
46093
  } catch (err) {
46090
46094
  const msg = err instanceof Error ? err.message : String(err);
46091
46095
  return {
@@ -46136,8 +46140,20 @@ var SR_MODEL_LABELS = {
46136
46140
  "sr-gemini-2.5-flash": "Gemini 2.5 Flash",
46137
46141
  "sr-deepseek-r1": "DeepSeek R1",
46138
46142
  "sr-deepseek-v3": "DeepSeek V3",
46139
- "sr-glm-5": "GLM-5"
46143
+ "sr-glm-5": "GLM-5",
46144
+ "sr-codex-5.5": "Codex 5.5"
46140
46145
  };
46146
+ var SR_MODEL_ALIASES = {
46147
+ flash: "sr-gemini-2.5-flash",
46148
+ gemini: "sr-gemini-2.5-pro",
46149
+ deepseek: "sr-deepseek-v3",
46150
+ r1: "sr-deepseek-r1",
46151
+ glm: "sr-glm-5",
46152
+ codex: "sr-codex-5.5"
46153
+ };
46154
+ function expandSrAlias(arg) {
46155
+ return SR_MODEL_ALIASES[arg.toLowerCase()] ?? arg;
46156
+ }
46141
46157
  function srFriendlyLabel(srName) {
46142
46158
  return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, "").replace(/-/g, " ");
46143
46159
  }
@@ -56047,10 +56063,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
56047
56063
  }
56048
56064
 
56049
56065
  // ../src/build-info.ts
56050
- var VERSION = "0.16.9";
56051
- var COMMIT_SHA = "1a926737";
56052
- var COMMIT_DATE = "2026-06-28T06:20:58Z";
56053
- var LATEST_PR = 2622;
56066
+ var VERSION = "0.16.11";
56067
+ var COMMIT_SHA = "7846a496";
56068
+ var COMMIT_DATE = "2026-06-28T09:51:30Z";
56069
+ var LATEST_PR = 2630;
56054
56070
  var COMMITS_AHEAD_OF_TAG = 0;
56055
56071
 
56056
56072
  // gateway/boot-version.ts
@@ -57709,12 +57725,13 @@ function loadAccess() {
57709
57725
  return BOOT_ACCESS ?? readAccessFile();
57710
57726
  }
57711
57727
  function assertAllowedChat(chat_id) {
57728
+ const id = String(chat_id);
57712
57729
  const access = loadAccess();
57713
- if (access.allowFrom.includes(chat_id))
57730
+ if (access.allowFrom.includes(id))
57714
57731
  return;
57715
- if (chat_id in access.groups)
57732
+ if (id in access.groups)
57716
57733
  return;
57717
- throw new Error(`chat ${chat_id} is not allowlisted \u2014 add via /telegram:access`);
57734
+ throw new Error(`chat ${id} is not allowlisted \u2014 add via /telegram:access`);
57718
57735
  }
57719
57736
  function saveAccess(a) {
57720
57737
  if (STATIC)
@@ -58534,7 +58551,7 @@ function endCurrentTurnAtomic(turn) {
58534
58551
  `);
58535
58552
  }
58536
58553
  if (OBLIGATION_LEDGER_ENABLED) {
58537
- if (turn.finalAnswerDelivered) {
58554
+ if (turn.finalAnswerDelivered || turn.replyCalled) {
58538
58555
  obligationLedger.close(turn.turnId);
58539
58556
  } else {
58540
58557
  obligationLedger.noteTurnEnded(turn.turnId, Date.now());
@@ -60688,7 +60705,7 @@ async function executeToolCall(tool, args) {
60688
60705
  }
60689
60706
  }
60690
60707
  async function executeSendChecklist(args) {
60691
- const chat_id = args.chat_id;
60708
+ const chat_id = String(args.chat_id ?? "");
60692
60709
  if (!chat_id)
60693
60710
  throw new Error("send_checklist: chat_id is required");
60694
60711
  const title = args.title;
@@ -60754,7 +60771,7 @@ async function executeLinearAgentSetup(args) {
60754
60771
  return runLinearAgentSetup(args);
60755
60772
  }
60756
60773
  async function executeUpdateChecklist(args) {
60757
- const chat_id = args.chat_id;
60774
+ const chat_id = String(args.chat_id ?? "");
60758
60775
  if (!chat_id)
60759
60776
  throw new Error("update_checklist: chat_id is required");
60760
60777
  const message_id = args.message_id;
@@ -60778,7 +60795,7 @@ function redactOutboundText(text2, site) {
60778
60795
  }
60779
60796
  async function executeReply(args) {
60780
60797
  const turn = currentTurn;
60781
- const chat_id = args.chat_id;
60798
+ const chat_id = String(args.chat_id ?? "");
60782
60799
  if (!chat_id)
60783
60800
  throw new Error("reply: chat_id is required");
60784
60801
  const rawText = args.text;
@@ -61315,14 +61332,14 @@ async function executeStreamReply(args) {
61315
61332
  args.text = scrub.scrubbed;
61316
61333
  emitRuntimeMetric({
61317
61334
  kind: "voice_scrub_applied",
61318
- chatKey: statusKey(args.chat_id, args.message_thread_id != null ? Number(args.message_thread_id) : undefined),
61335
+ chatKey: statusKey(String(args.chat_id ?? ""), args.message_thread_id != null ? Number(args.message_thread_id) : undefined),
61319
61336
  replaced: scrub.replaced,
61320
61337
  site: "stream_reply"
61321
61338
  });
61322
61339
  }
61323
61340
  }
61324
61341
  if (args.done === true) {
61325
- const sChatId = args.chat_id;
61342
+ const sChatId = String(args.chat_id ?? "");
61326
61343
  const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
61327
61344
  const sText = args.text;
61328
61345
  const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now(), currentTurn?.registryKey ?? null);
@@ -61333,7 +61350,7 @@ async function executeStreamReply(args) {
61333
61350
  }
61334
61351
  }
61335
61352
  const access = loadAccess();
61336
- const streamChatId = args.chat_id;
61353
+ const streamChatId = String(args.chat_id ?? "");
61337
61354
  const streamIsPrivate = isDmChatId(streamChatId);
61338
61355
  const streamIsForumTopic = args.message_thread_id != null && args.message_thread_id !== "";
61339
61356
  let streamReplyMarkup;
@@ -61433,18 +61450,18 @@ async function executeStreamReply(args) {
61433
61450
  });
61434
61451
  if (result.messageId != null) {
61435
61452
  try {
61436
- progressDriver?.recordOutboundDelivered(args.chat_id, args.message_thread_id);
61453
+ progressDriver?.recordOutboundDelivered(String(args.chat_id ?? ""), args.message_thread_id);
61437
61454
  } catch {}
61438
61455
  try {
61439
61456
  const threadIdNum2 = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
61440
- noteSignal(statusKey(args.chat_id, threadIdNum2), Date.now());
61457
+ noteSignal(statusKey(String(args.chat_id ?? ""), threadIdNum2), Date.now());
61441
61458
  } catch {}
61442
61459
  }
61443
61460
  if (args.done === true && result.messageId != null && streamButtonMeta != null && streamButtonMeta.size > 0) {
61444
- rememberAgentButtonMeta(args.chat_id, result.messageId, streamButtonMeta);
61461
+ rememberAgentButtonMeta(String(args.chat_id ?? ""), result.messageId, streamButtonMeta);
61445
61462
  }
61446
61463
  if (args.done === true && result.messageId != null) {
61447
- const sChatId = args.chat_id;
61464
+ const sChatId = String(args.chat_id ?? "");
61448
61465
  const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
61449
61466
  outboundDedup.record(sChatId, sThreadId, args.text, Date.now(), currentTurn?.registryKey ?? null);
61450
61467
  const streamFormat = args.format ?? (access.parseMode ?? "html");
@@ -61477,7 +61494,7 @@ async function executeStreamReply(args) {
61477
61494
  clearSilentEndState(statusKey(streamChatId, streamThreadIdForClear));
61478
61495
  }
61479
61496
  {
61480
- const sChat = args.chat_id;
61497
+ const sChat = String(args.chat_id ?? "");
61481
61498
  const sThread = resolveThreadId(sChat, args.message_thread_id);
61482
61499
  releaseTurnBufferGate(statusKey(sChat, sThread), turn ?? undefined);
61483
61500
  if (turn?.finalAnswerDelivered === true) {
@@ -61491,7 +61508,7 @@ async function executeProgressUpdate(args) {
61491
61508
  throw new Error("progress_update: chat_id is required");
61492
61509
  if (!args.text)
61493
61510
  throw new Error("progress_update: text is required");
61494
- const chat_id = args.chat_id;
61511
+ const chat_id = String(args.chat_id ?? "");
61495
61512
  let text2 = args.text;
61496
61513
  const threadId = resolveThreadId(chat_id, args.message_thread_id);
61497
61514
  const key = statusKey(chat_id, threadId);
@@ -61816,7 +61833,7 @@ function renderVaultRequestSaveCard(req, agentSlug) {
61816
61833
  `);
61817
61834
  }
61818
61835
  async function executeVaultRequestSave(args) {
61819
- const chat_id = args.chat_id;
61836
+ const chat_id = String(args.chat_id ?? "");
61820
61837
  if (!chat_id)
61821
61838
  throw new Error("vault_request_save: chat_id is required");
61822
61839
  const key = args.key;
@@ -61904,7 +61921,7 @@ function renderSecretRequestCard(req) {
61904
61921
  `);
61905
61922
  }
61906
61923
  async function executeRequestSecret(args) {
61907
- const chat_id = args.chat_id;
61924
+ const chat_id = String(args.chat_id ?? "");
61908
61925
  if (!chat_id)
61909
61926
  throw new Error("request_secret: chat_id is required");
61910
61927
  const key = args.key;
@@ -62110,7 +62127,7 @@ function renderVaultRequestAccessCard(req) {
62110
62127
  `);
62111
62128
  }
62112
62129
  async function executeVaultRequestAccess(args) {
62113
- const chat_id = args.chat_id;
62130
+ const chat_id = String(args.chat_id ?? "");
62114
62131
  if (!chat_id)
62115
62132
  throw new Error("vault_request_access: chat_id is required");
62116
62133
  const key = args.key;
@@ -62195,8 +62212,8 @@ async function executeReact(args) {
62195
62212
  throw new Error("react: message_id is required");
62196
62213
  if (!args.emoji)
62197
62214
  throw new Error("react: emoji is required");
62198
- assertAllowedChat(args.chat_id);
62199
- await lockedBot.api.setMessageReaction(args.chat_id, Number(args.message_id), [
62215
+ assertAllowedChat(String(args.chat_id ?? ""));
62216
+ await lockedBot.api.setMessageReaction(String(args.chat_id ?? ""), Number(args.message_id), [
62200
62217
  { type: "emoji", emoji: args.emoji }
62201
62218
  ]);
62202
62219
  return { content: [{ type: "text", text: "reacted" }] };
@@ -62239,7 +62256,7 @@ async function executeEditMessage(args) {
62239
62256
  throw new Error("edit_message: message_id is required");
62240
62257
  if (args.text == null || args.text === "")
62241
62258
  throw new Error("edit_message: text is required and cannot be empty");
62242
- assertAllowedChat(args.chat_id);
62259
+ assertAllowedChat(String(args.chat_id ?? ""));
62243
62260
  const editAccess = loadAccess();
62244
62261
  const editConfigMode = editAccess.parseMode ?? "html";
62245
62262
  const editFormat = args.format ?? editConfigMode;
@@ -62251,7 +62268,7 @@ async function executeEditMessage(args) {
62251
62268
  editRawText = scrub.scrubbed;
62252
62269
  emitRuntimeMetric({
62253
62270
  kind: "voice_scrub_applied",
62254
- chatKey: statusKey(args.chat_id, undefined),
62271
+ chatKey: statusKey(String(args.chat_id ?? ""), undefined),
62255
62272
  replaced: scrub.replaced,
62256
62273
  site: "edit_message"
62257
62274
  });
@@ -62269,11 +62286,11 @@ async function executeEditMessage(args) {
62269
62286
  editParseMode = undefined;
62270
62287
  editText = editRawText;
62271
62288
  }
62272
- const edited = await robustApiCall(() => lockedBot.api.editMessageText(args.chat_id, Number(args.message_id), editText, ...editParseMode ? [{ parse_mode: editParseMode }] : []));
62289
+ const edited = await robustApiCall(() => lockedBot.api.editMessageText(String(args.chat_id ?? ""), Number(args.message_id), editText, ...editParseMode ? [{ parse_mode: editParseMode }] : []));
62273
62290
  const id = typeof edited === "object" && edited ? edited.message_id : args.message_id;
62274
62291
  if (HISTORY_ENABLED) {
62275
62292
  try {
62276
- recordEdit({ chat_id: args.chat_id, message_id: Number(args.message_id), text: args.text });
62293
+ recordEdit({ chat_id: String(args.chat_id ?? ""), message_id: Number(args.message_id), text: args.text });
62277
62294
  } catch (err) {
62278
62295
  process.stderr.write(`telegram gateway: history recordEdit failed: ${err}
62279
62296
  `);
@@ -62284,7 +62301,7 @@ async function executeEditMessage(args) {
62284
62301
  async function executeSendTyping(args) {
62285
62302
  if (!args.chat_id)
62286
62303
  throw new Error("send_typing: chat_id is required");
62287
- const stChatId = args.chat_id;
62304
+ const stChatId = String(args.chat_id ?? "");
62288
62305
  assertAllowedChat(stChatId);
62289
62306
  const rawAction = args.action;
62290
62307
  let action = "typing";
@@ -62308,7 +62325,7 @@ async function executePinMessage(args) {
62308
62325
  throw new Error("pin_message: chat_id is required");
62309
62326
  if (!args.message_id)
62310
62327
  throw new Error("pin_message: message_id is required");
62311
- const pinChatId = args.chat_id;
62328
+ const pinChatId = String(args.chat_id ?? "");
62312
62329
  assertAllowedChat(pinChatId);
62313
62330
  await robustApiCall(() => lockedBot.api.pinChatMessage(pinChatId, Number(args.message_id)), { chat_id: pinChatId, verb: "pin_message" });
62314
62331
  return { content: [{ type: "text", text: `pinned message ${args.message_id}` }] };
@@ -62318,7 +62335,7 @@ async function executeDeleteMessage(args) {
62318
62335
  throw new Error("delete_message: chat_id is required");
62319
62336
  if (!args.message_id)
62320
62337
  throw new Error("delete_message: message_id is required");
62321
- const delChatId = args.chat_id;
62338
+ const delChatId = String(args.chat_id ?? "");
62322
62339
  const delMessageId = Number(args.message_id);
62323
62340
  assertAllowedChat(delChatId);
62324
62341
  await robustApiCall(() => lockedBot.api.deleteMessage(delChatId, delMessageId), { chat_id: delChatId });
@@ -62339,7 +62356,7 @@ async function executeForwardMessage(args) {
62339
62356
  throw new Error("forward_message: from_chat_id is required");
62340
62357
  if (!args.message_id)
62341
62358
  throw new Error("forward_message: message_id is required");
62342
- const fwdChatId = args.chat_id;
62359
+ const fwdChatId = String(args.chat_id ?? "");
62343
62360
  const fwdFromChatId = args.from_chat_id;
62344
62361
  const fwdMsgId = Number(args.message_id);
62345
62362
  assertAllowedChat(fwdChatId);
@@ -62371,7 +62388,7 @@ async function executeGetRecentMessages(args) {
62371
62388
  }
62372
62389
  if (!args.chat_id)
62373
62390
  throw new Error("get_recent_messages: chat_id is required");
62374
- const chat_id = args.chat_id;
62391
+ const chat_id = String(args.chat_id ?? "");
62375
62392
  assertAllowedChat(chat_id);
62376
62393
  const rawThread = args.message_thread_id;
62377
62394
  let thread_id;
@@ -1030,11 +1030,12 @@ function loadAccess(): Access {
1030
1030
  return BOOT_ACCESS ?? readAccessFile()
1031
1031
  }
1032
1032
 
1033
- function assertAllowedChat(chat_id: string): void {
1033
+ function assertAllowedChat(chat_id: string | number): void {
1034
+ const id = String(chat_id)
1034
1035
  const access = loadAccess()
1035
- if (access.allowFrom.includes(chat_id)) return
1036
- if (chat_id in access.groups) return
1037
- throw new Error(`chat ${chat_id} is not allowlisted — add via /telegram:access`)
1036
+ if (access.allowFrom.includes(id)) return
1037
+ if (id in access.groups) return
1038
+ throw new Error(`chat ${id} is not allowlisted — add via /telegram:access`)
1038
1039
  }
1039
1040
 
1040
1041
  function saveAccess(a: Access): void {
@@ -3375,15 +3376,24 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
3375
3376
  // finalAnswerDelivered===false → stays open → re-presented (the intended
3376
3377
  // catch). close() is a no-op for synthetic turns (turnId not in the ledger).
3377
3378
  // No-op when the flag is off.
3379
+ //
3380
+ // #2624 — sr-* model short-reply cascade fix. When the model routes through
3381
+ // LiteLLM (sr-* path), the claude CLI calls reply("OK", {disable_notification:
3382
+ // true}) for short answers — below the 200-char backstop and notification-
3383
+ // suppressed, so isFinalAnswerReply returns false and finalAnswerDelivered stays
3384
+ // false. This triggers a cascade: obligation re-presents with growing 25k-token
3385
+ // context, blocking the agent for minutes. The ack-then-ghost case that
3386
+ // obligations are designed to catch ends via silence_fallback, NOT turn_end.
3387
+ // At turn_end with replyCalled=true the model explicitly signalled completion
3388
+ // AND replied, so the obligation is satisfied regardless of finalAnswerDelivered.
3378
3389
  if (OBLIGATION_LEDGER_ENABLED) {
3379
- if (turn.finalAnswerDelivered) {
3390
+ if (turn.finalAnswerDelivered || turn.replyCalled) {
3380
3391
  obligationLedger.close(turn.turnId)
3381
3392
  } else {
3382
- // Turn ended WITHOUT a final answer. If this turn was handling an open
3383
- // obligation, stamp its grace clock so the idle sweep waits before
3384
- // re-presenting/escalating a slow/worker answer may still be in flight
3385
- // (the over-escalation fix). No-op when turn.turnId isn't an open
3386
- // obligation (synthetic / already-closed turn).
3393
+ // Turn ended WITHOUT any reply (no ack, no answer). If this turn was
3394
+ // handling an open obligation, stamp its grace clock so the idle sweep
3395
+ // waits before re-presenting/escalating. No-op when turn.turnId isn't
3396
+ // in the ledger (synthetic / already-closed turn).
3387
3397
  obligationLedger.noteTurnEnded(turn.turnId, Date.now())
3388
3398
  }
3389
3399
  }
@@ -7825,7 +7835,7 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
7825
7835
  }
7826
7836
 
7827
7837
  async function executeSendChecklist(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
7828
- const chat_id = args.chat_id as string
7838
+ const chat_id = String(args.chat_id ?? '')
7829
7839
  if (!chat_id) throw new Error('send_checklist: chat_id is required')
7830
7840
  const title = args.title as string | undefined
7831
7841
  if (!title) throw new Error('send_checklist: title is required')
@@ -7913,7 +7923,7 @@ async function executeLinearAgentSetup(args: Record<string, unknown>): Promise<{
7913
7923
  }
7914
7924
 
7915
7925
  async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
7916
- const chat_id = args.chat_id as string
7926
+ const chat_id = String(args.chat_id ?? '')
7917
7927
  if (!chat_id) throw new Error('update_checklist: chat_id is required')
7918
7928
  const message_id = args.message_id as string | undefined
7919
7929
  if (!message_id) throw new Error('update_checklist: message_id is required')
@@ -7962,7 +7972,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
7962
7972
  // module-scope currentTurn, which a future refactor could let roll over
7963
7973
  // mid-call.
7964
7974
  const turn = currentTurn
7965
- const chat_id = args.chat_id as string
7975
+ const chat_id = String(args.chat_id ?? '')
7966
7976
  if (!chat_id) throw new Error('reply: chat_id is required')
7967
7977
  const rawText = args.text as string | undefined
7968
7978
  if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
@@ -8988,7 +8998,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
8988
8998
  args.text = scrub.scrubbed
8989
8999
  emitRuntimeMetric({
8990
9000
  kind: 'voice_scrub_applied',
8991
- chatKey: statusKey(args.chat_id as string, args.message_thread_id != null
9001
+ chatKey: statusKey(String(args.chat_id ?? ''), args.message_thread_id != null
8992
9002
  ? Number(args.message_thread_id) : undefined),
8993
9003
  replaced: scrub.replaced,
8994
9004
  site: 'stream_reply',
@@ -9003,7 +9013,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9003
9013
  // Only check on done=true (the terminal call); intermediate
9004
9014
  // streaming chunks are progress edits, not full sends.
9005
9015
  if (args.done === true) {
9006
- const sChatId = args.chat_id as string
9016
+ const sChatId = String(args.chat_id ?? '')
9007
9017
  const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
9008
9018
  const sText = args.text as string
9009
9019
  const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now(), currentTurn?.registryKey ?? null)
@@ -9019,7 +9029,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9019
9029
  const access = loadAccess()
9020
9030
  // Detect chat type for throttle-default selection.
9021
9031
  // Private (DM) chats have positive numeric IDs; groups/channels are negative.
9022
- const streamChatId = args.chat_id as string
9032
+ const streamChatId = String(args.chat_id ?? '')
9023
9033
  const streamIsPrivate = isDmChatId(streamChatId)
9024
9034
  const streamIsForumTopic = args.message_thread_id != null && args.message_thread_id !== ''
9025
9035
  // Pre-allocated draft handoff removed in #553 PR 5 — draft-stream
@@ -9183,7 +9193,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9183
9193
  if (result.messageId != null) {
9184
9194
  try {
9185
9195
  progressDriver?.recordOutboundDelivered(
9186
- args.chat_id as string,
9196
+ String(args.chat_id ?? ''),
9187
9197
  args.message_thread_id as string | undefined,
9188
9198
  )
9189
9199
  } catch { /* best-effort signal */ }
@@ -9198,7 +9208,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9198
9208
  ? Number(args.message_thread_id)
9199
9209
  : undefined
9200
9210
  signalTracker.noteSignal(
9201
- statusKey(args.chat_id as string, threadIdNum),
9211
+ statusKey(String(args.chat_id ?? ''), threadIdNum),
9202
9212
  Date.now(),
9203
9213
  )
9204
9214
  } catch { /* best-effort signal */ }
@@ -9211,13 +9221,13 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9211
9221
  && streamButtonMeta != null
9212
9222
  && streamButtonMeta.size > 0
9213
9223
  ) {
9214
- rememberAgentButtonMeta(args.chat_id as string, result.messageId, streamButtonMeta)
9224
+ rememberAgentButtonMeta(String(args.chat_id ?? ''), result.messageId, streamButtonMeta)
9215
9225
  }
9216
9226
  // #546 dedup record: capture the final stream_reply text on the
9217
9227
  // terminal call so a subsequent retry (different bridge, same
9218
9228
  // content) lands as a no-op instead of a second message.
9219
9229
  if (args.done === true && result.messageId != null) {
9220
- const sChatId = args.chat_id as string
9230
+ const sChatId = String(args.chat_id ?? '')
9221
9231
  const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
9222
9232
  outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now(), currentTurn?.registryKey ?? null)
9223
9233
  // #1445 cross-turn pending-async ambient. The terminal stream_reply
@@ -9297,7 +9307,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9297
9307
  // (stream-reply-handler.ts:305) at this point — earlier failures
9298
9308
  // throw or return before reaching here.
9299
9309
  {
9300
- const sChat = args.chat_id as string
9310
+ const sChat = String(args.chat_id ?? '')
9301
9311
  const sThread = resolveThreadId(sChat, args.message_thread_id as string | undefined)
9302
9312
  // Component 1: pass the turn (finalAnswerDelivered set above for a
9303
9313
  // final stream emit). Interim stream chunks leave it false → no
@@ -9317,7 +9327,7 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
9317
9327
  if (!args.chat_id) throw new Error('progress_update: chat_id is required')
9318
9328
  if (!args.text) throw new Error('progress_update: text is required')
9319
9329
 
9320
- const chat_id = args.chat_id as string
9330
+ const chat_id = String(args.chat_id ?? '')
9321
9331
  let text = args.text as string
9322
9332
  const threadId = resolveThreadId(chat_id, args.message_thread_id as string | undefined)
9323
9333
  const key = statusKey(chat_id, threadId)
@@ -9801,7 +9811,7 @@ function renderVaultRequestSaveCard(req: PendingVaultRequestSave, agentSlug: str
9801
9811
  * written to vault only on user tap. See #969 P1a.
9802
9812
  */
9803
9813
  async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
9804
- const chat_id = args.chat_id as string
9814
+ const chat_id = String(args.chat_id ?? '')
9805
9815
  if (!chat_id) throw new Error('vault_request_save: chat_id is required')
9806
9816
  const key = args.key as string
9807
9817
  if (!key || typeof key !== 'string') throw new Error('vault_request_save: key is required')
@@ -9945,7 +9955,7 @@ function renderSecretRequestCard(req: PendingSecretRequest): string {
9945
9955
  * capture (the operator's next message after they tap [Provide securely]).
9946
9956
  */
9947
9957
  async function executeRequestSecret(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
9948
- const chat_id = args.chat_id as string
9958
+ const chat_id = String(args.chat_id ?? '')
9949
9959
  if (!chat_id) throw new Error('request_secret: chat_id is required')
9950
9960
  const key = args.key as string
9951
9961
  if (!key || typeof key !== 'string') throw new Error('request_secret: key is required')
@@ -10229,7 +10239,7 @@ function renderVaultRequestAccessCard(req: PendingVaultRequestAccess): string {
10229
10239
  * the agent itself can only REQUEST.
10230
10240
  */
10231
10241
  async function executeVaultRequestAccess(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
10232
- const chat_id = args.chat_id as string
10242
+ const chat_id = String(args.chat_id ?? '')
10233
10243
  if (!chat_id) throw new Error('vault_request_access: chat_id is required')
10234
10244
  const key = args.key as string
10235
10245
  if (!key || typeof key !== 'string') throw new Error('vault_request_access: key is required')
@@ -10347,8 +10357,8 @@ async function executeReact(args: Record<string, unknown>): Promise<unknown> {
10347
10357
  if (!args.chat_id) throw new Error('react: chat_id is required')
10348
10358
  if (!args.message_id) throw new Error('react: message_id is required')
10349
10359
  if (!args.emoji) throw new Error('react: emoji is required')
10350
- assertAllowedChat(args.chat_id as string)
10351
- await lockedBot.api.setMessageReaction(args.chat_id as string, Number(args.message_id), [
10360
+ assertAllowedChat(String(args.chat_id ?? ''))
10361
+ await lockedBot.api.setMessageReaction(String(args.chat_id ?? ''), Number(args.message_id), [
10352
10362
  { type: 'emoji', emoji: args.emoji as ReactionTypeEmoji['emoji'] },
10353
10363
  ])
10354
10364
  return { content: [{ type: 'text', text: 'reacted' }] }
@@ -10392,7 +10402,7 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
10392
10402
  if (!args.chat_id) throw new Error('edit_message: chat_id is required')
10393
10403
  if (!args.message_id) throw new Error('edit_message: message_id is required')
10394
10404
  if (args.text == null || args.text === '') throw new Error('edit_message: text is required and cannot be empty')
10395
- assertAllowedChat(args.chat_id as string)
10405
+ assertAllowedChat(String(args.chat_id ?? ''))
10396
10406
  const editAccess = loadAccess()
10397
10407
  const editConfigMode = editAccess.parseMode ?? 'html'
10398
10408
  const editFormat = (args.format as string | undefined) ?? editConfigMode
@@ -10410,7 +10420,7 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
10410
10420
  editRawText = scrub.scrubbed
10411
10421
  emitRuntimeMetric({
10412
10422
  kind: 'voice_scrub_applied',
10413
- chatKey: statusKey(args.chat_id as string, undefined),
10423
+ chatKey: statusKey(String(args.chat_id ?? ''), undefined),
10414
10424
  replaced: scrub.replaced,
10415
10425
  site: 'edit_message',
10416
10426
  })
@@ -10430,14 +10440,14 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
10430
10440
  }
10431
10441
  const edited = await robustApiCall(
10432
10442
  () => lockedBot.api.editMessageText(
10433
- args.chat_id as string, Number(args.message_id), editText,
10443
+ String(args.chat_id ?? ''), Number(args.message_id), editText,
10434
10444
  ...(editParseMode ? [{ parse_mode: editParseMode }] : []),
10435
10445
  ),
10436
10446
  )
10437
10447
  const id = typeof edited === 'object' && edited ? (edited as any).message_id : args.message_id
10438
10448
  if (HISTORY_ENABLED) {
10439
10449
  try {
10440
- recordEdit({ chat_id: args.chat_id as string, message_id: Number(args.message_id), text: args.text as string })
10450
+ recordEdit({ chat_id: String(args.chat_id ?? ''), message_id: Number(args.message_id), text: args.text as string })
10441
10451
  } catch (err) {
10442
10452
  process.stderr.write(`telegram gateway: history recordEdit failed: ${err}\n`)
10443
10453
  }
@@ -10447,7 +10457,7 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
10447
10457
 
10448
10458
  async function executeSendTyping(args: Record<string, unknown>): Promise<unknown> {
10449
10459
  if (!args.chat_id) throw new Error('send_typing: chat_id is required')
10450
- const stChatId = args.chat_id as string
10460
+ const stChatId = String(args.chat_id ?? '')
10451
10461
  assertAllowedChat(stChatId)
10452
10462
  // #273: granular chat actions. Default 'typing' preserves the
10453
10463
  // existing tool semantics; agents can opt in to upload_document,
@@ -10481,7 +10491,7 @@ async function executeSendTyping(args: Record<string, unknown>): Promise<unknown
10481
10491
  async function executePinMessage(args: Record<string, unknown>): Promise<unknown> {
10482
10492
  if (!args.chat_id) throw new Error('pin_message: chat_id is required')
10483
10493
  if (!args.message_id) throw new Error('pin_message: message_id is required')
10484
- const pinChatId = args.chat_id as string
10494
+ const pinChatId = String(args.chat_id ?? '')
10485
10495
  assertAllowedChat(pinChatId)
10486
10496
  // #1075: wrap through robustApiCall so flood-wait / transient network
10487
10497
  // errors are retried. THREAD_NOT_FOUND on a stale topic surfaces to the
@@ -10497,7 +10507,7 @@ async function executePinMessage(args: Record<string, unknown>): Promise<unknown
10497
10507
  async function executeDeleteMessage(args: Record<string, unknown>): Promise<unknown> {
10498
10508
  if (!args.chat_id) throw new Error('delete_message: chat_id is required')
10499
10509
  if (!args.message_id) throw new Error('delete_message: message_id is required')
10500
- const delChatId = args.chat_id as string
10510
+ const delChatId = String(args.chat_id ?? '')
10501
10511
  const delMessageId = Number(args.message_id)
10502
10512
  assertAllowedChat(delChatId)
10503
10513
  await robustApiCall(() => lockedBot.api.deleteMessage(delChatId, delMessageId), { chat_id: delChatId })
@@ -10513,7 +10523,7 @@ async function executeForwardMessage(args: Record<string, unknown>): Promise<unk
10513
10523
  if (!args.chat_id) throw new Error('forward_message: chat_id is required')
10514
10524
  if (!args.from_chat_id) throw new Error('forward_message: from_chat_id is required')
10515
10525
  if (!args.message_id) throw new Error('forward_message: message_id is required')
10516
- const fwdChatId = args.chat_id as string
10526
+ const fwdChatId = String(args.chat_id ?? '')
10517
10527
  const fwdFromChatId = args.from_chat_id as string
10518
10528
  const fwdMsgId = Number(args.message_id)
10519
10529
  assertAllowedChat(fwdChatId)
@@ -10547,7 +10557,7 @@ async function executeGetRecentMessages(args: Record<string, unknown>): Promise<
10547
10557
  }
10548
10558
  }
10549
10559
  if (!args.chat_id) throw new Error('get_recent_messages: chat_id is required')
10550
- const chat_id = args.chat_id as string
10560
+ const chat_id = String(args.chat_id ?? '')
10551
10561
  assertAllowedChat(chat_id)
10552
10562
  const rawThread = args.message_thread_id as string | undefined
10553
10563
  let thread_id: number | null | undefined
@@ -144,12 +144,14 @@ const PERSIST_NOTE =
144
144
  '<i>Session-only — lasts until restart. To persist, set <code>model:</code> in switchroom.yaml and restart.</i>'
145
145
 
146
146
  function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
147
+ const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `<code>${a}</code>`).join(' · ')
147
148
  const lines: string[] = []
148
149
  if (reason) lines.push(`⚠️ ${deps.escapeHtml(reason)}`)
149
150
  lines.push(
150
151
  '<b>/model</b> — show or switch the Claude model',
151
152
  '<code>/model</code> — show the configured model',
152
153
  `<code>/model &lt;name&gt;</code> — switch the live session (${MODEL_ALIASES.map(a => `<code>${a}</code>`).join(' · ')} or a full model id)`,
154
+ `<i>OpenRouter shortcuts:</i> ${srAliasExamples}`,
153
155
  PERSIST_NOTE,
154
156
  )
155
157
  return { text: lines.join('\n'), html: true }
@@ -164,11 +166,13 @@ export async function handleModelCommand(
164
166
  if (parsed.kind === 'show') {
165
167
  const configured = deps.getConfiguredModel()
166
168
  const shown = configured && configured.length > 0 ? configured : 'default'
169
+ const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `<code>/model ${a}</code>`).join(' · ')
167
170
  return {
168
171
  text: [
169
172
  `<b>Model — ${deps.escapeHtml(deps.getAgentName())}</b>`,
170
173
  `Configured: <code>${deps.escapeHtml(shown)}</code>`,
171
174
  `Switch the live session: ${MODEL_ALIASES.map(a => `<code>/model ${a}</code>`).join(' · ')}`,
175
+ `OpenRouter shortcuts: ${srAliasExamples}`,
172
176
  'or <code>/model &lt;full-model-id&gt;</code>',
173
177
  PERSIST_NOTE,
174
178
  ].join('\n'),
@@ -182,15 +186,18 @@ export async function handleModelCommand(
182
186
  return helpText(deps, `not a valid model name: ${parsed.model}`)
183
187
  }
184
188
 
189
+ // Expand short aliases: `flash` → `sr-gemini-2.5-flash`, `codex` → `sr-codex-5.5`, etc.
190
+ const model = expandSrAlias(parsed.model)
191
+
185
192
  // sr-* → Claude: an in-place `/model` inject would leave LiteLLM routing
186
193
  // active in the live session because the sr-* model context was set by
187
194
  // the proxy at session start, not by claude's own REPL. A graceful restart
188
195
  // is the only clean path back to the native OAuth route. This matches the
189
196
  // behaviour of the `/restart` command (same mechanism, same marker logic).
190
197
  const currentSession = deps.getActiveSessionModel()
191
- if (currentSession !== null && isSrModel(currentSession) && isClaudeModel(parsed.model)) {
198
+ if (currentSession !== null && isSrModel(currentSession) && isClaudeModel(model)) {
192
199
  try {
193
- await deps.scheduleRestart(`user: /model ${parsed.model} (sr-to-claude restart)`)
200
+ await deps.scheduleRestart(`user: /model ${model} (sr-to-claude restart)`)
194
201
  } catch (err) {
195
202
  const msg = err instanceof Error ? err.message : String(err)
196
203
  return {
@@ -207,10 +214,10 @@ export async function handleModelCommand(
207
214
  }
208
215
  }
209
216
 
210
- const verbHtml = `<code>/model ${deps.escapeHtml(parsed.model)}</code>`
217
+ const verbHtml = `<code>/model ${deps.escapeHtml(model)}</code>`
211
218
  let result: InjectResult
212
219
  try {
213
- result = await deps.inject(deps.getAgentName(), `/model ${parsed.model}`)
220
+ result = await deps.inject(deps.getAgentName(), `/model ${model}`)
214
221
  } catch (err) {
215
222
  const msg = err instanceof Error ? err.message : String(err)
216
223
  return {
@@ -315,6 +322,27 @@ export const SR_MODEL_LABELS: Record<string, string> = {
315
322
  'sr-deepseek-r1': 'DeepSeek R1',
316
323
  'sr-deepseek-v3': 'DeepSeek V3',
317
324
  'sr-glm-5': 'GLM-5',
325
+ 'sr-codex-5.5': 'Codex 5.5',
326
+ }
327
+
328
+ /**
329
+ * Short text-command aliases for sr-* models. These let the operator type
330
+ * `/model flash`, `/model codex`, etc. instead of the full `sr-*` id.
331
+ * Expanded in handleModelCommand before injection; the full sr-* id is what
332
+ * reaches the agent session and LiteLLM.
333
+ */
334
+ export const SR_MODEL_ALIASES: Record<string, string> = {
335
+ flash: 'sr-gemini-2.5-flash',
336
+ gemini: 'sr-gemini-2.5-pro',
337
+ deepseek: 'sr-deepseek-v3',
338
+ r1: 'sr-deepseek-r1',
339
+ glm: 'sr-glm-5',
340
+ codex: 'sr-codex-5.5',
341
+ }
342
+
343
+ /** Expand a short alias (case-insensitive) to its full sr-* id, or return the original. */
344
+ export function expandSrAlias(arg: string): string {
345
+ return SR_MODEL_ALIASES[arg.toLowerCase()] ?? arg
318
346
  }
319
347
 
320
348
  function srFriendlyLabel(srName: string): string {
@@ -13,7 +13,7 @@
13
13
  * REPL verb — already on the inject allowlist) and relays the
14
14
  * captured output, with the session-only persistence caveat.
15
15
  */
16
- import { describe, it, expect } from "vitest";
16
+ import { describe, it, expect, beforeAll } from "vitest";
17
17
  import {
18
18
  parseModelCommand,
19
19
  handleModelCommand,
@@ -344,6 +344,60 @@ describe("inject allowlist contract", () => {
344
344
  // Picker-driven menu (v2) — buildModelMenu + handleModelMenuCallback
345
345
  // ---------------------------------------------------------------------------
346
346
 
347
+ describe("SR_MODEL_ALIASES / expandSrAlias", () => {
348
+ let expandSrAlias: (arg: string) => string;
349
+ let SR_MODEL_ALIASES: Record<string, string>;
350
+
351
+ beforeAll(async () => {
352
+ const mod = await import("../gateway/model-command.js");
353
+ expandSrAlias = mod.expandSrAlias;
354
+ SR_MODEL_ALIASES = mod.SR_MODEL_ALIASES;
355
+ });
356
+
357
+ it("expands known short aliases to full sr-* ids", () => {
358
+ expect(expandSrAlias("flash")).toBe("sr-gemini-2.5-flash");
359
+ expect(expandSrAlias("gemini")).toBe("sr-gemini-2.5-pro");
360
+ expect(expandSrAlias("deepseek")).toBe("sr-deepseek-v3");
361
+ expect(expandSrAlias("r1")).toBe("sr-deepseek-r1");
362
+ expect(expandSrAlias("glm")).toBe("sr-glm-5");
363
+ expect(expandSrAlias("codex")).toBe("sr-codex-5.5");
364
+ });
365
+
366
+ it("is case-insensitive", () => {
367
+ expect(expandSrAlias("Flash")).toBe("sr-gemini-2.5-flash");
368
+ expect(expandSrAlias("CODEX")).toBe("sr-codex-5.5");
369
+ });
370
+
371
+ it("passes through unknown names unchanged", () => {
372
+ expect(expandSrAlias("opus")).toBe("opus");
373
+ expect(expandSrAlias("sr-gemini-2.5-flash")).toBe("sr-gemini-2.5-flash");
374
+ expect(expandSrAlias("claude-opus-4-8")).toBe("claude-opus-4-8");
375
+ });
376
+
377
+ it("every alias target is a valid sr-* model arg", () => {
378
+ for (const [alias, target] of Object.entries(SR_MODEL_ALIASES)) {
379
+ expect(target.startsWith("sr-"), `${alias} → ${target} must start with sr-`).toBe(true);
380
+ }
381
+ });
382
+
383
+ it("handleModelCommand injects expanded sr-* id, not the short alias", async () => {
384
+ const { deps, calls } = makeDeps({ getActiveSessionModel: () => null });
385
+ await handleModelCommand({ kind: "set", model: "flash" }, deps);
386
+ expect(calls).toHaveLength(1);
387
+ expect(calls[0].command).toBe("/model sr-gemini-2.5-flash");
388
+ });
389
+
390
+ it("handleModelCommand with alias schedules restart when session is on sr-*", async () => {
391
+ const { deps, calls, restartCalls } = makeDeps({
392
+ getActiveSessionModel: () => "sr-deepseek-v3",
393
+ });
394
+ await handleModelCommand({ kind: "set", model: "opus" }, deps);
395
+ expect(calls).toHaveLength(0);
396
+ expect(restartCalls).toHaveLength(1);
397
+ expect(restartCalls[0]).toContain("opus");
398
+ });
399
+ });
400
+
347
401
  import {
348
402
  buildModelMenu,
349
403
  handleModelMenuCallback,
@@ -42,9 +42,11 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
42
42
  const sc = await spinUp({ agent: AGENT });
43
43
  try {
44
44
  await sc.sendDM("/model");
45
+ // 60s — if an obligation turn is being processed when /model lands,
46
+ // the menu may be buffered until the turn drains (~40s max).
45
47
  const menu = await sc.expectMessage(/Default \(new sessions\):/i, {
46
48
  from: "bot",
47
- timeout: 30_000,
49
+ timeout: 60_000,
48
50
  });
49
51
 
50
52
  // ── 1. Section headers ──────────────────────────────────────────