switchroom 0.15.18 → 0.15.19
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 +4 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +221 -5
- package/telegram-plugin/gateway/effort-command.ts +272 -0
- package/telegram-plugin/gateway/gateway.ts +81 -0
- package/telegram-plugin/tests/effort-command.test.ts +191 -0
- package/telegram-plugin/welcome-text.ts +5 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -25739,6 +25739,7 @@ var init_inject = __esm(() => {
|
|
|
25739
25739
|
["/hooks", { description: "List configured hooks", expectsOutput: true }],
|
|
25740
25740
|
["/memory", { description: "Open memory picker", expectsOutput: true }],
|
|
25741
25741
|
["/model", { description: "Open model picker", expectsOutput: true }],
|
|
25742
|
+
["/effort", { description: "Set reasoning effort", expectsOutput: true }],
|
|
25742
25743
|
[
|
|
25743
25744
|
"/clear",
|
|
25744
25745
|
{ description: "Clear session screen", expectsOutput: false }
|
|
@@ -50459,8 +50460,8 @@ var {
|
|
|
50459
50460
|
} = import__.default;
|
|
50460
50461
|
|
|
50461
50462
|
// src/build-info.ts
|
|
50462
|
-
var VERSION = "0.15.
|
|
50463
|
-
var COMMIT_SHA = "
|
|
50463
|
+
var VERSION = "0.15.19";
|
|
50464
|
+
var COMMIT_SHA = "3f40c6f9";
|
|
50464
50465
|
|
|
50465
50466
|
// src/cli/agent.ts
|
|
50466
50467
|
init_source();
|
|
@@ -57061,6 +57062,7 @@ function registerAgentCommand(program3) {
|
|
|
57061
57062
|
status: status?.active ?? "unknown",
|
|
57062
57063
|
uptime: formatUptime2(status?.uptime ?? null),
|
|
57063
57064
|
model: resolved.model ?? SWITCHROOM_DEFAULT_MAIN_MODEL,
|
|
57065
|
+
thinking_effort: resolved.thinking_effort ?? SWITCHROOM_DEFAULT_THINKING_EFFORT,
|
|
57064
57066
|
extends: agentConfig.extends ?? "default",
|
|
57065
57067
|
topic_name: agentConfig.topic_name,
|
|
57066
57068
|
topic_emoji: agentConfig.topic_emoji,
|
package/package.json
CHANGED
|
@@ -43144,6 +43144,7 @@ var TELEGRAM_MENU_COMMANDS = [
|
|
|
43144
43144
|
{ command: "logs", description: "Show recent agent logs" },
|
|
43145
43145
|
{ command: "inject", description: "Inject a Claude Code slash command (e.g. /cost)" },
|
|
43146
43146
|
{ command: "model", description: "Show or switch the Claude model" },
|
|
43147
|
+
{ command: "effort", description: "Show or switch the reasoning effort" },
|
|
43147
43148
|
{ command: "doctor", description: "Health check (deps, services, MCP)" },
|
|
43148
43149
|
{ command: "usage", description: "Pro/Max plan quota (5h + 7d windows)" },
|
|
43149
43150
|
{ command: "vault", description: "Manage vault secrets + capability grants" },
|
|
@@ -43185,6 +43186,7 @@ function switchroomHelpText(agentName3) {
|
|
|
43185
43186
|
`<code>/auth rm [agent] <slot> [--force]</code> \u2014 remove a slot`,
|
|
43186
43187
|
`<code>/model</code> \u2014 show the configured Claude model`,
|
|
43187
43188
|
`<code>/model <name></code> \u2014 switch the live session's model (opus \u00b7 sonnet \u00b7 haiku or a full id; until restart)`,
|
|
43189
|
+
`<code>/effort</code> \u2014 show or switch reasoning effort (low \u00b7 medium \u00b7 high \u00b7 xhigh \u00b7 max; until restart)`,
|
|
43188
43190
|
`<code>/topics</code> \u2014 topic-to-agent mappings`,
|
|
43189
43191
|
`<code>/permissions [agent]</code> \u2014 show agent permissions`,
|
|
43190
43192
|
`<code>/grant <tool></code> \u2014 grant a tool permission`,
|
|
@@ -44527,6 +44529,7 @@ var INJECT_COMMANDS = new Map([
|
|
|
44527
44529
|
["/hooks", { description: "List configured hooks", expectsOutput: true }],
|
|
44528
44530
|
["/memory", { description: "Open memory picker", expectsOutput: true }],
|
|
44529
44531
|
["/model", { description: "Open model picker", expectsOutput: true }],
|
|
44532
|
+
["/effort", { description: "Set reasoning effort", expectsOutput: true }],
|
|
44530
44533
|
[
|
|
44531
44534
|
"/clear",
|
|
44532
44535
|
{ description: "Clear session screen", expectsOutput: false }
|
|
@@ -44784,6 +44787,7 @@ var INJECT_COMMANDS2 = new Map([
|
|
|
44784
44787
|
["/hooks", { description: "List configured hooks", expectsOutput: true }],
|
|
44785
44788
|
["/memory", { description: "Open memory picker", expectsOutput: true }],
|
|
44786
44789
|
["/model", { description: "Open model picker", expectsOutput: true }],
|
|
44790
|
+
["/effort", { description: "Set reasoning effort", expectsOutput: true }],
|
|
44787
44791
|
[
|
|
44788
44792
|
"/clear",
|
|
44789
44793
|
{ description: "Clear session screen", expectsOutput: false }
|
|
@@ -45392,6 +45396,161 @@ function extractConfirmation(pane) {
|
|
|
45392
45396
|
return null;
|
|
45393
45397
|
}
|
|
45394
45398
|
|
|
45399
|
+
// gateway/effort-command.ts
|
|
45400
|
+
var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
45401
|
+
function isValidEffortArg(arg) {
|
|
45402
|
+
return EFFORT_LEVELS.includes(arg.toLowerCase());
|
|
45403
|
+
}
|
|
45404
|
+
function parseEffortCommand(text) {
|
|
45405
|
+
const m = text.match(/^\/effort(?:@[A-Za-z0-9_]+)?(?:\s+([\s\S]*))?$/);
|
|
45406
|
+
if (!m)
|
|
45407
|
+
return null;
|
|
45408
|
+
const rest = (m[1] ?? "").trim();
|
|
45409
|
+
if (rest.length === 0)
|
|
45410
|
+
return { kind: "show" };
|
|
45411
|
+
const parts = rest.split(/\s+/);
|
|
45412
|
+
if (parts.length > 1) {
|
|
45413
|
+
return { kind: "help", reason: "effort takes a single level" };
|
|
45414
|
+
}
|
|
45415
|
+
const arg = parts[0];
|
|
45416
|
+
if (arg.toLowerCase() === "help")
|
|
45417
|
+
return { kind: "help" };
|
|
45418
|
+
if (!isValidEffortArg(arg)) {
|
|
45419
|
+
return { kind: "help", reason: `not a valid effort level: ${arg}` };
|
|
45420
|
+
}
|
|
45421
|
+
return { kind: "set", level: arg.toLowerCase() };
|
|
45422
|
+
}
|
|
45423
|
+
var PERSIST_NOTE2 = "<i>Session-only \u2014 reverts to the configured default on restart. To change the default, set <code>thinking_effort:</code> in switchroom.yaml and restart.</i>";
|
|
45424
|
+
var LEVELS_INLINE = EFFORT_LEVELS.map((l) => `<code>${l}</code>`).join(" \u00b7 ");
|
|
45425
|
+
function helpText3(deps, reason) {
|
|
45426
|
+
const lines = [];
|
|
45427
|
+
if (reason)
|
|
45428
|
+
lines.push(`\u26a0\ufe0f ${deps.escapeHtml(reason)}`);
|
|
45429
|
+
lines.push("<b>/effort</b> \u2014 show or switch the reasoning effort (faster\u2192smarter)", "<code>/effort</code> \u2014 show the configured effort + a tap menu", `<code>/effort <level></code> \u2014 switch the live session (${LEVELS_INLINE})`, PERSIST_NOTE2);
|
|
45430
|
+
return { text: lines.join(`
|
|
45431
|
+
`), html: true };
|
|
45432
|
+
}
|
|
45433
|
+
async function handleEffortCommand(parsed, deps) {
|
|
45434
|
+
if (parsed.kind === "help")
|
|
45435
|
+
return helpText3(deps, parsed.reason);
|
|
45436
|
+
if (parsed.kind === "show") {
|
|
45437
|
+
const configured = deps.getConfiguredEffort();
|
|
45438
|
+
const shown = configured && configured.length > 0 ? configured : "low";
|
|
45439
|
+
return {
|
|
45440
|
+
text: [
|
|
45441
|
+
`<b>Effort \u2014 ${deps.escapeHtml(deps.getAgentName())}</b>`,
|
|
45442
|
+
`Configured default: <code>${deps.escapeHtml(shown)}</code>`,
|
|
45443
|
+
`Switch the live session: ${EFFORT_LEVELS.map((l) => `<code>/effort ${l}</code>`).join(" \u00b7 ")}`,
|
|
45444
|
+
PERSIST_NOTE2
|
|
45445
|
+
].join(`
|
|
45446
|
+
`),
|
|
45447
|
+
html: true
|
|
45448
|
+
};
|
|
45449
|
+
}
|
|
45450
|
+
if (!isValidEffortArg(parsed.level)) {
|
|
45451
|
+
return helpText3(deps, `not a valid effort level: ${parsed.level}`);
|
|
45452
|
+
}
|
|
45453
|
+
const verbHtml = `<code>/effort ${deps.escapeHtml(parsed.level)}</code>`;
|
|
45454
|
+
let result;
|
|
45455
|
+
try {
|
|
45456
|
+
result = await deps.inject(deps.getAgentName(), `/effort ${parsed.level}`);
|
|
45457
|
+
} catch (err) {
|
|
45458
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
45459
|
+
return { text: `\u274c ${verbHtml} \u2014 inject failed: ${deps.escapeHtml(msg)}`, html: true };
|
|
45460
|
+
}
|
|
45461
|
+
if (result.outcome === "ok") {
|
|
45462
|
+
return {
|
|
45463
|
+
text: [
|
|
45464
|
+
`${verbHtml}`,
|
|
45465
|
+
deps.preBlock(result.output),
|
|
45466
|
+
...result.truncated ? ["<i>truncated</i>"] : [],
|
|
45467
|
+
PERSIST_NOTE2
|
|
45468
|
+
].join(`
|
|
45469
|
+
`),
|
|
45470
|
+
html: true
|
|
45471
|
+
};
|
|
45472
|
+
}
|
|
45473
|
+
if (result.outcome === "ok_no_output") {
|
|
45474
|
+
return {
|
|
45475
|
+
text: [
|
|
45476
|
+
`${verbHtml} \u2014 sent, but no response captured. The agent may be mid-turn; check <code>/inject /status</code> to confirm the active effort.`,
|
|
45477
|
+
PERSIST_NOTE2
|
|
45478
|
+
].join(`
|
|
45479
|
+
`),
|
|
45480
|
+
html: true
|
|
45481
|
+
};
|
|
45482
|
+
}
|
|
45483
|
+
if (result.errorCode === "session_missing") {
|
|
45484
|
+
return {
|
|
45485
|
+
text: "\u274c tmux session not found \u2014 the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.",
|
|
45486
|
+
html: true
|
|
45487
|
+
};
|
|
45488
|
+
}
|
|
45489
|
+
return {
|
|
45490
|
+
text: `\u274c ${verbHtml} \u2014 ${deps.escapeHtml(result.errorMessage ?? "inject failed")}`,
|
|
45491
|
+
html: true
|
|
45492
|
+
};
|
|
45493
|
+
}
|
|
45494
|
+
var EFFORT_CALLBACK_PREFIX = "eff:";
|
|
45495
|
+
var EFFORT_CALLBACK_SELECT = "eff:s:";
|
|
45496
|
+
function effortSelectCallbackData(level) {
|
|
45497
|
+
return `${EFFORT_CALLBACK_SELECT}${level}`;
|
|
45498
|
+
}
|
|
45499
|
+
function menuKeyboard2(highlight) {
|
|
45500
|
+
return [
|
|
45501
|
+
EFFORT_LEVELS.map((l) => ({
|
|
45502
|
+
text: l === highlight ? `\u2705 ${l}` : l,
|
|
45503
|
+
callback_data: effortSelectCallbackData(l)
|
|
45504
|
+
}))
|
|
45505
|
+
];
|
|
45506
|
+
}
|
|
45507
|
+
function buildEffortMenu(deps, highlight) {
|
|
45508
|
+
const configured = deps.getConfiguredEffort() || "low";
|
|
45509
|
+
const live = highlight ?? configured;
|
|
45510
|
+
return {
|
|
45511
|
+
text: [
|
|
45512
|
+
`<b>Effort \u2014 ${deps.escapeHtml(deps.getAgentName())}</b>`,
|
|
45513
|
+
`Default: <code>${deps.escapeHtml(configured)}</code> \u00b7 faster \u2192 smarter: ${LEVELS_INLINE}`,
|
|
45514
|
+
"Tap to switch the live session:",
|
|
45515
|
+
PERSIST_NOTE2
|
|
45516
|
+
].join(`
|
|
45517
|
+
`),
|
|
45518
|
+
html: true,
|
|
45519
|
+
keyboard: menuKeyboard2(live)
|
|
45520
|
+
};
|
|
45521
|
+
}
|
|
45522
|
+
async function handleEffortMenuCallback(data, deps) {
|
|
45523
|
+
if (!data.startsWith(EFFORT_CALLBACK_SELECT)) {
|
|
45524
|
+
return { reply: buildEffortMenu(deps) };
|
|
45525
|
+
}
|
|
45526
|
+
const level = data.slice(EFFORT_CALLBACK_SELECT.length);
|
|
45527
|
+
if (!isValidEffortArg(level)) {
|
|
45528
|
+
return { reply: buildEffortMenu(deps) };
|
|
45529
|
+
}
|
|
45530
|
+
let banner;
|
|
45531
|
+
let selected;
|
|
45532
|
+
try {
|
|
45533
|
+
const result = await deps.inject(deps.getAgentName(), `/effort ${level}`);
|
|
45534
|
+
if (result.outcome === "ok" || result.outcome === "ok_no_output") {
|
|
45535
|
+
banner = `\u2705 Effort \u2192 <code>${deps.escapeHtml(level)}</code> for this session`;
|
|
45536
|
+
selected = level;
|
|
45537
|
+
} else if (result.errorCode === "session_missing") {
|
|
45538
|
+
banner = "\u274c tmux session not found \u2014 is the agent running under the supervisor?";
|
|
45539
|
+
} else {
|
|
45540
|
+
banner = `\u274c couldn't set effort: ${deps.escapeHtml(result.errorMessage ?? "inject failed")}`;
|
|
45541
|
+
}
|
|
45542
|
+
} catch (err) {
|
|
45543
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
45544
|
+
banner = `\u274c inject failed: ${deps.escapeHtml(msg)}`;
|
|
45545
|
+
}
|
|
45546
|
+
const menu = buildEffortMenu(deps, selected);
|
|
45547
|
+
return {
|
|
45548
|
+
reply: { ...menu, text: `${banner}
|
|
45549
|
+
${menu.text}` },
|
|
45550
|
+
selectedEffort: selected
|
|
45551
|
+
};
|
|
45552
|
+
}
|
|
45553
|
+
|
|
45395
45554
|
// ../src/config/loader.ts
|
|
45396
45555
|
init_dist();
|
|
45397
45556
|
init_zod();
|
|
@@ -54158,11 +54317,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
|
|
|
54158
54317
|
}
|
|
54159
54318
|
|
|
54160
54319
|
// ../src/build-info.ts
|
|
54161
|
-
var VERSION = "0.15.
|
|
54162
|
-
var COMMIT_SHA = "
|
|
54163
|
-
var COMMIT_DATE = "2026-06-
|
|
54164
|
-
var LATEST_PR =
|
|
54165
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
54320
|
+
var VERSION = "0.15.19";
|
|
54321
|
+
var COMMIT_SHA = "3f40c6f9";
|
|
54322
|
+
var COMMIT_DATE = "2026-06-14T00:06:47Z";
|
|
54323
|
+
var LATEST_PR = 2337;
|
|
54324
|
+
var COMMITS_AHEAD_OF_TAG = 0;
|
|
54166
54325
|
|
|
54167
54326
|
// gateway/boot-version.ts
|
|
54168
54327
|
function formatRelativeAgo(iso) {
|
|
@@ -62143,6 +62302,43 @@ bot.command("model", async (ctx) => {
|
|
|
62143
62302
|
const reply = await handleModelCommand(parsed, deps);
|
|
62144
62303
|
await switchroomReply(ctx, reply.text, { html: reply.html });
|
|
62145
62304
|
});
|
|
62305
|
+
function buildEffortDeps() {
|
|
62306
|
+
return {
|
|
62307
|
+
inject: injectSlashCommand,
|
|
62308
|
+
getAgentName: getMyAgentName,
|
|
62309
|
+
getConfiguredEffort: () => {
|
|
62310
|
+
const data = switchroomExecJson(["agent", "list"]);
|
|
62311
|
+
return data?.agents?.find((a) => a.name === getMyAgentName())?.thinking_effort ?? null;
|
|
62312
|
+
},
|
|
62313
|
+
escapeHtml: escapeHtmlForTg,
|
|
62314
|
+
preBlock
|
|
62315
|
+
};
|
|
62316
|
+
}
|
|
62317
|
+
function effortMenuReplyMarkup(reply) {
|
|
62318
|
+
if (!reply.keyboard)
|
|
62319
|
+
return;
|
|
62320
|
+
const kb = new import_grammy9.InlineKeyboard;
|
|
62321
|
+
for (const row of reply.keyboard) {
|
|
62322
|
+
for (const btn of row)
|
|
62323
|
+
kb.text(btn.text, btn.callback_data);
|
|
62324
|
+
kb.row();
|
|
62325
|
+
}
|
|
62326
|
+
return kb;
|
|
62327
|
+
}
|
|
62328
|
+
bot.command("effort", async (ctx) => {
|
|
62329
|
+
if (!isAuthorizedSender(ctx))
|
|
62330
|
+
return;
|
|
62331
|
+
const text = ctx.message?.text ?? ctx.channelPost?.text ?? "";
|
|
62332
|
+
const parsed = parseEffortCommand(text) ?? { kind: "show" };
|
|
62333
|
+
const deps = buildEffortDeps();
|
|
62334
|
+
if (parsed.kind === "show") {
|
|
62335
|
+
const menu = buildEffortMenu(deps);
|
|
62336
|
+
await switchroomReply(ctx, menu.text, { html: true, reply_markup: effortMenuReplyMarkup(menu) });
|
|
62337
|
+
return;
|
|
62338
|
+
}
|
|
62339
|
+
const reply = await handleEffortCommand(parsed, deps);
|
|
62340
|
+
await switchroomReply(ctx, reply.text, { html: reply.html });
|
|
62341
|
+
});
|
|
62146
62342
|
bot.command("agentstart", async (ctx) => {
|
|
62147
62343
|
if (!isAuthorizedSender(ctx))
|
|
62148
62344
|
return;
|
|
@@ -64870,6 +65066,26 @@ bot.on("callback_query:data", async (ctx) => {
|
|
|
64870
65066
|
await handleAuthDashboardCallback(ctx);
|
|
64871
65067
|
return;
|
|
64872
65068
|
}
|
|
65069
|
+
if (data.startsWith(EFFORT_CALLBACK_PREFIX)) {
|
|
65070
|
+
const access2 = loadAccess();
|
|
65071
|
+
const senderId2 = String(ctx.from?.id ?? "");
|
|
65072
|
+
if (!access2.allowFrom.includes(senderId2)) {
|
|
65073
|
+
await ctx.answerCallbackQuery({ text: "Not authorized." });
|
|
65074
|
+
return;
|
|
65075
|
+
}
|
|
65076
|
+
await ctx.answerCallbackQuery({ text: "Setting effort\u2026" }).catch(() => {});
|
|
65077
|
+
try {
|
|
65078
|
+
const outcome = await handleEffortMenuCallback(data, buildEffortDeps());
|
|
65079
|
+
await ctx.editMessageText(outcome.reply.text, {
|
|
65080
|
+
parse_mode: "HTML",
|
|
65081
|
+
reply_markup: effortMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] }
|
|
65082
|
+
}).catch(() => {});
|
|
65083
|
+
} catch (err) {
|
|
65084
|
+
process.stderr.write(`telegram gateway: effort-menu callback failed: ${err?.message ?? String(err)}
|
|
65085
|
+
`);
|
|
65086
|
+
}
|
|
65087
|
+
return;
|
|
65088
|
+
}
|
|
64873
65089
|
if (data.startsWith(MODEL_CALLBACK_PREFIX)) {
|
|
64874
65090
|
const access2 = loadAccess();
|
|
64875
65091
|
const senderId2 = String(ctx.from?.id ?? "");
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram `/effort` command — show or switch the Claude reasoning effort
|
|
3
|
+
* for this agent's live session. The effort sibling of `/model`.
|
|
4
|
+
*
|
|
5
|
+
* `/effort` (bare) renders the effort menu: the configured default plus an
|
|
6
|
+
* inline keyboard of the five levels the CLI offers
|
|
7
|
+
* (`low · medium · high · xhigh · max`, faster→smarter), the live level
|
|
8
|
+
* marked ✅. A tap types claude's own `/effort <level>` into the agent's
|
|
9
|
+
* tmux pane via the allowlisted inject primitive (`/effort` is on the
|
|
10
|
+
* inject allowlist) — the Claude-native mechanism: the unmodified CLI's
|
|
11
|
+
* REPL command, no API, no SDK, no config mutation.
|
|
12
|
+
*
|
|
13
|
+
* `/effort <level>` does the same non-interactively.
|
|
14
|
+
*
|
|
15
|
+
* The switch is session-scoped. It lasts until the agent restarts, because
|
|
16
|
+
* `start.sh` always relaunches claude with `--effort <thinking_effort>`
|
|
17
|
+
* (the cascade-resolved default, "low" out of the box) which re-pins the
|
|
18
|
+
* session effort on boot. Persisting a new default is a `thinking_effort:`
|
|
19
|
+
* change in switchroom.yaml + restart, which the reply spells out.
|
|
20
|
+
*
|
|
21
|
+
* Split parser/handler shape mirrors `model-command.ts` so the logic is
|
|
22
|
+
* unit-testable without booting the bot.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { InjectResult } from '../../src/agents/inject.js'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The effort levels the installed CLI accepts (`claude --help`:
|
|
29
|
+
* "--effort <level> … (low, medium, high, xhigh, max)"). Fixed and
|
|
30
|
+
* ordered faster→smarter. Unlike model ids these don't churn, so they're
|
|
31
|
+
* listed here rather than discovered live — a new level needs a one-line
|
|
32
|
+
* edit, surfaced by the regression test.
|
|
33
|
+
*/
|
|
34
|
+
export const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
|
|
35
|
+
export type EffortLevel = (typeof EFFORT_LEVELS)[number]
|
|
36
|
+
|
|
37
|
+
/** Strict allowlist gate — the arg is typed verbatim into the agent pane. */
|
|
38
|
+
export function isValidEffortArg(arg: string): boolean {
|
|
39
|
+
return (EFFORT_LEVELS as readonly string[]).includes(arg.toLowerCase())
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ParsedEffortCommand =
|
|
43
|
+
| { kind: 'show' }
|
|
44
|
+
| { kind: 'set'; level: EffortLevel }
|
|
45
|
+
| { kind: 'help'; reason?: string }
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse an `/effort` message. Returns null when the text isn't an /effort
|
|
49
|
+
* command at all (caller bug — bot.command should pre-filter).
|
|
50
|
+
*/
|
|
51
|
+
export function parseEffortCommand(text: string): ParsedEffortCommand | null {
|
|
52
|
+
const m = text.match(/^\/effort(?:@[A-Za-z0-9_]+)?(?:\s+([\s\S]*))?$/)
|
|
53
|
+
if (!m) return null
|
|
54
|
+
const rest = (m[1] ?? '').trim()
|
|
55
|
+
if (rest.length === 0) return { kind: 'show' }
|
|
56
|
+
const parts = rest.split(/\s+/)
|
|
57
|
+
if (parts.length > 1) {
|
|
58
|
+
return { kind: 'help', reason: 'effort takes a single level' }
|
|
59
|
+
}
|
|
60
|
+
const arg = parts[0]
|
|
61
|
+
if (arg.toLowerCase() === 'help') return { kind: 'help' }
|
|
62
|
+
if (!isValidEffortArg(arg)) {
|
|
63
|
+
return { kind: 'help', reason: `not a valid effort level: ${arg}` }
|
|
64
|
+
}
|
|
65
|
+
return { kind: 'set', level: arg.toLowerCase() as EffortLevel }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface EffortCommandDeps {
|
|
69
|
+
/** Inject primitive — wired to injectSlashCommand in the gateway. */
|
|
70
|
+
inject: (agent: string, command: string) => Promise<InjectResult>
|
|
71
|
+
getAgentName: () => string
|
|
72
|
+
/**
|
|
73
|
+
* The agent's cascade-resolved `thinking_effort` from
|
|
74
|
+
* `switchroom agent list` (the value start.sh bakes into `--effort`).
|
|
75
|
+
* Null when unreadable — rendered as the built-in default.
|
|
76
|
+
*/
|
|
77
|
+
getConfiguredEffort: () => string | null
|
|
78
|
+
escapeHtml: (s: string) => string
|
|
79
|
+
preBlock: (s: string) => string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface EffortCommandReply {
|
|
83
|
+
text: string
|
|
84
|
+
html: true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const PERSIST_NOTE =
|
|
88
|
+
'<i>Session-only — reverts to the configured default on restart. To change the default, set <code>thinking_effort:</code> in switchroom.yaml and restart.</i>'
|
|
89
|
+
|
|
90
|
+
const LEVELS_INLINE = EFFORT_LEVELS.map(l => `<code>${l}</code>`).join(' · ')
|
|
91
|
+
|
|
92
|
+
function helpText(deps: EffortCommandDeps, reason?: string): EffortCommandReply {
|
|
93
|
+
const lines: string[] = []
|
|
94
|
+
if (reason) lines.push(`⚠️ ${deps.escapeHtml(reason)}`)
|
|
95
|
+
lines.push(
|
|
96
|
+
'<b>/effort</b> — show or switch the reasoning effort (faster→smarter)',
|
|
97
|
+
'<code>/effort</code> — show the configured effort + a tap menu',
|
|
98
|
+
`<code>/effort <level></code> — switch the live session (${LEVELS_INLINE})`,
|
|
99
|
+
PERSIST_NOTE,
|
|
100
|
+
)
|
|
101
|
+
return { text: lines.join('\n'), html: true }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function handleEffortCommand(
|
|
105
|
+
parsed: ParsedEffortCommand,
|
|
106
|
+
deps: EffortCommandDeps,
|
|
107
|
+
): Promise<EffortCommandReply> {
|
|
108
|
+
if (parsed.kind === 'help') return helpText(deps, parsed.reason)
|
|
109
|
+
|
|
110
|
+
if (parsed.kind === 'show') {
|
|
111
|
+
const configured = deps.getConfiguredEffort()
|
|
112
|
+
const shown = configured && configured.length > 0 ? configured : 'low'
|
|
113
|
+
return {
|
|
114
|
+
text: [
|
|
115
|
+
`<b>Effort — ${deps.escapeHtml(deps.getAgentName())}</b>`,
|
|
116
|
+
`Configured default: <code>${deps.escapeHtml(shown)}</code>`,
|
|
117
|
+
`Switch the live session: ${EFFORT_LEVELS.map(l => `<code>/effort ${l}</code>`).join(' · ')}`,
|
|
118
|
+
PERSIST_NOTE,
|
|
119
|
+
].join('\n'),
|
|
120
|
+
html: true,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// kind === 'set' — re-gate at the seam so a caller that skipped the
|
|
125
|
+
// parser can't type arbitrary keys into the pane.
|
|
126
|
+
if (!isValidEffortArg(parsed.level)) {
|
|
127
|
+
return helpText(deps, `not a valid effort level: ${parsed.level}`)
|
|
128
|
+
}
|
|
129
|
+
const verbHtml = `<code>/effort ${deps.escapeHtml(parsed.level)}</code>`
|
|
130
|
+
let result: InjectResult
|
|
131
|
+
try {
|
|
132
|
+
result = await deps.inject(deps.getAgentName(), `/effort ${parsed.level}`)
|
|
133
|
+
} catch (err) {
|
|
134
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
135
|
+
return { text: `❌ ${verbHtml} — inject failed: ${deps.escapeHtml(msg)}`, html: true }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (result.outcome === 'ok') {
|
|
139
|
+
return {
|
|
140
|
+
text: [
|
|
141
|
+
`${verbHtml}`,
|
|
142
|
+
deps.preBlock(result.output),
|
|
143
|
+
...(result.truncated ? ['<i>truncated</i>'] : []),
|
|
144
|
+
PERSIST_NOTE,
|
|
145
|
+
].join('\n'),
|
|
146
|
+
html: true,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (result.outcome === 'ok_no_output') {
|
|
150
|
+
return {
|
|
151
|
+
text: [
|
|
152
|
+
`${verbHtml} — sent, but no response captured. The agent may be mid-turn; check <code>/inject /status</code> to confirm the active effort.`,
|
|
153
|
+
PERSIST_NOTE,
|
|
154
|
+
].join('\n'),
|
|
155
|
+
html: true,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// outcome === 'failed'
|
|
159
|
+
if (result.errorCode === 'session_missing') {
|
|
160
|
+
return {
|
|
161
|
+
text:
|
|
162
|
+
'❌ tmux session not found — the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.',
|
|
163
|
+
html: true,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
text: `❌ ${verbHtml} — ${deps.escapeHtml(result.errorMessage ?? 'inject failed')}`,
|
|
168
|
+
html: true,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Button menu — five fixed levels, the live one marked ✅. No live discovery
|
|
174
|
+
// (the levels don't churn) and no picker-driving (the inline `/effort <level>`
|
|
175
|
+
// form sets it directly), so this is far simpler than the /model menu.
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
export interface EffortMenuKeyboardButton {
|
|
179
|
+
text: string
|
|
180
|
+
callback_data: string
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface EffortMenuReply {
|
|
184
|
+
text: string
|
|
185
|
+
html: true
|
|
186
|
+
keyboard?: EffortMenuKeyboardButton[][]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const EFFORT_CALLBACK_PREFIX = 'eff:'
|
|
190
|
+
const EFFORT_CALLBACK_SELECT = 'eff:s:'
|
|
191
|
+
|
|
192
|
+
export function effortSelectCallbackData(level: string): string {
|
|
193
|
+
return `${EFFORT_CALLBACK_SELECT}${level}`
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function menuKeyboard(highlight: string): EffortMenuKeyboardButton[][] {
|
|
197
|
+
// Five short labels fit one row (Telegram allows up to 8/row). ✅ marks
|
|
198
|
+
// the level we believe is live.
|
|
199
|
+
return [
|
|
200
|
+
EFFORT_LEVELS.map(l => ({
|
|
201
|
+
text: l === highlight ? `✅ ${l}` : l,
|
|
202
|
+
callback_data: effortSelectCallbackData(l),
|
|
203
|
+
})),
|
|
204
|
+
]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Build the `/effort` menu: configured default + a tap row of the five
|
|
209
|
+
* levels. `highlight` marks the level shown as live (defaults to the
|
|
210
|
+
* configured value; the callback path passes the just-selected level).
|
|
211
|
+
*/
|
|
212
|
+
export function buildEffortMenu(deps: EffortCommandDeps, highlight?: string): EffortMenuReply {
|
|
213
|
+
const configured = deps.getConfiguredEffort() || 'low'
|
|
214
|
+
const live = highlight ?? configured
|
|
215
|
+
return {
|
|
216
|
+
text: [
|
|
217
|
+
`<b>Effort — ${deps.escapeHtml(deps.getAgentName())}</b>`,
|
|
218
|
+
`Default: <code>${deps.escapeHtml(configured)}</code> · faster → smarter: ${LEVELS_INLINE}`,
|
|
219
|
+
'Tap to switch the live session:',
|
|
220
|
+
PERSIST_NOTE,
|
|
221
|
+
].join('\n'),
|
|
222
|
+
html: true,
|
|
223
|
+
keyboard: menuKeyboard(live),
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface EffortCallbackOutcome {
|
|
228
|
+
reply: EffortMenuReply
|
|
229
|
+
/** The level applied to the live session, if any (gateway records it). */
|
|
230
|
+
selectedEffort?: string
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Handle an `eff:*` callback tap. `eff:s:<level>` injects claude's
|
|
235
|
+
* `/effort <level>` and re-renders the menu with a one-line banner and the
|
|
236
|
+
* new level checked. Never throws — failures render as a banner.
|
|
237
|
+
*/
|
|
238
|
+
export async function handleEffortMenuCallback(
|
|
239
|
+
data: string,
|
|
240
|
+
deps: EffortCommandDeps,
|
|
241
|
+
): Promise<EffortCallbackOutcome> {
|
|
242
|
+
if (!data.startsWith(EFFORT_CALLBACK_SELECT)) {
|
|
243
|
+
return { reply: buildEffortMenu(deps) }
|
|
244
|
+
}
|
|
245
|
+
const level = data.slice(EFFORT_CALLBACK_SELECT.length)
|
|
246
|
+
if (!isValidEffortArg(level)) {
|
|
247
|
+
return { reply: buildEffortMenu(deps) }
|
|
248
|
+
}
|
|
249
|
+
let banner: string
|
|
250
|
+
let selected: string | undefined
|
|
251
|
+
try {
|
|
252
|
+
const result = await deps.inject(deps.getAgentName(), `/effort ${level}`)
|
|
253
|
+
if (result.outcome === 'ok' || result.outcome === 'ok_no_output') {
|
|
254
|
+
banner = `✅ Effort → <code>${deps.escapeHtml(level)}</code> for this session`
|
|
255
|
+
selected = level
|
|
256
|
+
} else if (result.errorCode === 'session_missing') {
|
|
257
|
+
banner = '❌ tmux session not found — is the agent running under the supervisor?'
|
|
258
|
+
} else {
|
|
259
|
+
banner = `❌ couldn't set effort: ${deps.escapeHtml(result.errorMessage ?? 'inject failed')}`
|
|
260
|
+
}
|
|
261
|
+
} catch (err) {
|
|
262
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
263
|
+
banner = `❌ inject failed: ${deps.escapeHtml(msg)}`
|
|
264
|
+
}
|
|
265
|
+
// Re-render with the just-selected level checked (or the configured
|
|
266
|
+
// default if the inject failed) and the banner on top.
|
|
267
|
+
const menu = buildEffortMenu(deps, selected)
|
|
268
|
+
return {
|
|
269
|
+
reply: { ...menu, text: `${banner}\n${menu.text}` },
|
|
270
|
+
selectedEffort: selected,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -271,6 +271,15 @@ import {
|
|
|
271
271
|
type ModelMenuReply,
|
|
272
272
|
} from './model-command.js'
|
|
273
273
|
import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
|
|
274
|
+
import {
|
|
275
|
+
parseEffortCommand,
|
|
276
|
+
handleEffortCommand,
|
|
277
|
+
buildEffortMenu,
|
|
278
|
+
handleEffortMenuCallback,
|
|
279
|
+
EFFORT_CALLBACK_PREFIX,
|
|
280
|
+
type EffortCommandDeps,
|
|
281
|
+
type EffortMenuReply,
|
|
282
|
+
} from './effort-command.js'
|
|
274
283
|
import { type BannerState } from '../slot-banner.js'
|
|
275
284
|
import { refreshBanner } from '../slot-banner-driver.js'
|
|
276
285
|
import { loadConfig as loadSwitchroomConfig, findConfigFile as findSwitchroomConfigFile } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
|
|
@@ -14219,6 +14228,51 @@ bot.command('model', async ctx => {
|
|
|
14219
14228
|
await switchroomReply(ctx, reply.text, { html: reply.html })
|
|
14220
14229
|
})
|
|
14221
14230
|
|
|
14231
|
+
// `/effort` — show or switch the reasoning effort for the live session.
|
|
14232
|
+
// The effort sibling of `/model`: bare form renders a five-button menu
|
|
14233
|
+
// (low/medium/high/xhigh/max, the live level ✅), a typed form
|
|
14234
|
+
// `/effort <level>` sets it directly. Both ride the allowlisted inject
|
|
14235
|
+
// primitive (claude's own `/effort` REPL command), session-scoped — boot
|
|
14236
|
+
// re-pins the configured default via start.sh's `--effort`. Implementation
|
|
14237
|
+
// in effort-command.ts so it's unit-testable without booting the bot.
|
|
14238
|
+
function buildEffortDeps(): EffortCommandDeps {
|
|
14239
|
+
return {
|
|
14240
|
+
inject: injectSlashCommandImpl,
|
|
14241
|
+
getAgentName: getMyAgentName,
|
|
14242
|
+
getConfiguredEffort: () => {
|
|
14243
|
+
type AgentListResp = { agents: Array<{ name: string; thinking_effort?: string | null }> }
|
|
14244
|
+
const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
|
|
14245
|
+
return data?.agents?.find(a => a.name === getMyAgentName())?.thinking_effort ?? null
|
|
14246
|
+
},
|
|
14247
|
+
escapeHtml: escapeHtmlForTg,
|
|
14248
|
+
preBlock,
|
|
14249
|
+
}
|
|
14250
|
+
}
|
|
14251
|
+
|
|
14252
|
+
function effortMenuReplyMarkup(reply: EffortMenuReply): InlineKeyboard | undefined {
|
|
14253
|
+
if (!reply.keyboard) return undefined
|
|
14254
|
+
const kb = new InlineKeyboard()
|
|
14255
|
+
for (const row of reply.keyboard) {
|
|
14256
|
+
for (const btn of row) kb.text(btn.text, btn.callback_data)
|
|
14257
|
+
kb.row()
|
|
14258
|
+
}
|
|
14259
|
+
return kb
|
|
14260
|
+
}
|
|
14261
|
+
|
|
14262
|
+
bot.command('effort', async ctx => {
|
|
14263
|
+
if (!isAuthorizedSender(ctx)) return
|
|
14264
|
+
const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
|
|
14265
|
+
const parsed = parseEffortCommand(text) ?? { kind: 'show' as const }
|
|
14266
|
+
const deps = buildEffortDeps()
|
|
14267
|
+
if (parsed.kind === 'show') {
|
|
14268
|
+
const menu = buildEffortMenu(deps)
|
|
14269
|
+
await switchroomReply(ctx, menu.text, { html: true, reply_markup: effortMenuReplyMarkup(menu) })
|
|
14270
|
+
return
|
|
14271
|
+
}
|
|
14272
|
+
const reply = await handleEffortCommand(parsed, deps)
|
|
14273
|
+
await switchroomReply(ctx, reply.text, { html: reply.html })
|
|
14274
|
+
})
|
|
14275
|
+
|
|
14222
14276
|
bot.command('agentstart', async ctx => {
|
|
14223
14277
|
if (!isAuthorizedSender(ctx)) return
|
|
14224
14278
|
const name = ctx.match?.trim() || getMyAgentName()
|
|
@@ -18608,6 +18662,33 @@ bot.on('callback_query:data', async ctx => {
|
|
|
18608
18662
|
// a stale index); `mdl:r` re-renders. Strict allowFrom gate like
|
|
18609
18663
|
// every other mutating callback family — a model switch changes the
|
|
18610
18664
|
// fleet's quota burn profile.
|
|
18665
|
+
if (data.startsWith(EFFORT_CALLBACK_PREFIX)) {
|
|
18666
|
+
const access = loadAccess()
|
|
18667
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
18668
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
18669
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' })
|
|
18670
|
+
return
|
|
18671
|
+
}
|
|
18672
|
+
// No picker-driving (the inline `/effort <level>` form sets it
|
|
18673
|
+
// directly via the inject primitive), so no mid-turn guard is needed
|
|
18674
|
+
// here — just ack and apply.
|
|
18675
|
+
await ctx.answerCallbackQuery({ text: 'Setting effort…' }).catch(() => {})
|
|
18676
|
+
try {
|
|
18677
|
+
const outcome = await handleEffortMenuCallback(data, buildEffortDeps())
|
|
18678
|
+
await ctx
|
|
18679
|
+
.editMessageText(outcome.reply.text, {
|
|
18680
|
+
parse_mode: 'HTML',
|
|
18681
|
+
reply_markup: effortMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
|
|
18682
|
+
})
|
|
18683
|
+
.catch(() => {})
|
|
18684
|
+
} catch (err) {
|
|
18685
|
+
process.stderr.write(
|
|
18686
|
+
`telegram gateway: effort-menu callback failed: ${(err as Error)?.message ?? String(err)}\n`,
|
|
18687
|
+
)
|
|
18688
|
+
}
|
|
18689
|
+
return
|
|
18690
|
+
}
|
|
18691
|
+
|
|
18611
18692
|
if (data.startsWith(MODEL_CALLBACK_PREFIX)) {
|
|
18612
18693
|
const access = loadAccess()
|
|
18613
18694
|
const senderId = String(ctx.from?.id ?? '')
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/effort` Telegram command — parser + handler + menu coverage.
|
|
3
|
+
*
|
|
4
|
+
* Guarantees mirrored from `/model`:
|
|
5
|
+
* 1. The argument is allowlist-gated before it's typed into the tmux
|
|
6
|
+
* pane — only the five levels claude accepts, nothing else.
|
|
7
|
+
* 2. The set path injects exactly `/effort <level>` (claude's own REPL
|
|
8
|
+
* verb, on the inject allowlist) and relays the captured output with
|
|
9
|
+
* the session-only / reverts-on-restart caveat.
|
|
10
|
+
* 3. The bare form renders a five-button menu; a tap injects the level.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
parseEffortCommand,
|
|
15
|
+
handleEffortCommand,
|
|
16
|
+
isValidEffortArg,
|
|
17
|
+
EFFORT_LEVELS,
|
|
18
|
+
buildEffortMenu,
|
|
19
|
+
handleEffortMenuCallback,
|
|
20
|
+
effortSelectCallbackData,
|
|
21
|
+
EFFORT_CALLBACK_PREFIX,
|
|
22
|
+
type EffortCommandDeps,
|
|
23
|
+
} from "../gateway/effort-command.js";
|
|
24
|
+
import type { InjectResult } from "../../src/agents/inject.js";
|
|
25
|
+
|
|
26
|
+
function okResult(output: string): InjectResult {
|
|
27
|
+
return {
|
|
28
|
+
outcome: "ok",
|
|
29
|
+
output,
|
|
30
|
+
truncated: false,
|
|
31
|
+
command: "/effort",
|
|
32
|
+
meta: { description: "Set reasoning effort", expectsOutput: true },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function failedResult(errorMessage: string): InjectResult {
|
|
37
|
+
return {
|
|
38
|
+
outcome: "failed",
|
|
39
|
+
output: "",
|
|
40
|
+
truncated: false,
|
|
41
|
+
command: "/effort",
|
|
42
|
+
errorMessage,
|
|
43
|
+
meta: { description: "Set reasoning effort", expectsOutput: true },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeDeps(overrides: Partial<EffortCommandDeps> = {}) {
|
|
48
|
+
const calls: Array<{ agent: string; command: string }> = [];
|
|
49
|
+
const deps: EffortCommandDeps = {
|
|
50
|
+
inject: async (agent, command) => {
|
|
51
|
+
calls.push({ agent, command });
|
|
52
|
+
return okResult("Set effort level to high");
|
|
53
|
+
},
|
|
54
|
+
getAgentName: () => "carrie",
|
|
55
|
+
getConfiguredEffort: () => "low",
|
|
56
|
+
escapeHtml: (s) => s,
|
|
57
|
+
preBlock: (s) => `<pre>${s}</pre>`,
|
|
58
|
+
...overrides,
|
|
59
|
+
};
|
|
60
|
+
return { deps, calls };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("effort-command: levels + validation", () => {
|
|
64
|
+
it("exposes exactly the five CLI levels in faster→smarter order", () => {
|
|
65
|
+
expect(EFFORT_LEVELS).toEqual(["low", "medium", "high", "xhigh", "max"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("isValidEffortArg accepts levels case-insensitively, rejects others", () => {
|
|
69
|
+
for (const l of EFFORT_LEVELS) {
|
|
70
|
+
expect(isValidEffortArg(l)).toBe(true);
|
|
71
|
+
expect(isValidEffortArg(l.toUpperCase())).toBe(true);
|
|
72
|
+
}
|
|
73
|
+
for (const bad of ["", "highest", "fast", "/effort", "low high", "9"]) {
|
|
74
|
+
expect(isValidEffortArg(bad)).toBe(false);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("effort-command: parser", () => {
|
|
80
|
+
it("bare /effort → show", () => {
|
|
81
|
+
expect(parseEffortCommand("/effort")).toEqual({ kind: "show" });
|
|
82
|
+
expect(parseEffortCommand("/effort ")).toEqual({ kind: "show" });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("/effort@bot suffix is tolerated", () => {
|
|
86
|
+
expect(parseEffortCommand("/effort@carrie_bot")).toEqual({ kind: "show" });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("/effort <level> → set, normalized to lowercase", () => {
|
|
90
|
+
expect(parseEffortCommand("/effort high")).toEqual({ kind: "set", level: "high" });
|
|
91
|
+
expect(parseEffortCommand("/effort XHIGH")).toEqual({ kind: "set", level: "xhigh" });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("/effort help → help", () => {
|
|
95
|
+
expect(parseEffortCommand("/effort help")).toEqual({ kind: "help" });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("invalid level → help with a reason", () => {
|
|
99
|
+
const p = parseEffortCommand("/effort turbo");
|
|
100
|
+
expect(p?.kind).toBe("help");
|
|
101
|
+
expect((p as { reason?: string }).reason).toMatch(/not a valid effort level/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("more than one token → help", () => {
|
|
105
|
+
const p = parseEffortCommand("/effort high please");
|
|
106
|
+
expect(p?.kind).toBe("help");
|
|
107
|
+
expect((p as { reason?: string }).reason).toMatch(/single level/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("non-/effort text → null", () => {
|
|
111
|
+
expect(parseEffortCommand("/model opus")).toBeNull();
|
|
112
|
+
expect(parseEffortCommand("hello")).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("effort-command: handler", () => {
|
|
117
|
+
it("show renders the configured default", async () => {
|
|
118
|
+
const { deps } = makeDeps({ getConfiguredEffort: () => "medium" });
|
|
119
|
+
const r = await handleEffortCommand({ kind: "show" }, deps);
|
|
120
|
+
expect(r.text).toContain("medium");
|
|
121
|
+
expect(r.text).toMatch(/reverts to the configured default/);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("show falls back to low when effort is unreadable", async () => {
|
|
125
|
+
const { deps } = makeDeps({ getConfiguredEffort: () => null });
|
|
126
|
+
const r = await handleEffortCommand({ kind: "show" }, deps);
|
|
127
|
+
expect(r.text).toContain("low");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("set injects exactly '/effort <level>' and relays output", async () => {
|
|
131
|
+
const { deps, calls } = makeDeps();
|
|
132
|
+
const r = await handleEffortCommand({ kind: "set", level: "high" }, deps);
|
|
133
|
+
expect(calls).toEqual([{ agent: "carrie", command: "/effort high" }]);
|
|
134
|
+
expect(r.text).toContain("Set effort level to high");
|
|
135
|
+
expect(r.text).toMatch(/reverts to the configured default/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("set surfaces an inject failure", async () => {
|
|
139
|
+
const { deps } = makeDeps({ inject: async () => failedResult("pane locked") });
|
|
140
|
+
const r = await handleEffortCommand({ kind: "set", level: "max" }, deps);
|
|
141
|
+
expect(r.text).toContain("pane locked");
|
|
142
|
+
expect(r.text).toContain("❌");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("set re-gates the level at the seam (defensive)", async () => {
|
|
146
|
+
const { deps, calls } = makeDeps();
|
|
147
|
+
// Hand-craft a parsed object that skipped the parser's gate.
|
|
148
|
+
const r = await handleEffortCommand({ kind: "set", level: "evil; rm -rf" as never }, deps);
|
|
149
|
+
expect(calls).toEqual([]); // never injected
|
|
150
|
+
expect(r.text).toMatch(/not a valid effort level/);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("effort-command: menu + callback", () => {
|
|
155
|
+
it("buildEffortMenu offers all five levels with the configured one checked", () => {
|
|
156
|
+
const { deps } = makeDeps({ getConfiguredEffort: () => "high" });
|
|
157
|
+
const menu = buildEffortMenu(deps);
|
|
158
|
+
const buttons = menu.keyboard!.flat();
|
|
159
|
+
expect(buttons.map((b) => b.callback_data)).toEqual(
|
|
160
|
+
EFFORT_LEVELS.map((l) => effortSelectCallbackData(l)),
|
|
161
|
+
);
|
|
162
|
+
const checked = buttons.find((b) => b.text.startsWith("✅"));
|
|
163
|
+
expect(checked?.text).toBe("✅ high");
|
|
164
|
+
expect(menu.keyboard![0]).toHaveLength(5);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("callback eff:s:<level> injects the level and checks it in the re-render", async () => {
|
|
168
|
+
const { deps, calls } = makeDeps();
|
|
169
|
+
const out = await handleEffortMenuCallback(effortSelectCallbackData("xhigh"), deps);
|
|
170
|
+
expect(calls).toEqual([{ agent: "carrie", command: "/effort xhigh" }]);
|
|
171
|
+
expect(out.selectedEffort).toBe("xhigh");
|
|
172
|
+
expect(out.reply.text).toContain("Effort → ");
|
|
173
|
+
const checked = out.reply.keyboard!.flat().find((b) => b.text.startsWith("✅"));
|
|
174
|
+
expect(checked?.text).toBe("✅ xhigh");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("callback with a failed inject keeps the menu and shows the error, no selection", async () => {
|
|
178
|
+
const { deps } = makeDeps({ inject: async () => failedResult("session_missing") });
|
|
179
|
+
const out = await handleEffortMenuCallback(effortSelectCallbackData("max"), deps);
|
|
180
|
+
expect(out.selectedEffort).toBeUndefined();
|
|
181
|
+
expect(out.reply.text).toContain("❌");
|
|
182
|
+
expect(out.reply.keyboard!.flat()).toHaveLength(5); // buttons preserved
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("callback ignores a malformed level", async () => {
|
|
186
|
+
const { deps, calls } = makeDeps();
|
|
187
|
+
const out = await handleEffortMenuCallback(`${EFFORT_CALLBACK_PREFIX}s:bogus`, deps);
|
|
188
|
+
expect(calls).toEqual([]);
|
|
189
|
+
expect(out.selectedEffort).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -318,6 +318,10 @@ export const TELEGRAM_MENU_COMMANDS = [
|
|
|
318
318
|
// same inject primitive as `/inject /model` but with a typed argument,
|
|
319
319
|
// so it never opens the undriveable no-arg picker modal).
|
|
320
320
|
{ command: "model", description: "Show or switch the Claude model" },
|
|
321
|
+
// /effort — show or switch the reasoning effort (low→max, faster→smarter).
|
|
322
|
+
// Same Claude-native inject mechanism as /model; session-scoped, reverts
|
|
323
|
+
// to the configured `thinking_effort` default on restart.
|
|
324
|
+
{ command: "effort", description: "Show or switch the reasoning effort" },
|
|
321
325
|
{ command: "doctor", description: "Health check (deps, services, MCP)" },
|
|
322
326
|
{ command: "usage", description: "Pro/Max plan quota (5h + 7d windows)" },
|
|
323
327
|
// Vault — secrets + capability grants. /vault is a top-level command
|
|
@@ -379,6 +383,7 @@ export function switchroomHelpText(agentName: string): string {
|
|
|
379
383
|
`<code>/auth rm [agent] <slot> [--force]</code> — remove a slot`,
|
|
380
384
|
`<code>/model</code> — show the configured Claude model`,
|
|
381
385
|
`<code>/model <name></code> — switch the live session's model (opus · sonnet · haiku or a full id; until restart)`,
|
|
386
|
+
`<code>/effort</code> — show or switch reasoning effort (low · medium · high · xhigh · max; until restart)`,
|
|
382
387
|
`<code>/topics</code> — topic-to-agent mappings`,
|
|
383
388
|
`<code>/permissions [agent]</code> — show agent permissions`,
|
|
384
389
|
`<code>/grant <tool></code> — grant a tool permission`,
|