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.
- package/dist/cli/switchroom.js +2 -2
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/telegram-plugin/dist/gateway/gateway.js +90 -9
- package/telegram-plugin/gateway/gateway.ts +92 -3
- package/telegram-plugin/gateway/model-command.ts +73 -0
- package/telegram-plugin/tests/model-command.test.ts +124 -1
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +24 -10
package/dist/cli/switchroom.js
CHANGED
|
@@ -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.
|
|
51678
|
-
var COMMIT_SHA = "
|
|
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.
|
|
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.
|
|
4
|
-
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw
|
|
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.
|
|
56019
|
-
var COMMIT_SHA = "
|
|
56020
|
-
var COMMIT_DATE = "2026-06-
|
|
56021
|
-
var LATEST_PR =
|
|
56022
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">"),
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
159
|
+
120_000,
|
|
146
160
|
);
|
|
147
161
|
});
|