switchroom 0.16.8 → 0.16.9

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.8";
51678
- var COMMIT_SHA = "c2668516";
51677
+ var VERSION = "0.16.9";
51678
+ var COMMIT_SHA = "1a926737";
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.8";
22590
+ var VERSION = "0.16.9";
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.8",
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.",
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.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "switchroom": "./dist/cli/switchroom.js"
@@ -46004,6 +46004,15 @@ var MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._\[\]-]{0,99}$/;
46004
46004
  function isValidModelArg(arg) {
46005
46005
  return MODEL_ARG_RE.test(arg);
46006
46006
  }
46007
+ function isSrModel(name) {
46008
+ return name.startsWith("sr-");
46009
+ }
46010
+ function isClaudeModel(name) {
46011
+ const lower = name.toLowerCase();
46012
+ if (MODEL_ALIASES.includes(lower))
46013
+ return true;
46014
+ return lower.startsWith("claude-");
46015
+ }
46007
46016
  function parseModelCommand(text) {
46008
46017
  const m = text.match(/^\/model(?:@[A-Za-z0-9_]+)?(?:\s+([\s\S]*))?$/);
46009
46018
  if (!m)
@@ -46053,6 +46062,26 @@ async function handleModelCommand(parsed, deps) {
46053
46062
  if (!isValidModelArg(parsed.model)) {
46054
46063
  return helpText2(deps, `not a valid model name: ${parsed.model}`);
46055
46064
  }
46065
+ const currentSession = deps.getActiveSessionModel();
46066
+ if (currentSession !== null && isSrModel(currentSession) && isClaudeModel(parsed.model)) {
46067
+ try {
46068
+ await deps.scheduleRestart(`user: /model ${parsed.model} (sr-to-claude restart)`);
46069
+ } catch (err) {
46070
+ const msg = err instanceof Error ? err.message : String(err);
46071
+ return {
46072
+ text: `\u274c Could not schedule restart: ${deps.escapeHtml(msg)}`,
46073
+ html: true
46074
+ };
46075
+ }
46076
+ return {
46077
+ text: [
46078
+ `Switching from <code>${deps.escapeHtml(currentSession)}</code> back to Claude \u2014 restarting session cleanly. Claude will be ready in ~30s.`,
46079
+ PERSIST_NOTE
46080
+ ].join(`
46081
+ `),
46082
+ html: true
46083
+ };
46084
+ }
46056
46085
  const verbHtml = `<code>/model ${deps.escapeHtml(parsed.model)}</code>`;
46057
46086
  let result;
46058
46087
  try {
@@ -46272,6 +46301,9 @@ async function handleModelMenuCallback(data, deps) {
46272
46301
  selectedModel: sessionModelFromConfirmation(result.confirmation) ?? target.label
46273
46302
  };
46274
46303
  }
46304
+ function isSrToClaudeTransition(prevModel, nextModel) {
46305
+ return !!prevModel?.startsWith("sr-") && !nextModel.startsWith("sr-");
46306
+ }
46275
46307
  function sessionModelFromConfirmation(confirmation) {
46276
46308
  const m = /(?:Set model to|Switched to)\s+(.+?)(?:\s+for (?:this|the) session|\s*\(|\s*$)/i.exec(confirmation.trim());
46277
46309
  const name = m?.[1]?.trim();
@@ -56015,11 +56047,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
56015
56047
  }
56016
56048
 
56017
56049
  // ../src/build-info.ts
56018
- var VERSION = "0.16.8";
56019
- var COMMIT_SHA = "c2668516";
56020
- var COMMIT_DATE = "2026-06-28T15:28:08+10:00";
56021
- var LATEST_PR = null;
56022
- var COMMITS_AHEAD_OF_TAG = 5;
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;
56054
+ var COMMITS_AHEAD_OF_TAG = 0;
56023
56055
 
56024
56056
  // gateway/boot-version.ts
56025
56057
  function formatRelativeAgo(iso) {
@@ -65209,7 +65241,7 @@ bot.command("compact", async (ctx) => {
65209
65241
  bot.command("clear", async (ctx) => {
65210
65242
  await handleInjectCommand(ctx, buildInjectDeps({ open: true, fixedVerb: "/clear" }));
65211
65243
  });
65212
- function buildModelDeps() {
65244
+ function buildModelDeps(restartCtx) {
65213
65245
  return {
65214
65246
  discover: (a) => discoverModels(a),
65215
65247
  discoverSrModels: async () => {
@@ -65258,7 +65290,39 @@ function buildModelDeps() {
65258
65290
  return data?.agents?.find((a) => a.name === getMyAgentName())?.model ?? null;
65259
65291
  },
65260
65292
  escapeHtml: escapeHtmlForTg,
65261
- preBlock
65293
+ preBlock,
65294
+ getActiveSessionModel: () => activeSessionModelOverride,
65295
+ scheduleRestart: async (reason) => {
65296
+ const name = getMyAgentName();
65297
+ const existing = readRestartMarker();
65298
+ if (existing && Date.now() - existing.ts < 15000)
65299
+ return;
65300
+ if (restartCtx) {
65301
+ writeRestartMarker({
65302
+ chat_id: restartCtx.chatId,
65303
+ thread_id: restartCtx.threadId ?? null,
65304
+ ack_message_id: null,
65305
+ ts: Date.now()
65306
+ });
65307
+ }
65308
+ stampUserRestartReason(reason);
65309
+ await sweepBeforeSelfRestart();
65310
+ const hostdResp = await tryHostdDispatch(name, {
65311
+ v: 1,
65312
+ op: "agent_restart",
65313
+ request_id: hostdRequestId("gw-model-restart"),
65314
+ args: { name, force: true, reason }
65315
+ });
65316
+ if (hostdResp === "not-configured") {
65317
+ warnLegacySpawnIfHostdDisabled("agent_restart");
65318
+ spawnSwitchroomDetached(["agent", "restart", name, "--force"], notifyDetachedFailure(restartCtx?.chatId ?? "", restartCtx?.threadId ?? null, `restart ${name}`));
65319
+ return;
65320
+ }
65321
+ if (hostdResp.result !== "started" && hostdResp.result !== "completed") {
65322
+ clearRestartMarker();
65323
+ throw new Error(`hostd restart failed (result=${hostdResp.result}): ${hostdResp.error ?? "(no details)"}`);
65324
+ }
65325
+ }
65262
65326
  };
65263
65327
  }
65264
65328
  function modelMenuReplyMarkup(reply) {
@@ -65277,7 +65341,9 @@ bot.command("model", async (ctx) => {
65277
65341
  return;
65278
65342
  const text2 = ctx.message?.text ?? ctx.channelPost?.text ?? "";
65279
65343
  const parsed = parseModelCommand(text2) ?? { kind: "show" };
65280
- const deps = buildModelDeps();
65344
+ const chatId = String(ctx.chat.id);
65345
+ const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id);
65346
+ const deps = buildModelDeps({ chatId, threadId });
65281
65347
  if (parsed.kind === "show" && process.env.SWITCHROOM_MODEL_MENU !== "0") {
65282
65348
  const menu = await buildModelMenu(deps);
65283
65349
  await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) });
@@ -68174,7 +68240,9 @@ bot.on("callback_query:data", async (ctx) => {
68174
68240
  }).catch(() => {});
68175
68241
  return;
68176
68242
  }
68177
- const modelDeps = buildModelDeps();
68243
+ const cbChatId = String(ctx.chat?.id ?? "");
68244
+ const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id);
68245
+ const modelDeps = buildModelDeps({ chatId: cbChatId, threadId: cbThreadId });
68178
68246
  if (modelDeps.isBusy()) {
68179
68247
  await ctx.answerCallbackQuery({ text: "\u23F3 Agent is mid-turn \u2014 tap again when it\u2019s idle", show_alert: false }).catch(() => {});
68180
68248
  return;
@@ -68185,12 +68253,25 @@ bot.on("callback_query:data", async (ctx) => {
68185
68253
  }
68186
68254
  await ctx.answerCallbackQuery({ text: "Switching\u2026" }).catch(() => {});
68187
68255
  try {
68256
+ const prevSessionModel = activeSessionModelOverride;
68188
68257
  const outcome = await handleModelMenuCallback(data, modelDeps);
68189
68258
  if (outcome.selectedModel) {
68190
68259
  activeSessionModelOverride = outcome.selectedModel;
68191
68260
  }
68192
68261
  if (outcome.toastOnly)
68193
68262
  return;
68263
+ if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
68264
+ const agentName3 = getMyAgentName();
68265
+ await ctx.editMessageText(`\uD83D\uDD04 Switching from <b>${escapeHtmlForTg(prevSessionModel)}</b> back to Claude \u2014 restarting session cleanly. Claude will be ready in ~30s.`, { parse_mode: "HTML", reply_markup: { inline_keyboard: [] } }).catch(() => {});
68266
+ writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() });
68267
+ stampUserRestartReason("user: sr-to-claude model switch (menu)");
68268
+ if (turnInFlightForGate()) {
68269
+ pendingRestarts.set(agentName3, Date.now());
68270
+ } else {
68271
+ sweepBeforeSelfRestart().finally(() => triggerSelfRestart(agentName3, "sr-to-claude-model-switch", 1500));
68272
+ }
68273
+ return;
68274
+ }
68194
68275
  await ctx.editMessageText(outcome.reply.text, {
68195
68276
  parse_mode: "HTML",
68196
68277
  reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] }
@@ -275,6 +275,7 @@ import {
275
275
  handleModelCommand,
276
276
  buildModelMenu,
277
277
  handleModelMenuCallback,
278
+ isSrToClaudeTransition,
278
279
  MODEL_CALLBACK_PREFIX,
279
280
  MODEL_CALLBACK_HEADER,
280
281
  type ModelMenuDeps,
@@ -15978,7 +15979,12 @@ bot.command('clear', async ctx => {
15978
15979
  // text. The typed argument form rides the allowlisted inject
15979
15980
  // primitive unchanged. Implementation in model-command.ts so it's
15980
15981
  // unit-testable without booting the bot.
15981
- function buildModelDeps(): ModelMenuDeps & ModelCommandDeps {
15982
+ interface ModelDepsRestartContext {
15983
+ chatId: string
15984
+ threadId: number | undefined
15985
+ }
15986
+
15987
+ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & ModelCommandDeps {
15982
15988
  return {
15983
15989
  discover: (a) => discoverModels(a),
15984
15990
  discoverSrModels: async () => {
@@ -16029,6 +16035,55 @@ function buildModelDeps(): ModelMenuDeps & ModelCommandDeps {
16029
16035
  },
16030
16036
  escapeHtml: escapeHtmlForTg,
16031
16037
  preBlock,
16038
+ getActiveSessionModel: () => activeSessionModelOverride,
16039
+ /**
16040
+ * Graceful restart for sr-* → Claude model switch. Same mechanism as
16041
+ * the /restart command: writes a restart marker (so the post-restart
16042
+ * greeting lands in the originating chat), stamps the reason, and
16043
+ * dispatches via hostd (or legacy detached spawn as fallback).
16044
+ */
16045
+ scheduleRestart: async (reason: string) => {
16046
+ const name = getMyAgentName()
16047
+ // Debounce: mirror the /restart command's 15 s guard to prevent
16048
+ // double-dispatch on a rapid double-tap of /model <claude-alias>.
16049
+ const existing = readRestartMarker()
16050
+ if (existing && Date.now() - existing.ts < 15_000) return
16051
+ if (restartCtx) {
16052
+ writeRestartMarker({
16053
+ chat_id: restartCtx.chatId,
16054
+ thread_id: restartCtx.threadId ?? null,
16055
+ ack_message_id: null,
16056
+ ts: Date.now(),
16057
+ })
16058
+ }
16059
+ stampUserRestartReason(reason)
16060
+ await sweepBeforeSelfRestart()
16061
+ const hostdResp = await tryHostdDispatch(name, {
16062
+ v: 1,
16063
+ op: 'agent_restart',
16064
+ request_id: hostdRequestId('gw-model-restart'),
16065
+ args: { name, force: true, reason },
16066
+ })
16067
+ if (hostdResp === 'not-configured') {
16068
+ warnLegacySpawnIfHostdDisabled('agent_restart')
16069
+ spawnSwitchroomDetached(
16070
+ ['agent', 'restart', name, '--force'],
16071
+ notifyDetachedFailure(
16072
+ restartCtx?.chatId ?? '',
16073
+ restartCtx?.threadId ?? null,
16074
+ `restart ${name}`,
16075
+ ),
16076
+ )
16077
+ return
16078
+ }
16079
+ // hostd is configured but returned an error/denied result.
16080
+ if (hostdResp.result !== 'started' && hostdResp.result !== 'completed') {
16081
+ clearRestartMarker()
16082
+ throw new Error(
16083
+ `hostd restart failed (result=${hostdResp.result}): ${hostdResp.error ?? '(no details)'}`,
16084
+ )
16085
+ }
16086
+ },
16032
16087
  }
16033
16088
  }
16034
16089
 
@@ -16046,7 +16101,9 @@ bot.command('model', async ctx => {
16046
16101
  if (!isAuthorizedSender(ctx)) return
16047
16102
  const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
16048
16103
  const parsed = parseModelCommand(text) ?? { kind: 'show' as const }
16049
- const deps = buildModelDeps()
16104
+ const chatId = String(ctx.chat!.id)
16105
+ const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
16106
+ const deps = buildModelDeps({ chatId, threadId })
16050
16107
  if (parsed.kind === 'show' && process.env.SWITCHROOM_MODEL_MENU !== '0') {
16051
16108
  const menu = await buildModelMenu(deps)
16052
16109
  await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) })
@@ -20726,7 +20783,9 @@ bot.on('callback_query:data', async ctx => {
20726
20783
  .catch(() => {})
20727
20784
  return
20728
20785
  }
20729
- const modelDeps = buildModelDeps()
20786
+ const cbChatId = String(ctx.chat?.id ?? '')
20787
+ const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id)
20788
+ const modelDeps = buildModelDeps({ chatId: cbChatId, threadId: cbThreadId })
20730
20789
  // Mid-turn refusal is INSTANT (a sync isBusy() check, no picker drive),
20731
20790
  // so handle it before the "Working…" ack: toast WHY and leave the menu
20732
20791
  // message untouched (buttons intact) so the operator taps again when
@@ -20750,6 +20809,7 @@ bot.on('callback_query:data', async ctx => {
20750
20809
  }
20751
20810
  await ctx.answerCallbackQuery({ text: 'Switching…' }).catch(() => {})
20752
20811
  try {
20812
+ const prevSessionModel = activeSessionModelOverride
20753
20813
  const outcome = await handleModelMenuCallback(data, modelDeps)
20754
20814
  // Record a successful session switch so /status reflects what's
20755
20815
  // actually running. In-memory only → clears when the gateway (and thus
@@ -20760,6 +20820,35 @@ bot.on('callback_query:data', async ctx => {
20760
20820
  // toastOnly: a no-op outcome that should not disturb the menu (defence
20761
20821
  // in depth — the isBusy() short-circuit above is the live path).
20762
20822
  if (outcome.toastOnly) return
20823
+
20824
+ // sr-* → Claude transition via the model menu: trigger a graceful restart.
20825
+ // Switching FROM an sr-* (LiteLLM/OpenRouter) model BACK to a Claude model
20826
+ // via the picker requires a session restart — the picker-select only changes
20827
+ // the session model label, but the sr-* LiteLLM routing context persists
20828
+ // until the session is torn down. Same mechanism as the /restart command.
20829
+ if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
20830
+ const agentName = getMyAgentName()
20831
+ // Replace the menu with a restart notice (no buttons — session is ending).
20832
+ await ctx
20833
+ .editMessageText(
20834
+ `🔄 Switching from <b>${escapeHtmlForTg(prevSessionModel!)}</b> back to Claude — restarting session cleanly. Claude will be ready in ~30s.`,
20835
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
20836
+ )
20837
+ .catch(() => {})
20838
+ // Write the restart marker so the post-restart boot card edits into this chat.
20839
+ writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() })
20840
+ stampUserRestartReason('user: sr-to-claude model switch (menu)')
20841
+ if (turnInFlightForGate()) {
20842
+ // Defer restart until the in-flight turn completes (same gate as /restart).
20843
+ pendingRestarts.set(agentName, Date.now())
20844
+ } else {
20845
+ void sweepBeforeSelfRestart().finally(() =>
20846
+ triggerSelfRestart(agentName, 'sr-to-claude-model-switch', 1500),
20847
+ )
20848
+ }
20849
+ return
20850
+ }
20851
+
20763
20852
  await ctx
20764
20853
  .editMessageText(outcome.reply.text, {
20765
20854
  parse_mode: 'HTML',
@@ -62,6 +62,23 @@ export function isValidModelArg(arg: string): boolean {
62
62
  return MODEL_ARG_RE.test(arg)
63
63
  }
64
64
 
65
+ /** True when `name` is an sr-* (LiteLLM/OpenRouter) model identifier. */
66
+ export function isSrModel(name: string): boolean {
67
+ return name.startsWith('sr-')
68
+ }
69
+
70
+ /**
71
+ * True when `name` is a Claude model — either a well-known alias or a
72
+ * full `claude-*` id (including `[1m]` variants). This is intentionally
73
+ * broader than MODEL_ALIASES: any `claude-…` string from claude's own
74
+ * picker (e.g. `claude-opus-4-8`) qualifies.
75
+ */
76
+ export function isClaudeModel(name: string): boolean {
77
+ const lower = name.toLowerCase()
78
+ if ((MODEL_ALIASES as readonly string[]).includes(lower)) return true
79
+ return lower.startsWith('claude-')
80
+ }
81
+
65
82
  export type ParsedModelCommand =
66
83
  | { kind: 'show' }
67
84
  | { kind: 'set'; model: string }
@@ -100,6 +117,22 @@ export interface ModelCommandDeps {
100
117
  getConfiguredModel: () => string | null
101
118
  escapeHtml: (s: string) => string
102
119
  preBlock: (s: string) => string
120
+ /**
121
+ * The active session-model override set by a prior `/model` switch.
122
+ * Null when no session override is active (using configured/default model).
123
+ * Used to detect whether the current session is on an sr-* (OpenRouter)
124
+ * model so a switch back to Claude can trigger a graceful restart instead
125
+ * of an in-place inject (which would leave stale sr-* routing in place).
126
+ */
127
+ getActiveSessionModel: () => string | null
128
+ /**
129
+ * Schedule a graceful restart of this agent. Called instead of inject
130
+ * when switching from an sr-* model back to Claude — the restart clears
131
+ * the sr-* session context cleanly, whereas an in-place inject would
132
+ * leave LiteLLM routing active. The gateway wires this to the same
133
+ * mechanism as the `/restart` command (hostd-first, SIGTERM fallback).
134
+ */
135
+ scheduleRestart: (reason: string) => Promise<void>
103
136
  }
104
137
 
105
138
  export interface ModelCommandReply {
@@ -148,6 +181,32 @@ export async function handleModelCommand(
148
181
  if (!isValidModelArg(parsed.model)) {
149
182
  return helpText(deps, `not a valid model name: ${parsed.model}`)
150
183
  }
184
+
185
+ // sr-* → Claude: an in-place `/model` inject would leave LiteLLM routing
186
+ // active in the live session because the sr-* model context was set by
187
+ // the proxy at session start, not by claude's own REPL. A graceful restart
188
+ // is the only clean path back to the native OAuth route. This matches the
189
+ // behaviour of the `/restart` command (same mechanism, same marker logic).
190
+ const currentSession = deps.getActiveSessionModel()
191
+ if (currentSession !== null && isSrModel(currentSession) && isClaudeModel(parsed.model)) {
192
+ try {
193
+ await deps.scheduleRestart(`user: /model ${parsed.model} (sr-to-claude restart)`)
194
+ } catch (err) {
195
+ const msg = err instanceof Error ? err.message : String(err)
196
+ return {
197
+ text: `❌ Could not schedule restart: ${deps.escapeHtml(msg)}`,
198
+ html: true,
199
+ }
200
+ }
201
+ return {
202
+ text: [
203
+ `Switching from <code>${deps.escapeHtml(currentSession)}</code> back to Claude — restarting session cleanly. Claude will be ready in ~30s.`,
204
+ PERSIST_NOTE,
205
+ ].join('\n'),
206
+ html: true,
207
+ }
208
+ }
209
+
151
210
  const verbHtml = `<code>/model ${deps.escapeHtml(parsed.model)}</code>`
152
211
  let result: InjectResult
153
212
  try {
@@ -549,6 +608,20 @@ export async function handleModelMenuCallback(
549
608
  }
550
609
  }
551
610
 
611
+ /**
612
+ * True when the transition from `prevModel` to `nextModel` is a switch FROM
613
+ * an sr-* (LiteLLM/OpenRouter) model BACK TO a native Claude model. This
614
+ * signals that a session restart is required — an in-place model-picker select
615
+ * cannot undo the LiteLLM routing that the sr-* switch established in the live
616
+ * session. Null / undefined prev means no prior sr-* session — not a transition.
617
+ */
618
+ export function isSrToClaudeTransition(
619
+ prevModel: string | null | undefined,
620
+ nextModel: string,
621
+ ): boolean {
622
+ return !!prevModel?.startsWith('sr-') && !nextModel.startsWith('sr-')
623
+ }
624
+
552
625
  /**
553
626
  * Pull the model NAME out of claude's session-switch confirmation so it can
554
627
  * be shown in `/status` as the live session model. claude phrases it as
@@ -18,6 +18,8 @@ import {
18
18
  parseModelCommand,
19
19
  handleModelCommand,
20
20
  isValidModelArg,
21
+ isSrModel,
22
+ isClaudeModel,
21
23
  MODEL_ALIASES,
22
24
  type ModelCommandDeps,
23
25
  } from "../gateway/model-command.js";
@@ -35,6 +37,7 @@ function okResult(output: string): InjectResult {
35
37
 
36
38
  function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
37
39
  const calls: Array<{ agent: string; command: string }> = [];
40
+ const restartCalls: string[] = [];
38
41
  const deps: ModelCommandDeps = {
39
42
  inject: async (agent, command) => {
40
43
  calls.push({ agent, command });
@@ -45,9 +48,11 @@ function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
45
48
  escapeHtml: (s) =>
46
49
  s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"),
47
50
  preBlock: (s) => `<pre>${s}</pre>`,
51
+ getActiveSessionModel: () => null,
52
+ scheduleRestart: async (reason) => { restartCalls.push(reason); },
48
53
  ...overrides,
49
54
  };
50
- return { deps, calls };
55
+ return { deps, calls, restartCalls };
51
56
  }
52
57
 
53
58
  describe("parseModelCommand", () => {
@@ -237,6 +242,97 @@ describe("handleModelCommand — set", () => {
237
242
  });
238
243
  });
239
244
 
245
+ describe("isSrModel / isClaudeModel helpers", () => {
246
+ it("isSrModel is true only for sr-* names", () => {
247
+ expect(isSrModel("sr-gemini-2.5-pro")).toBe(true);
248
+ expect(isSrModel("sr-deepseek-r1")).toBe(true);
249
+ expect(isSrModel("claude-sonnet-4-6")).toBe(false);
250
+ expect(isSrModel("sonnet")).toBe(false);
251
+ expect(isSrModel("")).toBe(false);
252
+ });
253
+
254
+ it("isClaudeModel is true for aliases and claude-* ids", () => {
255
+ for (const alias of MODEL_ALIASES) {
256
+ expect(isClaudeModel(alias), alias).toBe(true);
257
+ }
258
+ expect(isClaudeModel("claude-opus-4-8")).toBe(true);
259
+ expect(isClaudeModel("claude-sonnet-4-6[1m]")).toBe(true);
260
+ expect(isClaudeModel("sr-gemini-2.5-pro")).toBe(false);
261
+ expect(isClaudeModel("gpt-4")).toBe(false);
262
+ });
263
+ });
264
+
265
+ describe("handleModelCommand — sr-* → Claude graceful restart", () => {
266
+ it("schedules restart instead of injecting when session is on sr-* and target is Claude alias", async () => {
267
+ const { deps, calls, restartCalls } = makeDeps({
268
+ getActiveSessionModel: () => "sr-gemini-2.5-pro",
269
+ });
270
+ const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
271
+ // Must NOT inject
272
+ expect(calls).toHaveLength(0);
273
+ // Must schedule a restart
274
+ expect(restartCalls).toHaveLength(1);
275
+ expect(restartCalls[0]).toContain("opus");
276
+ expect(restartCalls[0]).toContain("sr-to-claude");
277
+ // Reply mentions the sr-* model and ~30s
278
+ expect(reply.text).toContain("sr-gemini-2.5-pro");
279
+ expect(reply.text).toContain("30s");
280
+ expect(reply.html).toBe(true);
281
+ });
282
+
283
+ it("schedules restart when session is on sr-* and target is a full claude-* id", async () => {
284
+ const { deps, calls, restartCalls } = makeDeps({
285
+ getActiveSessionModel: () => "sr-deepseek-r1",
286
+ });
287
+ const reply = await handleModelCommand({ kind: "set", model: "claude-opus-4-8" }, deps);
288
+ expect(calls).toHaveLength(0);
289
+ expect(restartCalls).toHaveLength(1);
290
+ expect(reply.text).toContain("sr-deepseek-r1");
291
+ expect(reply.text).toContain("30s");
292
+ });
293
+
294
+ it("does NOT restart when switching between Claude models (no sr-* session)", async () => {
295
+ const { deps, calls, restartCalls } = makeDeps({
296
+ getActiveSessionModel: () => "Opus 4.8",
297
+ });
298
+ const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
299
+ // Normal inject path: still injects, no restart
300
+ expect(calls).toHaveLength(1);
301
+ expect(restartCalls).toHaveLength(0);
302
+ expect(reply.text).toContain("Set model to sonnet");
303
+ });
304
+
305
+ it("does NOT restart when switching from Claude to sr-* (no session override)", async () => {
306
+ const { deps, calls, restartCalls } = makeDeps({
307
+ getActiveSessionModel: () => null,
308
+ });
309
+ await handleModelCommand({ kind: "set", model: "sr-gemini-2.5-pro" }, deps);
310
+ // sr-* is not a Claude model — no restart
311
+ expect(restartCalls).toHaveLength(0);
312
+ expect(calls).toHaveLength(1);
313
+ });
314
+
315
+ it("surfaces scheduleRestart failures without propagating the error", async () => {
316
+ const { deps, calls } = makeDeps({
317
+ getActiveSessionModel: () => "sr-deepseek-r1",
318
+ scheduleRestart: async () => { throw new Error("hostd unreachable"); },
319
+ });
320
+ const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
321
+ expect(calls).toHaveLength(0);
322
+ expect(reply.text).toContain("Could not schedule restart");
323
+ expect(reply.text).toContain("hostd unreachable");
324
+ });
325
+
326
+ it("null session model (no prior override) still uses normal inject path for Claude target", async () => {
327
+ const { deps, calls, restartCalls } = makeDeps({
328
+ getActiveSessionModel: () => null,
329
+ });
330
+ await handleModelCommand({ kind: "set", model: "opus" }, deps);
331
+ expect(calls).toHaveLength(1);
332
+ expect(restartCalls).toHaveLength(0);
333
+ });
334
+ });
335
+
240
336
  describe("inject allowlist contract", () => {
241
337
  it("/model stays on the inject allowlist (the set path depends on it)", async () => {
242
338
  const { INJECT_COMMANDS } = await import("../../src/agents/inject.js");
@@ -258,6 +354,7 @@ import {
258
354
  MODEL_CALLBACK_HEADER,
259
355
  MODEL_CALLBACK_SR,
260
356
  SR_MODEL_LABELS,
357
+ isSrToClaudeTransition,
261
358
  type ModelMenuDeps,
262
359
  } from "../gateway/model-command.js";
263
360
  import { labelTag } from "../../src/agents/model-picker.js";
@@ -578,3 +675,29 @@ describe("handleModelMenuCallback — sr-* selection", () => {
578
675
  expect(out.answer).toBe("Invalid model name");
579
676
  });
580
677
  });
678
+
679
+ // ---------------------------------------------------------------------------
680
+ // isSrToClaudeTransition helper (used by gateway callback handler)
681
+ // ---------------------------------------------------------------------------
682
+
683
+ describe("isSrToClaudeTransition", () => {
684
+ it("true when prev is sr-* and next is not sr-*", () => {
685
+ expect(isSrToClaudeTransition("sr-gemini-2.5-pro", "Haiku 4.5")).toBe(true);
686
+ expect(isSrToClaudeTransition("sr-deepseek-r1", "Fable 5")).toBe(true);
687
+ expect(isSrToClaudeTransition("sr-deepseek-r1", "claude-opus-4-8")).toBe(true);
688
+ });
689
+
690
+ it("false when prev is not sr-* (Claude → Claude)", () => {
691
+ expect(isSrToClaudeTransition("Opus 4.8", "Haiku 4.5")).toBe(false);
692
+ expect(isSrToClaudeTransition(null, "Sonnet")).toBe(false);
693
+ expect(isSrToClaudeTransition(undefined, "Sonnet")).toBe(false);
694
+ });
695
+
696
+ it("false when prev is sr-* but next is also sr-* (sr-* → sr-*)", () => {
697
+ expect(isSrToClaudeTransition("sr-gemini-2.5-pro", "sr-deepseek-r1")).toBe(false);
698
+ });
699
+
700
+ it("false when switching to sr-* from Claude (Claude → sr-*)", () => {
701
+ expect(isSrToClaudeTransition("Sonnet", "sr-gemini-2.5-pro")).toBe(false);
702
+ });
703
+ });
@@ -57,7 +57,12 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
57
57
  const openrouterHeader = flat.find(
58
58
  (b) => b.text.includes("OpenRouter") && b.callbackData === "mdl:h",
59
59
  );
60
- const srButton = flat.find((b) => b.callbackData?.startsWith("mdl:sr:"));
60
+ // Prefer a fast non-reasoning model for the E2E test; reasoning models
61
+ // (deepseek-r1, o1, o3) take 2-5 min per response and hit the silence poke.
62
+ const srButton =
63
+ flat.find((b) => b.callbackData?.startsWith("mdl:sr:") && /flash/.test(b.callbackData)) ??
64
+ flat.find((b) => b.callbackData?.startsWith("mdl:sr:") && !/r1|o1|o3|thinking/.test(b.callbackData)) ??
65
+ flat.find((b) => b.callbackData?.startsWith("mdl:sr:"));
61
66
 
62
67
  if (!srButton) {
63
68
  console.log("No sr-* buttons in menu — agent not LiteLLM-enabled or no sr-* models registered. Skipping.");
@@ -86,8 +91,10 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
86
91
  expect((kbAfter ?? []).flat().length, "menu keeps buttons after sr-* tap").toBeGreaterThan(0);
87
92
 
88
93
  // ── 3. Send a quick message to generate a LiteLLM-routed turn ──
94
+ // Use 120s — fast models (gemini-flash, deepseek-v3) respond in <10s,
95
+ // but the request still has to go through the model switch inject + proxy.
89
96
  await sc.sendDM("Just reply with the word OK.");
90
- await sc.expectMessage(/ok/i, { from: "bot", timeout: 60_000 });
97
+ await sc.expectMessage(/ok/i, { from: "bot", timeout: 120_000 });
91
98
 
92
99
  // ── 4. LiteLLM spend attribution ────────────────────────────────
93
100
  if (spendBefore >= 0) {
@@ -97,14 +104,19 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
97
104
  expect(spendAfter, `agent:${AGENT} log_count increased after turn`).toBeGreaterThan(spendBefore);
98
105
  }
99
106
 
100
- // ── Restore: switch back to configured model ────────────────────
107
+ // ── Restore: switch back to a Claude subscription model ─────────
108
+ // Re-fetch keyboard so we have the latest state after sr-* switch.
109
+ // Look for any Claude model (mdl:s:) button — those are Max/Pro
110
+ // subscription models and don't trigger slow reasoning. If graceful
111
+ // restart is implemented, pressing a claude button fires a restart
112
+ // (not a deepseek/gemini inject), so no secondary LiteLLM calls.
101
113
  const currentKb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
102
- const restoreBtn = (currentKb ?? []).flat().find(
103
- (b) => b.callbackData?.startsWith("mdl:s:") && /Default|Sonnet|claude-sonnet/i.test(b.text),
104
- );
114
+ const restoreBtn = (currentKb ?? []).flat().find((b) => b.callbackData?.startsWith("mdl:s:"));
105
115
  if (restoreBtn?.callbackData) {
106
116
  await sc.driver.pressButton(sc.botUserId, menu.messageId, restoreBtn.callbackData);
107
- await new Promise((r) => setTimeout(r, 4_000));
117
+ // Wait for the restart to complete (graceful restart PR #2619) or for
118
+ // the in-place inject to propagate — 15s is sufficient for either path.
119
+ await new Promise((r) => setTimeout(r, 15_000));
108
120
  }
109
121
 
110
122
  console.log(`✅ sr-* model switch (${srName}) verified end-to-end through LiteLLM`);
@@ -112,7 +124,7 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
112
124
  await sc.tearDown();
113
125
  }
114
126
  },
115
- 180_000,
127
+ 210_000,
116
128
  );
117
129
 
118
130
  it(
@@ -121,9 +133,11 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
121
133
  const sc = await spinUp({ agent: AGENT });
122
134
  try {
123
135
  await sc.sendDM("/model");
136
+ // 60s — test 2 runs after test 1's restore restart, which takes ~15s.
137
+ // If the restart is still finishing when /model lands, it may be queued.
124
138
  const menu = await sc.expectMessage(/Default \(new sessions\):/i, {
125
139
  from: "bot",
126
- timeout: 30_000,
140
+ timeout: 60_000,
127
141
  });
128
142
  const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
129
143
  const flat = (kb ?? []).flat();
@@ -142,6 +156,6 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
142
156
  await sc.tearDown();
143
157
  }
144
158
  },
145
- 60_000,
159
+ 120_000,
146
160
  );
147
161
  });