switchroom 0.15.17 → 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.
@@ -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
+ });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Tests for the "⏱ 30 min" scoped-approval tier (scoped-approval.ts)
2
+ * Tests for scoped-approval.ts — the 30-min window backing the "Allow" tap
3
3
  * the middle rung between "Allow once" and "🔁 Always".
4
4
  *
5
5
  * These pin the access-model invariants the adversarial review flagged as
@@ -233,7 +233,7 @@ describe('isDestructiveBashCommand — fail-closed denylist', () => {
233
233
  recordScopedGrant(store, 'clerk', 'Bash(git:*)', T0, TTL)
234
234
  // first token is the harmless `git`, but the backtick hides `rm -rf`
235
235
  expect(lookupScopedGrant(store, 'clerk', 'Bash', bashInput('git status `rm -rf /important`'), T0 + 1)).toBeNull()
236
- // and the request never gets offered the ⏱ button at grant time either
236
+ // and the request never gets a window at grant time either
237
237
  expect(timeBoxRule('Bash', bashInput('git status `rm -rf x`'))).toBeNull()
238
238
  })
239
239
 
@@ -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] &lt;slot&gt; [--force]</code> — remove a slot`,
380
384
  `<code>/model</code> — show the configured Claude model`,
381
385
  `<code>/model &lt;name&gt;</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 &lt;tool&gt;</code> — grant a tool permission`,