switchroom 0.16.13 → 0.16.15
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 +224 -4
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +22 -7
- package/telegram-plugin/gateway/gateway.ts +24 -3
- package/telegram-plugin/gateway/model-command.ts +23 -2
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/tests/model-command.test.ts +7 -1
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +3 -3
package/dist/cli/switchroom.js
CHANGED
|
@@ -15589,6 +15589,10 @@ var init_helpers = __esm(() => {
|
|
|
15589
15589
|
function isKnownCheapModel(model) {
|
|
15590
15590
|
return model !== undefined && CHEAP_MODEL_RE.test(model);
|
|
15591
15591
|
}
|
|
15592
|
+
function isCheapCronEnabled(env2 = process.env) {
|
|
15593
|
+
const v = (env2.SWITCHROOM_CHEAP_CRON ?? "").toLowerCase();
|
|
15594
|
+
return !(v === "0" || v === "false" || v === "off");
|
|
15595
|
+
}
|
|
15592
15596
|
function resolveCronModel(model) {
|
|
15593
15597
|
return isKnownCheapModel(model) ? model : DEFAULT_CRON_MODEL;
|
|
15594
15598
|
}
|
|
@@ -50753,6 +50757,10 @@ function dispatchTool(name, args) {
|
|
|
50753
50757
|
cliArgs = buildArgs(["cron", "list"], args);
|
|
50754
50758
|
parseMode = "json";
|
|
50755
50759
|
break;
|
|
50760
|
+
case "cron_doctor":
|
|
50761
|
+
cliArgs = buildArgs(["cron", "doctor"], args);
|
|
50762
|
+
parseMode = "json";
|
|
50763
|
+
break;
|
|
50756
50764
|
case "skill_list":
|
|
50757
50765
|
cliArgs = buildArgs(["skill", "list"], args);
|
|
50758
50766
|
parseMode = "json";
|
|
@@ -50970,7 +50978,17 @@ var init_server3 = __esm(() => {
|
|
|
50970
50978
|
},
|
|
50971
50979
|
{
|
|
50972
50980
|
name: "cron_list",
|
|
50973
|
-
description: "List the agent's scheduled cron entries (schedule array) as JSON.",
|
|
50981
|
+
description: "List the agent's scheduled cron entries (schedule array) as JSON. " + "Each entry is self-describing: `source` (base-config vs overlay), " + "`file` (switchroom.yaml or the schedule.d path), resolved " + "`tier`/`context`, and a `duplicate_of` back-reference when another " + "entry shares the same cron expression. Original entry fields are " + "preserved.",
|
|
50982
|
+
inputSchema: {
|
|
50983
|
+
type: "object",
|
|
50984
|
+
properties: {
|
|
50985
|
+
agent: { type: "string" }
|
|
50986
|
+
}
|
|
50987
|
+
}
|
|
50988
|
+
},
|
|
50989
|
+
{
|
|
50990
|
+
name: "cron_doctor",
|
|
50991
|
+
description: "Read-only cron health report: duplicate cron expressions (the " + "double-fire hazard), base-config-vs-overlay name conflicts, and " + "entries missing a resolved tier/context. Returns " + "{agent, entry_count, healthy, findings[]}. Call this BEFORE adding " + "a cron, or when a schedule misbehaves (firing twice, removing the " + "wrong entry).",
|
|
50974
50992
|
inputSchema: {
|
|
50975
50993
|
type: "object",
|
|
50976
50994
|
properties: {
|
|
@@ -51674,8 +51692,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
51674
51692
|
import { dirname, join } from "node:path";
|
|
51675
51693
|
|
|
51676
51694
|
// src/build-info.ts
|
|
51677
|
-
var VERSION = "0.16.
|
|
51678
|
-
var COMMIT_SHA = "
|
|
51695
|
+
var VERSION = "0.16.15";
|
|
51696
|
+
var COMMIT_SHA = "a9c59169";
|
|
51679
51697
|
|
|
51680
51698
|
// src/cli/resolve-version.ts
|
|
51681
51699
|
function readPackageVersion() {
|
|
@@ -51828,6 +51846,138 @@ import {
|
|
|
51828
51846
|
appendFileSync,
|
|
51829
51847
|
readFileSync as readFileSync5
|
|
51830
51848
|
} from "node:fs";
|
|
51849
|
+
|
|
51850
|
+
// src/scheduler/cron-introspect.ts
|
|
51851
|
+
init_overlay_loader();
|
|
51852
|
+
init_cron_routing();
|
|
51853
|
+
function normalizeCronExpr(expr) {
|
|
51854
|
+
if (typeof expr !== "string")
|
|
51855
|
+
return null;
|
|
51856
|
+
const t = expr.trim();
|
|
51857
|
+
if (t === "")
|
|
51858
|
+
return null;
|
|
51859
|
+
return t.replace(/\s+/g, " ");
|
|
51860
|
+
}
|
|
51861
|
+
function attributeEntry(raw) {
|
|
51862
|
+
const entry = raw ?? {};
|
|
51863
|
+
const isOverlay = entry[OVERLAY_SOURCE] === true;
|
|
51864
|
+
const overlayTitle = entry[OVERLAY_TITLE];
|
|
51865
|
+
const explicitName = typeof entry.name === "string" && entry.name.trim() !== "" ? entry.name : null;
|
|
51866
|
+
if (isOverlay) {
|
|
51867
|
+
const title = typeof overlayTitle === "string" && overlayTitle.trim() !== "" ? overlayTitle : explicitName;
|
|
51868
|
+
const cron = typeof entry.cron === "string" ? entry.cron : "";
|
|
51869
|
+
const prompt = typeof entry.prompt === "string" ? entry.prompt : undefined;
|
|
51870
|
+
const file = `schedule.d/cron-${cronUnitHash(cron, prompt)}.yaml`;
|
|
51871
|
+
return { entry, source: "overlay", file, name: title };
|
|
51872
|
+
}
|
|
51873
|
+
return { entry, source: "base-config", file: "switchroom.yaml", name: explicitName };
|
|
51874
|
+
}
|
|
51875
|
+
function resolveTierContext(entry, cheapCronEnabled) {
|
|
51876
|
+
const routing = resolveCronRouting({ kind: entry.kind, model: entry.model, context: entry.context }, { cheapCronEnabled });
|
|
51877
|
+
const ctx = routing.tier === "poll" || routing.tier === "action" ? null : routing.session === "cron" ? "fresh" : "agent";
|
|
51878
|
+
return { tier: routing.tier, context: ctx };
|
|
51879
|
+
}
|
|
51880
|
+
function buildCronList(schedule, opts) {
|
|
51881
|
+
const raws = (schedule ?? []).map(attributeEntry);
|
|
51882
|
+
const byExpr = new Map;
|
|
51883
|
+
raws.forEach((r, i) => {
|
|
51884
|
+
const expr = normalizeCronExpr(r.entry.cron);
|
|
51885
|
+
if (expr === null)
|
|
51886
|
+
return;
|
|
51887
|
+
const arr = byExpr.get(expr);
|
|
51888
|
+
if (arr)
|
|
51889
|
+
arr.push(i);
|
|
51890
|
+
else
|
|
51891
|
+
byExpr.set(expr, [i]);
|
|
51892
|
+
});
|
|
51893
|
+
return raws.map((r, i) => {
|
|
51894
|
+
const { tier, context } = resolveTierContext(r.entry, opts.cheapCronEnabled);
|
|
51895
|
+
const expr = normalizeCronExpr(r.entry.cron);
|
|
51896
|
+
const out = {
|
|
51897
|
+
...r.entry,
|
|
51898
|
+
name: r.name,
|
|
51899
|
+
source: r.source,
|
|
51900
|
+
file: r.file,
|
|
51901
|
+
tier,
|
|
51902
|
+
context
|
|
51903
|
+
};
|
|
51904
|
+
if (expr !== null) {
|
|
51905
|
+
const siblings = (byExpr.get(expr) ?? []).filter((j) => j !== i);
|
|
51906
|
+
if (siblings.length > 0) {
|
|
51907
|
+
out.duplicate_of = siblings.map((j) => ({ name: raws[j].name, file: raws[j].file }));
|
|
51908
|
+
}
|
|
51909
|
+
}
|
|
51910
|
+
return out;
|
|
51911
|
+
});
|
|
51912
|
+
}
|
|
51913
|
+
function cronDoctor(agent, schedule, opts) {
|
|
51914
|
+
const raws = (schedule ?? []).map(attributeEntry);
|
|
51915
|
+
const findings = [];
|
|
51916
|
+
const byExpr = new Map;
|
|
51917
|
+
for (const r of raws) {
|
|
51918
|
+
const expr = normalizeCronExpr(r.entry.cron);
|
|
51919
|
+
if (expr === null)
|
|
51920
|
+
continue;
|
|
51921
|
+
const arr = byExpr.get(expr);
|
|
51922
|
+
if (arr)
|
|
51923
|
+
arr.push(r);
|
|
51924
|
+
else
|
|
51925
|
+
byExpr.set(expr, [r]);
|
|
51926
|
+
}
|
|
51927
|
+
for (const [expr, group] of byExpr) {
|
|
51928
|
+
if (group.length > 1) {
|
|
51929
|
+
findings.push({
|
|
51930
|
+
kind: "duplicate_cron",
|
|
51931
|
+
severity: "error",
|
|
51932
|
+
message: `${group.length} entries share cron expression "${expr}" \u2014 they will ` + `all fire together (double-fire). Remove the redundant one(s).`,
|
|
51933
|
+
cron: expr,
|
|
51934
|
+
entries: group.map((r) => ({ name: r.name, file: r.file }))
|
|
51935
|
+
});
|
|
51936
|
+
}
|
|
51937
|
+
}
|
|
51938
|
+
const byName = new Map;
|
|
51939
|
+
for (const r of raws) {
|
|
51940
|
+
if (r.name === null)
|
|
51941
|
+
continue;
|
|
51942
|
+
const arr = byName.get(r.name);
|
|
51943
|
+
if (arr)
|
|
51944
|
+
arr.push(r);
|
|
51945
|
+
else
|
|
51946
|
+
byName.set(r.name, [r]);
|
|
51947
|
+
}
|
|
51948
|
+
for (const [name, group] of byName) {
|
|
51949
|
+
const sources = new Set(group.map((r) => r.source));
|
|
51950
|
+
if (group.length > 1 && sources.size > 1) {
|
|
51951
|
+
findings.push({
|
|
51952
|
+
kind: "name_conflict",
|
|
51953
|
+
severity: "warn",
|
|
51954
|
+
message: `name "${name}" is used by both a base-config and an overlay entry \u2014 ` + `removing by name is ambiguous.`,
|
|
51955
|
+
conflicting_name: name,
|
|
51956
|
+
entries: group.map((r) => ({ name: r.name, file: r.file }))
|
|
51957
|
+
});
|
|
51958
|
+
}
|
|
51959
|
+
}
|
|
51960
|
+
for (const r of raws) {
|
|
51961
|
+
const expr = normalizeCronExpr(r.entry.cron);
|
|
51962
|
+
if (expr === null) {
|
|
51963
|
+
findings.push({
|
|
51964
|
+
kind: "missing_tier",
|
|
51965
|
+
severity: "warn",
|
|
51966
|
+
message: `entry ${r.name ? `"${r.name}"` : "(unnamed)"} has no valid cron ` + `expression, so its tier/context cannot be resolved.`,
|
|
51967
|
+
entries: [{ name: r.name, file: r.file }]
|
|
51968
|
+
});
|
|
51969
|
+
}
|
|
51970
|
+
}
|
|
51971
|
+
return {
|
|
51972
|
+
agent,
|
|
51973
|
+
entry_count: raws.length,
|
|
51974
|
+
findings,
|
|
51975
|
+
healthy: findings.length === 0
|
|
51976
|
+
};
|
|
51977
|
+
}
|
|
51978
|
+
|
|
51979
|
+
// src/cli/agent-config.ts
|
|
51980
|
+
init_cron_routing();
|
|
51831
51981
|
var AUDIT_ROOT = join3(homedir2(), ".switchroom", "audit");
|
|
51832
51982
|
function auditPathFor(agent) {
|
|
51833
51983
|
return join3(AUDIT_ROOT, agent, "agent-config.jsonl");
|
|
@@ -52068,7 +52218,10 @@ function registerAgentConfigCommands(program2) {
|
|
|
52068
52218
|
const cfg = getConfig(program2);
|
|
52069
52219
|
try {
|
|
52070
52220
|
const slice = getAgentSlice(cfg, agent);
|
|
52071
|
-
const
|
|
52221
|
+
const rows = buildCronList(slice.schedule ?? [], {
|
|
52222
|
+
cheapCronEnabled: isCheapCronEnabled()
|
|
52223
|
+
});
|
|
52224
|
+
const tasks = stripSecretValues(rows);
|
|
52072
52225
|
process.stdout.write(JSON.stringify(tasks) + `
|
|
52073
52226
|
`);
|
|
52074
52227
|
appendAudit(agent, "cron.list", { ...opts }, 0);
|
|
@@ -52079,6 +52232,32 @@ function registerAgentConfigCommands(program2) {
|
|
|
52079
52232
|
process.exit(1);
|
|
52080
52233
|
}
|
|
52081
52234
|
}));
|
|
52235
|
+
cron.command("doctor").description("Report cron health: duplicate cron expressions, base-vs-overlay " + "name conflicts, and entries missing a resolved tier/context. " + "Read-only.").option("--agent <name>", "Target agent (defaults to $SWITCHROOM_AGENT_NAME)").action(withConfigError(async (opts) => {
|
|
52236
|
+
let agent;
|
|
52237
|
+
try {
|
|
52238
|
+
agent = resolveTargetAgent(opts.agent);
|
|
52239
|
+
} catch (err) {
|
|
52240
|
+
process.stderr.write(`${err.message}
|
|
52241
|
+
`);
|
|
52242
|
+
appendAudit(opts.agent ?? "<unknown>", "cron.doctor", { ...opts }, 7);
|
|
52243
|
+
process.exit(7);
|
|
52244
|
+
}
|
|
52245
|
+
const cfg = getConfig(program2);
|
|
52246
|
+
try {
|
|
52247
|
+
const slice = getAgentSlice(cfg, agent);
|
|
52248
|
+
const report = cronDoctor(agent, slice.schedule ?? [], {
|
|
52249
|
+
cheapCronEnabled: isCheapCronEnabled()
|
|
52250
|
+
});
|
|
52251
|
+
process.stdout.write(JSON.stringify(report) + `
|
|
52252
|
+
`);
|
|
52253
|
+
appendAudit(agent, "cron.doctor", { ...opts }, 0);
|
|
52254
|
+
} catch (err) {
|
|
52255
|
+
process.stderr.write(`${err.message}
|
|
52256
|
+
`);
|
|
52257
|
+
appendAudit(agent, "cron.doctor", { ...opts }, 1);
|
|
52258
|
+
process.exit(1);
|
|
52259
|
+
}
|
|
52260
|
+
}));
|
|
52082
52261
|
const skill = program2.commands.find((c) => c.name() === "skill") ?? program2.command("skill").description("Read-only access to an agent's skill list");
|
|
52083
52262
|
skill.command("list").description("List the agent's configured skills as JSON").option("--agent <name>", "Target agent (defaults to $SWITCHROOM_AGENT_NAME)").action(withConfigError(async (opts) => {
|
|
52084
52263
|
let agent;
|
|
@@ -87819,6 +87998,7 @@ function exitCodeFor(code) {
|
|
|
87819
87998
|
switch (code) {
|
|
87820
87999
|
case "E_OVERLAY_SECRETS_REQUIRES_APPROVAL":
|
|
87821
88000
|
case "E_CRON_TOO_FREQUENT":
|
|
88001
|
+
case "E_CRON_DUPLICATE":
|
|
87822
88002
|
case "E_QUOTA_EXCEEDED":
|
|
87823
88003
|
case "E_WRITE_REQUIRES_RECREATE":
|
|
87824
88004
|
case "E_SLUG_COLLISION":
|
|
@@ -87834,6 +88014,24 @@ function exitCodeFor(code) {
|
|
|
87834
88014
|
return 10;
|
|
87835
88015
|
}
|
|
87836
88016
|
}
|
|
88017
|
+
function parseOverlayCronAndName(e) {
|
|
88018
|
+
let cron;
|
|
88019
|
+
let name;
|
|
88020
|
+
const headerMatch = e.raw.match(/^#[^\S\n]*name:[^\S\n]*(\S.*?)[^\S\n]*$/m);
|
|
88021
|
+
if (headerMatch)
|
|
88022
|
+
name = headerMatch[1];
|
|
88023
|
+
try {
|
|
88024
|
+
const doc = import_yaml21.parse(e.raw);
|
|
88025
|
+
const first = doc?.schedule?.[0];
|
|
88026
|
+
if (first) {
|
|
88027
|
+
if (typeof first.cron === "string")
|
|
88028
|
+
cron = first.cron;
|
|
88029
|
+
if (!name && typeof first.name === "string")
|
|
88030
|
+
name = first.name;
|
|
88031
|
+
}
|
|
88032
|
+
} catch {}
|
|
88033
|
+
return { cron, name };
|
|
88034
|
+
}
|
|
87837
88035
|
function scheduleAdd(opts) {
|
|
87838
88036
|
let agent;
|
|
87839
88037
|
try {
|
|
@@ -87903,6 +88101,28 @@ function scheduleAdd(opts) {
|
|
|
87903
88101
|
meta: { current: existing.length }
|
|
87904
88102
|
};
|
|
87905
88103
|
}
|
|
88104
|
+
const wantExpr = normalizeCronExpr(opts.cronExpr);
|
|
88105
|
+
for (const e of existing) {
|
|
88106
|
+
const parsed = parseOverlayCronAndName(e);
|
|
88107
|
+
if (opts.name && parsed.name && parsed.name === opts.name) {
|
|
88108
|
+
return {
|
|
88109
|
+
ok: false,
|
|
88110
|
+
code: "E_CRON_DUPLICATE",
|
|
88111
|
+
message: `an overlay cron named "${opts.name}" already exists ` + `(${e.slug}.yaml) \u2014 remove it first or choose a different name`,
|
|
88112
|
+
exit: 9,
|
|
88113
|
+
meta: { conflicting_slug: e.slug, conflicting_name: parsed.name }
|
|
88114
|
+
};
|
|
88115
|
+
}
|
|
88116
|
+
if (wantExpr && parsed.cron && normalizeCronExpr(parsed.cron) === wantExpr) {
|
|
88117
|
+
return {
|
|
88118
|
+
ok: false,
|
|
88119
|
+
code: "E_CRON_DUPLICATE",
|
|
88120
|
+
message: `an overlay cron with expression "${wantExpr}" already exists ` + `(${e.slug}.yaml${parsed.name ? `, name "${parsed.name}"` : ""}) \u2014 ` + `adding another would double-fire. Remove the existing one first.`,
|
|
88121
|
+
exit: 9,
|
|
88122
|
+
meta: { conflicting_slug: e.slug, conflicting_cron: wantExpr }
|
|
88123
|
+
};
|
|
88124
|
+
}
|
|
88125
|
+
}
|
|
87906
88126
|
const hash2 = cronUnitHash(opts.cronExpr, opts.prompt);
|
|
87907
88127
|
const slug = `cron-${hash2}`;
|
|
87908
88128
|
let priorContent = null;
|
|
@@ -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.15";
|
|
22591
22591
|
|
|
22592
22592
|
// src/cli/resolve-version.ts
|
|
22593
22593
|
function readPackageVersion() {
|
package/package.json
CHANGED
|
@@ -46272,7 +46272,7 @@ async function handleModelMenuCallback(data, deps) {
|
|
|
46272
46272
|
`).map((l) => l.trim()).find((l) => /set model|switched/i.test(l)) ?? `Switched to ${friendlyName} (session)`;
|
|
46273
46273
|
return {
|
|
46274
46274
|
answer: confirmation,
|
|
46275
|
-
reply: await
|
|
46275
|
+
reply: await menuWithBannerStatic(deps, `\u2705 ${deps.escapeHtml(confirmation)}`),
|
|
46276
46276
|
selectedModel: srName
|
|
46277
46277
|
};
|
|
46278
46278
|
}
|
|
@@ -46334,6 +46334,14 @@ async function menuWithBanner(deps, banner) {
|
|
|
46334
46334
|
...fresh.keyboard ? { keyboard: fresh.keyboard } : {}
|
|
46335
46335
|
};
|
|
46336
46336
|
}
|
|
46337
|
+
async function menuWithBannerStatic(deps, banner) {
|
|
46338
|
+
const v1 = await handleModelCommand({ kind: "show" }, deps);
|
|
46339
|
+
return {
|
|
46340
|
+
text: [banner, "", v1.text].join(`
|
|
46341
|
+
`),
|
|
46342
|
+
html: true
|
|
46343
|
+
};
|
|
46344
|
+
}
|
|
46337
46345
|
|
|
46338
46346
|
// ../src/agents/model-picker.ts
|
|
46339
46347
|
var HEADER_RE = /Select model/;
|
|
@@ -54753,6 +54761,7 @@ var ARG_SUMMARY_LINE_MAX = 180;
|
|
|
54753
54761
|
var MCP_TOOL_DESCRIPTIONS = {
|
|
54754
54762
|
"mcp__agent-config__config_get": "Read its own merged config",
|
|
54755
54763
|
"mcp__agent-config__cron_list": "List its own scheduled tasks",
|
|
54764
|
+
"mcp__agent-config__cron_doctor": "Health-check its own cron schedule",
|
|
54756
54765
|
"mcp__agent-config__skill_list": "List its own installed skills",
|
|
54757
54766
|
"mcp__agent-config__audit_tail": "Read its own recent tool-call audit log",
|
|
54758
54767
|
"mcp__agent-config__peers_list": "List the other agents on this instance",
|
|
@@ -56064,11 +56073,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
|
|
|
56064
56073
|
}
|
|
56065
56074
|
|
|
56066
56075
|
// ../src/build-info.ts
|
|
56067
|
-
var VERSION = "0.16.
|
|
56068
|
-
var COMMIT_SHA = "
|
|
56069
|
-
var COMMIT_DATE = "2026-06-
|
|
56070
|
-
var LATEST_PR =
|
|
56071
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
56076
|
+
var VERSION = "0.16.15";
|
|
56077
|
+
var COMMIT_SHA = "a9c59169";
|
|
56078
|
+
var COMMIT_DATE = "2026-06-28T22:48:22Z";
|
|
56079
|
+
var LATEST_PR = 2643;
|
|
56080
|
+
var COMMITS_AHEAD_OF_TAG = 0;
|
|
56072
56081
|
|
|
56073
56082
|
// gateway/boot-version.ts
|
|
56074
56083
|
function formatRelativeAgo(iso) {
|
|
@@ -68285,13 +68294,19 @@ bot.on("callback_query:data", async (ctx) => {
|
|
|
68285
68294
|
return;
|
|
68286
68295
|
}
|
|
68287
68296
|
await ctx.answerCallbackQuery({ text: "Switching\u2026" }).catch(() => {});
|
|
68297
|
+
let didInterimSrEdit = false;
|
|
68298
|
+
if (data.startsWith(MODEL_CALLBACK_SR)) {
|
|
68299
|
+
const srLabel = escapeHtmlForTg(srFriendlyLabel(data.slice(MODEL_CALLBACK_SR.length)));
|
|
68300
|
+
await ctx.editMessageText(`\u23F3 Switching session to <b>${srLabel}</b>\u2026`, { parse_mode: "HTML", reply_markup: { inline_keyboard: [] } }).catch(() => {});
|
|
68301
|
+
didInterimSrEdit = true;
|
|
68302
|
+
}
|
|
68288
68303
|
try {
|
|
68289
68304
|
const prevSessionModel = activeSessionModelOverride;
|
|
68290
68305
|
const outcome = await handleModelMenuCallback(data, modelDeps);
|
|
68291
68306
|
if (outcome.selectedModel) {
|
|
68292
68307
|
activeSessionModelOverride = outcome.selectedModel;
|
|
68293
68308
|
}
|
|
68294
|
-
if (outcome.toastOnly)
|
|
68309
|
+
if (outcome.toastOnly && !didInterimSrEdit)
|
|
68295
68310
|
return;
|
|
68296
68311
|
if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
|
|
68297
68312
|
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,
|
|
@@ -20849,6 +20851,23 @@ bot.on('callback_query:data', async ctx => {
|
|
|
20849
20851
|
return
|
|
20850
20852
|
}
|
|
20851
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
|
+
}
|
|
20852
20871
|
try {
|
|
20853
20872
|
const prevSessionModel = activeSessionModelOverride
|
|
20854
20873
|
const outcome = await handleModelMenuCallback(data, modelDeps)
|
|
@@ -20858,9 +20877,11 @@ bot.on('callback_query:data', async ctx => {
|
|
|
20858
20877
|
if (outcome.selectedModel) {
|
|
20859
20878
|
activeSessionModelOverride = outcome.selectedModel
|
|
20860
20879
|
}
|
|
20861
|
-
// toastOnly:
|
|
20862
|
-
//
|
|
20863
|
-
|
|
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
|
|
20864
20885
|
|
|
20865
20886
|
// sr-* → Claude transition via the model menu: trigger a graceful restart.
|
|
20866
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
|
|
|
@@ -560,7 +560,11 @@ export async function handleModelMenuCallback(
|
|
|
560
560
|
.find((l) => /set model|switched/i.test(l)) ?? `Switched to ${friendlyName} (session)`
|
|
561
561
|
return {
|
|
562
562
|
answer: confirmation,
|
|
563
|
-
|
|
563
|
+
// Use the static (no-discover) path — after a text-inject the picker
|
|
564
|
+
// is in flux and discover() reliably fails, producing a spurious
|
|
565
|
+
// "(picker unavailable)" line that reads as an error when the switch
|
|
566
|
+
// actually succeeded.
|
|
567
|
+
reply: await menuWithBannerStatic(deps, `✅ ${deps.escapeHtml(confirmation)}`),
|
|
564
568
|
selectedModel: srName,
|
|
565
569
|
}
|
|
566
570
|
}
|
|
@@ -683,3 +687,20 @@ async function menuWithBanner(
|
|
|
683
687
|
...(fresh.keyboard ? { keyboard: fresh.keyboard } : {}),
|
|
684
688
|
}
|
|
685
689
|
}
|
|
690
|
+
|
|
691
|
+
// Static variant — skips discover() entirely and uses the v1 text path.
|
|
692
|
+
// Use after a text-inject where the picker state is inherently uncertain:
|
|
693
|
+
// discover() reliably fails immediately post-inject, producing a spurious
|
|
694
|
+
// "(picker unavailable)" warning that reads as an error.
|
|
695
|
+
async function menuWithBannerStatic(
|
|
696
|
+
deps: ModelMenuDeps & ModelCommandDeps,
|
|
697
|
+
banner: string,
|
|
698
|
+
): Promise<ModelMenuReply> {
|
|
699
|
+
const v1 = await handleModelCommand({ kind: 'show' }, deps)
|
|
700
|
+
return {
|
|
701
|
+
text: [banner, '', v1.text].join('\n'),
|
|
702
|
+
html: true,
|
|
703
|
+
// No keyboard — picker state is unknown after a text-inject; operator
|
|
704
|
+
// can tap /model to get a fresh interactive menu.
|
|
705
|
+
}
|
|
706
|
+
}
|
|
@@ -46,6 +46,7 @@ const MCP_TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
|
46
46
|
// agent-config — every agent's self-service surface (#1163, #1215)
|
|
47
47
|
"mcp__agent-config__config_get": "Read its own merged config",
|
|
48
48
|
"mcp__agent-config__cron_list": "List its own scheduled tasks",
|
|
49
|
+
"mcp__agent-config__cron_doctor": "Health-check its own cron schedule",
|
|
49
50
|
"mcp__agent-config__skill_list": "List its own installed skills",
|
|
50
51
|
"mcp__agent-config__audit_tail": "Read its own recent tool-call audit log",
|
|
51
52
|
"mcp__agent-config__peers_list": "List the other agents on this instance",
|
|
@@ -713,7 +713,13 @@ describe("handleModelMenuCallback — sr-* selection", () => {
|
|
|
713
713
|
expect(calls.select).toHaveLength(0);
|
|
714
714
|
expect(out.answer).toContain("Set model to sonnet");
|
|
715
715
|
expect(out.selectedModel).toBe("sr-gemini-2.5-pro");
|
|
716
|
-
|
|
716
|
+
// No keyboard on success: static reply path skips discover() to avoid the
|
|
717
|
+
// spurious "(picker unavailable)" line that discover() reliably produces
|
|
718
|
+
// immediately after an inject. Operator taps /model for a fresh menu.
|
|
719
|
+
expect(out.reply.keyboard).toBeUndefined();
|
|
720
|
+
// Banner present and text doesn't contain picker-unavailable noise
|
|
721
|
+
expect(out.reply.text).toContain('✅');
|
|
722
|
+
expect(out.reply.text).not.toContain('picker unavailable');
|
|
717
723
|
});
|
|
718
724
|
|
|
719
725
|
it("sr-* tap while busy returns toast-only with no inject", async () => {
|
|
@@ -140,11 +140,11 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
|
|
|
140
140
|
const sc = await spinUp({ agent: AGENT });
|
|
141
141
|
try {
|
|
142
142
|
await sc.sendDM("/model");
|
|
143
|
-
//
|
|
144
|
-
//
|
|
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.
|
|
145
145
|
const menu = await sc.expectMessage(/Default \(new sessions\):/i, {
|
|
146
146
|
from: "bot",
|
|
147
|
-
timeout:
|
|
147
|
+
timeout: 90_000,
|
|
148
148
|
});
|
|
149
149
|
const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
|
|
150
150
|
const flat = (kb ?? []).flat();
|