switchroom 0.15.18 → 0.15.20
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 +521 -336
- package/package.json +1 -1
- package/profiles/_shared/agent-self-service.md.hbs +24 -15
- package/telegram-plugin/dist/gateway/gateway.js +304 -6
- package/telegram-plugin/gateway/effort-command.ts +272 -0
- package/telegram-plugin/gateway/gateway.ts +199 -2
- package/telegram-plugin/gateway/grant-restart.ts +30 -0
- package/telegram-plugin/tests/effort-command.test.ts +191 -0
- package/telegram-plugin/tests/grant-restart.test.ts +38 -0
- package/telegram-plugin/welcome-text.ts +7 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for grantRestartDecision — the gating for the "make a just-persisted
|
|
3
|
+
* grant LIVE" self-restart (item ② config-edit-takes-effect). Pins: kill-switch,
|
|
4
|
+
* self-agent-only (never a peer/fleet bounce), and turn-deferred-vs-now.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from "vitest";
|
|
8
|
+
import { grantRestartDecision } from "../gateway/grant-restart.js";
|
|
9
|
+
|
|
10
|
+
const base = { killSwitch: undefined, selfAgent: "clerk", agentName: "clerk", turnInFlight: true };
|
|
11
|
+
|
|
12
|
+
describe("grantRestartDecision", () => {
|
|
13
|
+
it("defers to turn-complete when a turn is in flight (marker-safe)", () => {
|
|
14
|
+
expect(grantRestartDecision({ ...base, turnInFlight: true })).toBe("deferred");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("fires now when no turn is in flight", () => {
|
|
18
|
+
expect(grantRestartDecision({ ...base, turnInFlight: false })).toBe("now");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("is disabled by the kill-switch (SWITCHROOM_AUTORESTART_ON_GRANT=0)", () => {
|
|
22
|
+
expect(grantRestartDecision({ ...base, killSwitch: "0" })).toBe("disabled");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("default-on for any non-'0' kill-switch value", () => {
|
|
26
|
+
expect(grantRestartDecision({ ...base, killSwitch: "" })).toBe("deferred");
|
|
27
|
+
expect(grantRestartDecision({ ...base, killSwitch: "1" })).toBe("deferred");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("NEVER restarts a peer — self-agent only", () => {
|
|
31
|
+
// edit targets a different agent than the gateway's own identity
|
|
32
|
+
expect(grantRestartDecision({ ...base, selfAgent: "clerk", agentName: "gymbro" })).toBe("disabled");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("is disabled when self identity is unknown", () => {
|
|
36
|
+
expect(grantRestartDecision({ ...base, selfAgent: undefined })).toBe("disabled");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -270,7 +270,7 @@ export const switchroomHelpCommandNames = [
|
|
|
270
270
|
"agents", "agentstart", "stop", "restart", "logs", "memory",
|
|
271
271
|
// Auth & config — consolidated onto the `/auth` dashboard.
|
|
272
272
|
"auth", "model",
|
|
273
|
-
"topics", "update", "version",
|
|
273
|
+
"topics", "update", "version", "whoami",
|
|
274
274
|
"permissions", "grant", "dangerous", "vault", "doctor",
|
|
275
275
|
"commands",
|
|
276
276
|
// Note: "reconcile" is a deprecated alias still handled as a bot command
|
|
@@ -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
|
|
@@ -370,6 +374,7 @@ export function switchroomHelpText(agentName: string): string {
|
|
|
370
374
|
`<code>/update</code> — dry-run plan; <code>/update apply</code> — actually pull images, reconcile, restart`,
|
|
371
375
|
`<code>/restart [name|all]</code> — bounce agent (drains in-flight turn by default)`,
|
|
372
376
|
`<code>/version</code> — show versions + running agent health summary`,
|
|
377
|
+
`<code>/whoami</code> — this agent's sandbox: tools, MCP, vault key-names, powers`,
|
|
373
378
|
``,
|
|
374
379
|
`<b>Auth & config</b>`,
|
|
375
380
|
`<code>/auth</code> — auth status or actions`,
|
|
@@ -379,6 +384,7 @@ export function switchroomHelpText(agentName: string): string {
|
|
|
379
384
|
`<code>/auth rm [agent] <slot> [--force]</code> — remove a slot`,
|
|
380
385
|
`<code>/model</code> — show the configured Claude model`,
|
|
381
386
|
`<code>/model <name></code> — switch the live session's model (opus · sonnet · haiku or a full id; until restart)`,
|
|
387
|
+
`<code>/effort</code> — show or switch reasoning effort (low · medium · high · xhigh · max; until restart)`,
|
|
382
388
|
`<code>/topics</code> — topic-to-agent mappings`,
|
|
383
389
|
`<code>/permissions [agent]</code> — show agent permissions`,
|
|
384
390
|
`<code>/grant <tool></code> — grant a tool permission`,
|