switchroom 0.16.12 → 0.16.14
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 +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +18 -8
- package/telegram-plugin/gateway/gateway.ts +43 -8
- package/telegram-plugin/gateway/model-command.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +15 -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.14";
|
|
51678
|
+
var COMMIT_SHA = "6daa5e37";
|
|
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.14";
|
|
22591
22591
|
|
|
22592
22592
|
// src/cli/resolve-version.ts
|
|
22593
22593
|
function readPackageVersion() {
|
package/package.json
CHANGED
|
@@ -56064,10 +56064,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
|
|
|
56064
56064
|
}
|
|
56065
56065
|
|
|
56066
56066
|
// ../src/build-info.ts
|
|
56067
|
-
var VERSION = "0.16.
|
|
56068
|
-
var COMMIT_SHA = "
|
|
56069
|
-
var COMMIT_DATE = "2026-06-
|
|
56070
|
-
var LATEST_PR =
|
|
56067
|
+
var VERSION = "0.16.14";
|
|
56068
|
+
var COMMIT_SHA = "6daa5e37";
|
|
56069
|
+
var COMMIT_DATE = "2026-06-28T19:14:51Z";
|
|
56070
|
+
var LATEST_PR = 2639;
|
|
56071
56071
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
56072
56072
|
|
|
56073
56073
|
// gateway/boot-version.ts
|
|
@@ -58112,9 +58112,11 @@ var progressUpdateLastSent = new Map;
|
|
|
58112
58112
|
var progressUpdateTurnCount = new Map;
|
|
58113
58113
|
var currentTurn = null;
|
|
58114
58114
|
var currentTurnMap = new CurrentTurnMap;
|
|
58115
|
+
var lastActiveTurnChatId;
|
|
58115
58116
|
function setCurrentTurn(turn, key) {
|
|
58116
58117
|
currentTurnMap.set(turn, key);
|
|
58117
58118
|
currentTurn = currentTurnMap.get();
|
|
58119
|
+
lastActiveTurnChatId = turn.sessionChatId;
|
|
58118
58120
|
}
|
|
58119
58121
|
function endCurrentTurnForKey(turn, key) {
|
|
58120
58122
|
const ended = currentTurnMap.endTurnForKey(turn, key);
|
|
@@ -60803,10 +60805,12 @@ async function executeReply(args) {
|
|
|
60803
60805
|
const _a = loadAccess();
|
|
60804
60806
|
if (_a.allowFrom.includes(_rawChatId) || _rawChatId in _a.groups)
|
|
60805
60807
|
return _rawChatId;
|
|
60806
|
-
|
|
60807
|
-
|
|
60808
|
+
const fallbackChatId = turn?.sessionChatId ?? lastActiveTurnChatId;
|
|
60809
|
+
if (fallbackChatId && (_a.allowFrom.includes(fallbackChatId) || (fallbackChatId in _a.groups))) {
|
|
60810
|
+
const tier = turn == null ? "last-known" : "active";
|
|
60811
|
+
process.stderr.write(`telegram gateway: reply: model passed chat_id "${_rawChatId}" (not allowlisted) \u2014 ` + `routing to ${tier} turn chat "${fallbackChatId}"
|
|
60808
60812
|
`);
|
|
60809
|
-
return
|
|
60813
|
+
return fallbackChatId;
|
|
60810
60814
|
}
|
|
60811
60815
|
return _rawChatId;
|
|
60812
60816
|
})();
|
|
@@ -68281,13 +68285,19 @@ bot.on("callback_query:data", async (ctx) => {
|
|
|
68281
68285
|
return;
|
|
68282
68286
|
}
|
|
68283
68287
|
await ctx.answerCallbackQuery({ text: "Switching\u2026" }).catch(() => {});
|
|
68288
|
+
let didInterimSrEdit = false;
|
|
68289
|
+
if (data.startsWith(MODEL_CALLBACK_SR)) {
|
|
68290
|
+
const srLabel = escapeHtmlForTg(srFriendlyLabel(data.slice(MODEL_CALLBACK_SR.length)));
|
|
68291
|
+
await ctx.editMessageText(`\u23F3 Switching session to <b>${srLabel}</b>\u2026`, { parse_mode: "HTML", reply_markup: { inline_keyboard: [] } }).catch(() => {});
|
|
68292
|
+
didInterimSrEdit = true;
|
|
68293
|
+
}
|
|
68284
68294
|
try {
|
|
68285
68295
|
const prevSessionModel = activeSessionModelOverride;
|
|
68286
68296
|
const outcome = await handleModelMenuCallback(data, modelDeps);
|
|
68287
68297
|
if (outcome.selectedModel) {
|
|
68288
68298
|
activeSessionModelOverride = outcome.selectedModel;
|
|
68289
68299
|
}
|
|
68290
|
-
if (outcome.toastOnly)
|
|
68300
|
+
if (outcome.toastOnly && !didInterimSrEdit)
|
|
68291
68301
|
return;
|
|
68292
68302
|
if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
|
|
68293
68303
|
const agentName3 = getMyAgentName();
|
|
@@ -278,6 +278,8 @@ import {
|
|
|
278
278
|
isSrToClaudeTransition,
|
|
279
279
|
MODEL_CALLBACK_PREFIX,
|
|
280
280
|
MODEL_CALLBACK_HEADER,
|
|
281
|
+
MODEL_CALLBACK_SR,
|
|
282
|
+
srFriendlyLabel,
|
|
281
283
|
type ModelMenuDeps,
|
|
282
284
|
type ModelCommandDeps,
|
|
283
285
|
type ModelMenuReply,
|
|
@@ -2216,6 +2218,13 @@ type CurrentTurn = {
|
|
|
2216
2218
|
// is never written and this is exactly the old singleton.
|
|
2217
2219
|
let currentTurn: CurrentTurn | null = null
|
|
2218
2220
|
const currentTurnMap = new CurrentTurnMap<CurrentTurn>()
|
|
2221
|
+
// Captures the most-recently-started turn's sessionChatId. Unlike currentTurn,
|
|
2222
|
+
// this is NOT cleared by the silence poke (firePoke/clearTurnStarted). It lets
|
|
2223
|
+
// the Bug B fallback in executeReply route to the correct chat even when the
|
|
2224
|
+
// model calls reply AFTER the silence poke has nulled currentTurn (Bug D).
|
|
2225
|
+
// Safe in single-tenant gateways: the fallback only fires when the raw chat_id
|
|
2226
|
+
// fails the allowlist, and this value was itself validated at inbound receipt.
|
|
2227
|
+
let lastActiveTurnChatId: string | undefined
|
|
2219
2228
|
|
|
2220
2229
|
/**
|
|
2221
2230
|
* Set the live turn for `key`. Flag-branches in ONE place (inside the map):
|
|
@@ -2227,6 +2236,7 @@ const currentTurnMap = new CurrentTurnMap<CurrentTurn>()
|
|
|
2227
2236
|
function setCurrentTurn(turn: CurrentTurn, key: string): void {
|
|
2228
2237
|
currentTurnMap.set(turn, key)
|
|
2229
2238
|
currentTurn = currentTurnMap.get() // mirror most-recent-set (== `turn`)
|
|
2239
|
+
lastActiveTurnChatId = turn.sessionChatId
|
|
2230
2240
|
}
|
|
2231
2241
|
|
|
2232
2242
|
/**
|
|
@@ -7977,17 +7987,23 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
7977
7987
|
// Non-Claude models (e.g. Gemini via LiteLLM sr-* routing) sometimes pass a
|
|
7978
7988
|
// chat_id that is not in the allowlist — either an int/string mismatch, or
|
|
7979
7989
|
// the model echoing the wrong identifier from context. When the raw value
|
|
7980
|
-
// fails the allowlist check
|
|
7981
|
-
//
|
|
7990
|
+
// fails the allowlist check, fall back to the active (or last-known) turn's
|
|
7991
|
+
// validated sessionChatId so the reply still lands correctly.
|
|
7992
|
+
// Tier 1: live turn (currentTurn was non-null at reply entry).
|
|
7993
|
+
// Tier 2: last-known turn (survives silence poke — Bug D fix; currentTurn is
|
|
7994
|
+
// null because clearTurnStarted fired ≥5 min of model silence, but the model
|
|
7995
|
+
// finally called reply after the poke).
|
|
7982
7996
|
const chat_id = (() => {
|
|
7983
7997
|
const _a = loadAccess()
|
|
7984
7998
|
if (_a.allowFrom.includes(_rawChatId) || _rawChatId in _a.groups) return _rawChatId
|
|
7985
|
-
|
|
7999
|
+
const fallbackChatId = turn?.sessionChatId ?? lastActiveTurnChatId
|
|
8000
|
+
if (fallbackChatId && (_a.allowFrom.includes(fallbackChatId) || fallbackChatId in _a.groups)) {
|
|
8001
|
+
const tier = turn == null ? 'last-known' : 'active'
|
|
7986
8002
|
process.stderr.write(
|
|
7987
8003
|
`telegram gateway: reply: model passed chat_id "${_rawChatId}" (not allowlisted) — ` +
|
|
7988
|
-
`routing to
|
|
8004
|
+
`routing to ${tier} turn chat "${fallbackChatId}"\n`,
|
|
7989
8005
|
)
|
|
7990
|
-
return
|
|
8006
|
+
return fallbackChatId
|
|
7991
8007
|
}
|
|
7992
8008
|
return _rawChatId // let assertAllowedChat below throw the human-readable error
|
|
7993
8009
|
})()
|
|
@@ -20835,6 +20851,23 @@ bot.on('callback_query:data', async ctx => {
|
|
|
20835
20851
|
return
|
|
20836
20852
|
}
|
|
20837
20853
|
await ctx.answerCallbackQuery({ text: 'Switching…' }).catch(() => {})
|
|
20854
|
+
// sr-* inject waits for claude to respond (can take 10-30s). Edit the
|
|
20855
|
+
// menu immediately to show a "working on it" state so the operator isn't
|
|
20856
|
+
// left looking at a stale menu with no feedback. The final edit (✅/❌)
|
|
20857
|
+
// replaces this once the inject returns.
|
|
20858
|
+
// NOTE: this await yields the event loop, so a new inbound turn could
|
|
20859
|
+
// start between here and handleModelMenuCallback's inner isBusy() check.
|
|
20860
|
+
// We track whether we applied the interim edit so we can skip the
|
|
20861
|
+
// toastOnly short-circuit if we did — a toastOnly return after the interim
|
|
20862
|
+
// edit would leave the menu stuck button-less.
|
|
20863
|
+
let didInterimSrEdit = false
|
|
20864
|
+
if (data.startsWith(MODEL_CALLBACK_SR)) {
|
|
20865
|
+
const srLabel = escapeHtmlForTg(srFriendlyLabel(data.slice(MODEL_CALLBACK_SR.length)))
|
|
20866
|
+
await ctx
|
|
20867
|
+
.editMessageText(`⏳ Switching session to <b>${srLabel}</b>…`, { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } })
|
|
20868
|
+
.catch(() => {})
|
|
20869
|
+
didInterimSrEdit = true
|
|
20870
|
+
}
|
|
20838
20871
|
try {
|
|
20839
20872
|
const prevSessionModel = activeSessionModelOverride
|
|
20840
20873
|
const outcome = await handleModelMenuCallback(data, modelDeps)
|
|
@@ -20844,9 +20877,11 @@ bot.on('callback_query:data', async ctx => {
|
|
|
20844
20877
|
if (outcome.selectedModel) {
|
|
20845
20878
|
activeSessionModelOverride = outcome.selectedModel
|
|
20846
20879
|
}
|
|
20847
|
-
// toastOnly:
|
|
20848
|
-
//
|
|
20849
|
-
|
|
20880
|
+
// toastOnly: leave the menu untouched — but only if we haven't already
|
|
20881
|
+
// cleared its buttons with the interim sr-* edit. If we have, fall
|
|
20882
|
+
// through to the final edit so the message is recovered (busyReply or
|
|
20883
|
+
// the full menu) rather than left permanently button-less.
|
|
20884
|
+
if (outcome.toastOnly && !didInterimSrEdit) return
|
|
20850
20885
|
|
|
20851
20886
|
// sr-* → Claude transition via the model menu: trigger a graceful restart.
|
|
20852
20887
|
// Switching FROM an sr-* (LiteLLM/OpenRouter) model BACK to a Claude model
|
|
@@ -345,7 +345,7 @@ export function expandSrAlias(arg: string): string {
|
|
|
345
345
|
return SR_MODEL_ALIASES[arg.toLowerCase()] ?? arg
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
-
function srFriendlyLabel(srName: string): string {
|
|
348
|
+
export function srFriendlyLabel(srName: string): string {
|
|
349
349
|
return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
|
|
350
350
|
}
|
|
351
351
|
|
|
@@ -59,10 +59,13 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
|
|
|
59
59
|
const openrouterHeader = flat.find(
|
|
60
60
|
(b) => b.text.includes("OpenRouter") && b.callbackData === "mdl:h",
|
|
61
61
|
);
|
|
62
|
-
// Prefer
|
|
63
|
-
//
|
|
62
|
+
// Prefer deepseek-v3 (non-thinking, consistently fast) for the E2E test.
|
|
63
|
+
// gemini-2.5-flash may run in thinking mode via OpenRouter (5+ min latency),
|
|
64
|
+
// reasoning models (deepseek-r1, o1, o3) also take 2-5 min and hit the
|
|
65
|
+
// silence poke — both are OK now (Bug D fallback), but slow tests are painful.
|
|
64
66
|
const srButton =
|
|
65
|
-
flat.find((b) => b.callbackData?.startsWith("mdl:sr:") && /
|
|
67
|
+
flat.find((b) => b.callbackData?.startsWith("mdl:sr:") && /deepseek-v3/.test(b.callbackData)) ??
|
|
68
|
+
flat.find((b) => b.callbackData?.startsWith("mdl:sr:") && /flash/.test(b.callbackData) && !/thinking/.test(b.callbackData)) ??
|
|
66
69
|
flat.find((b) => b.callbackData?.startsWith("mdl:sr:") && !/r1|o1|o3|thinking/.test(b.callbackData)) ??
|
|
67
70
|
flat.find((b) => b.callbackData?.startsWith("mdl:sr:"));
|
|
68
71
|
|
|
@@ -93,10 +96,12 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
|
|
|
93
96
|
expect((kbAfter ?? []).flat().length, "menu keeps buttons after sr-* tap").toBeGreaterThan(0);
|
|
94
97
|
|
|
95
98
|
// ── 3. Send a quick message to generate a LiteLLM-routed turn ──
|
|
96
|
-
//
|
|
97
|
-
//
|
|
99
|
+
// 480s budget: fast models (deepseek-v3) reply in <10s; Gemini 2.5 Flash
|
|
100
|
+
// may run thinking mode via OpenRouter and take 5+ min. The silence poke
|
|
101
|
+
// fires at 300s, which is OK — the Bug D fallback (lastActiveTurnChatId)
|
|
102
|
+
// routes the reply even when currentTurn was nulled by the poke.
|
|
98
103
|
await sc.sendDM("Just reply with the word OK.");
|
|
99
|
-
await sc.expectMessage(/ok/i, { from: "bot", timeout:
|
|
104
|
+
await sc.expectMessage(/ok/i, { from: "bot", timeout: 480_000 });
|
|
100
105
|
|
|
101
106
|
// ── 4. LiteLLM spend attribution ────────────────────────────────
|
|
102
107
|
if (spendBefore >= 0) {
|
|
@@ -126,7 +131,7 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
|
|
|
126
131
|
await sc.tearDown();
|
|
127
132
|
}
|
|
128
133
|
},
|
|
129
|
-
|
|
134
|
+
660_000,
|
|
130
135
|
);
|
|
131
136
|
|
|
132
137
|
it(
|
|
@@ -135,11 +140,11 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
|
|
|
135
140
|
const sc = await spinUp({ agent: AGENT });
|
|
136
141
|
try {
|
|
137
142
|
await sc.sendDM("/model");
|
|
138
|
-
//
|
|
139
|
-
//
|
|
143
|
+
// 90s — test 2 runs after test 1's restore restart. The model-switch
|
|
144
|
+
// restart can take 30–60s to fully boot; 90s gives comfortable margin.
|
|
140
145
|
const menu = await sc.expectMessage(/Default \(new sessions\):/i, {
|
|
141
146
|
from: "bot",
|
|
142
|
-
timeout:
|
|
147
|
+
timeout: 90_000,
|
|
143
148
|
});
|
|
144
149
|
const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
|
|
145
150
|
const flat = (kb ?? []).flat();
|