switchroom 0.15.19 → 0.15.21

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.
@@ -21,40 +21,29 @@ import {
21
21
  EFFORT_CALLBACK_PREFIX,
22
22
  type EffortCommandDeps,
23
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
- };
24
+ import type { EffortApplyResult } from "../../src/agents/effort-picker.js";
25
+
26
+ function applyOk(level: string, confirmed = false): EffortApplyResult {
27
+ return { ok: true, level, confirmed, output: `Set effort level to ${level}` };
34
28
  }
35
29
 
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
- };
30
+ function applyFail(
31
+ reason: "session_missing" | "confirm_failed" | "apply_unverified",
32
+ wedged?: boolean,
33
+ ): EffortApplyResult {
34
+ return { ok: false, reason, ...(wedged !== undefined ? { wedged } : {}) };
45
35
  }
46
36
 
47
37
  function makeDeps(overrides: Partial<EffortCommandDeps> = {}) {
48
- const calls: Array<{ agent: string; command: string }> = [];
38
+ const calls: Array<{ agent: string; level: string }> = [];
49
39
  const deps: EffortCommandDeps = {
50
- inject: async (agent, command) => {
51
- calls.push({ agent, command });
52
- return okResult("Set effort level to high");
40
+ applyEffort: async (agent, level) => {
41
+ calls.push({ agent, level });
42
+ return applyOk(level);
53
43
  },
54
44
  getAgentName: () => "carrie",
55
45
  getConfiguredEffort: () => "low",
56
46
  escapeHtml: (s) => s,
57
- preBlock: (s) => `<pre>${s}</pre>`,
58
47
  ...overrides,
59
48
  };
60
49
  return { deps, calls };
@@ -127,18 +116,24 @@ describe("effort-command: handler", () => {
127
116
  expect(r.text).toContain("low");
128
117
  });
129
118
 
130
- it("set injects exactly '/effort <level>' and relays output", async () => {
119
+ it("set applies exactly the level and relays output", async () => {
131
120
  const { deps, calls } = makeDeps();
132
121
  const r = await handleEffortCommand({ kind: "set", level: "high" }, deps);
133
- expect(calls).toEqual([{ agent: "carrie", command: "/effort high" }]);
122
+ expect(calls).toEqual([{ agent: "carrie", level: "high" }]);
134
123
  expect(r.text).toContain("Set effort level to high");
135
124
  expect(r.text).toMatch(/reverts to the configured default/);
136
125
  });
137
126
 
138
- it("set surfaces an inject failure", async () => {
139
- const { deps } = makeDeps({ inject: async () => failedResult("pane locked") });
127
+ it("set notes the re-read cost when a confirmation was needed", async () => {
128
+ const { deps } = makeDeps({ applyEffort: async (_a, l) => applyOk(l, true) });
129
+ const r = await handleEffortCommand({ kind: "set", level: "high" }, deps);
130
+ expect(r.text).toMatch(/re-reads the cached history/);
131
+ });
132
+
133
+ it("set surfaces a confirm_failed outcome honestly", async () => {
134
+ const { deps } = makeDeps({ applyEffort: async () => applyFail("confirm_failed", false) });
140
135
  const r = await handleEffortCommand({ kind: "set", level: "max" }, deps);
141
- expect(r.text).toContain("pane locked");
136
+ expect(r.text).toContain("couldn't confirm the switch");
142
137
  expect(r.text).toContain("❌");
143
138
  });
144
139
 
@@ -146,7 +141,7 @@ describe("effort-command: handler", () => {
146
141
  const { deps, calls } = makeDeps();
147
142
  // Hand-craft a parsed object that skipped the parser's gate.
148
143
  const r = await handleEffortCommand({ kind: "set", level: "evil; rm -rf" as never }, deps);
149
- expect(calls).toEqual([]); // never injected
144
+ expect(calls).toEqual([]); // never applied
150
145
  expect(r.text).toMatch(/not a valid effort level/);
151
146
  });
152
147
  });
@@ -164,24 +159,38 @@ describe("effort-command: menu + callback", () => {
164
159
  expect(menu.keyboard![0]).toHaveLength(5);
165
160
  });
166
161
 
167
- it("callback eff:s:<level> injects the level and checks it in the re-render", async () => {
162
+ it("callback eff:s:<level> applies the level and checks it in the re-render", async () => {
168
163
  const { deps, calls } = makeDeps();
169
164
  const out = await handleEffortMenuCallback(effortSelectCallbackData("xhigh"), deps);
170
- expect(calls).toEqual([{ agent: "carrie", command: "/effort xhigh" }]);
165
+ expect(calls).toEqual([{ agent: "carrie", level: "xhigh" }]);
171
166
  expect(out.selectedEffort).toBe("xhigh");
172
167
  expect(out.reply.text).toContain("Effort → ");
173
168
  const checked = out.reply.keyboard!.flat().find((b) => b.text.startsWith("✅"));
174
169
  expect(checked?.text).toBe("✅ xhigh");
175
170
  });
176
171
 
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") });
172
+ it("callback notes the re-read when a confirmation was answered", async () => {
173
+ const { deps } = makeDeps({ applyEffort: async (_a, l) => applyOk(l, true) });
174
+ const out = await handleEffortMenuCallback(effortSelectCallbackData("high"), deps);
175
+ expect(out.reply.text).toMatch(/re-reads history/);
176
+ expect(out.selectedEffort).toBe("high");
177
+ });
178
+
179
+ it("callback with a failed apply keeps the menu and shows the error, no selection", async () => {
180
+ const { deps } = makeDeps({ applyEffort: async () => applyFail("session_missing") });
179
181
  const out = await handleEffortMenuCallback(effortSelectCallbackData("max"), deps);
180
182
  expect(out.selectedEffort).toBeUndefined();
181
183
  expect(out.reply.text).toContain("❌");
182
184
  expect(out.reply.keyboard!.flat()).toHaveLength(5); // buttons preserved
183
185
  });
184
186
 
187
+ it("callback surfaces a wedged confirm_failed as a warning, no selection", async () => {
188
+ const { deps } = makeDeps({ applyEffort: async () => applyFail("confirm_failed", true) });
189
+ const out = await handleEffortMenuCallback(effortSelectCallbackData("max"), deps);
190
+ expect(out.selectedEffort).toBeUndefined();
191
+ expect(out.reply.text).toMatch(/may still be open/);
192
+ });
193
+
185
194
  it("callback ignores a malformed level", async () => {
186
195
  const { deps, calls } = makeDeps();
187
196
  const out = await handleEffortMenuCallback(`${EFFORT_CALLBACK_PREFIX}s:bogus`, deps);
@@ -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
+ });
@@ -39,17 +39,28 @@ function extractPerformBlock(): string {
39
39
  describe("performVaultAccessApproval injects a synthetic inbound on success (#1052)", () => {
40
40
  const block = extractPerformBlock();
41
41
 
42
- it("calls ipcServer.sendToAgent AFTER successful mint + token-write", () => {
42
+ it("routes the resume injection through the turn-gated helper AFTER successful mint + token-write", () => {
43
43
  // fails when: the auto-resume injection gets dropped. Pre-fix
44
44
  // operator had to message the agent again to resume the task —
45
45
  // the injection is the load-bearing wiring.
46
- expect(block, "missing ipcServer.sendToAgent call").toMatch(/ipcServer\.sendToAgent\(/);
46
+ //
47
+ // The raw `ipcServer.sendToAgent` was replaced by
48
+ // `deliverResumeSyntheticOrBuffer` (the mid-turn-strand fix,
49
+ // 2026-06-14): a resume delivered while the grant-requesting turn
50
+ // is still finishing used to strand in claude's composer
51
+ // (delivered=true but mid-turn → #1556). The helper turn-gates the
52
+ // send (buffer-until-idle when a turn is in flight) so the resume
53
+ // always lands as a fresh turn. Pinning the helper call (not raw
54
+ // sendToAgent) is the new load-bearing contract.
55
+ expect(block, "missing deliverResumeSyntheticOrBuffer call").toMatch(
56
+ /deliverResumeSyntheticOrBuffer\(/,
57
+ );
47
58
  // Must run AFTER the mint-success path (i.e., after the
48
59
  // `result.kind === 'error'` early-return guard).
49
60
  const errorReturn = block.indexOf("result.kind === 'error'");
50
- const sendIdx = block.indexOf("ipcServer.sendToAgent(");
61
+ const sendIdx = block.indexOf("deliverResumeSyntheticOrBuffer(");
51
62
  expect(errorReturn).toBeGreaterThan(0);
52
- expect(sendIdx, "sendToAgent must come AFTER the error-return early exit").toBeGreaterThan(errorReturn);
63
+ expect(sendIdx, "the resume helper must come AFTER the error-return early exit").toBeGreaterThan(errorReturn);
53
64
  });
54
65
 
55
66
  it("delegates inbound construction to buildVaultGrantApprovedInbound", () => {
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Regression guard — vault/secret RESUME synthetics are turn-gated
3
+ * (the clerk `hotdoc/credentials` mid-turn-strand, 2026-06-14).
4
+ *
5
+ * THE BUG: when an operator approved a vault grant (or provided a
6
+ * secret, or completed a save) WHILE the agent's grant-requesting turn
7
+ * was still finishing, the gateway did a raw `ipcServer.sendToAgent` of
8
+ * the resume synthetic. The socket write succeeded (`delivered=true`)
9
+ * but claude was mid-turn, so the channel notification was typed into
10
+ * its TUI composer and stranded by the turn-completion race (#1556).
11
+ * The pending-inbound buffer never rescued it (it only catches
12
+ * `delivered=false`), so the agent sat idle until the operator manually
13
+ * poked it.
14
+ *
15
+ * Live proof (clerk, 2026-06-13 22:10:57):
16
+ * 22:10:57.098 vault_grant_approved injection delivered=true
17
+ * 22:10:57.277 turn_end #14081 finalAnswer=true (still mid-turn!)
18
+ * 22:12:57.713 inbound msg=14085 → turnStart (operator poke, 2m later)
19
+ *
20
+ * THE FIX: every resume synthetic goes through
21
+ * `deliverResumeSyntheticOrBuffer`, which consults the SAME
22
+ * `decideInboundDelivery` gate the Telegram handleInbound path uses —
23
+ * mid-turn → `buffer-until-idle` (flushed cleanly at turn-end). This
24
+ * file pins (a) the gate decision for a resume synthetic's
25
+ * shape, and (b) that no resume callsite regressed to a raw
26
+ * `ipcServer.sendToAgent`.
27
+ */
28
+ import { describe, it, expect } from "vitest";
29
+ import { readFileSync } from "node:fs";
30
+ import { resolve } from "node:path";
31
+ import { decideInboundDelivery } from "../gateway/inbound-delivery-gate.js";
32
+
33
+ const gatewaySrc = readFileSync(
34
+ resolve(__dirname, "..", "gateway", "gateway.ts"),
35
+ "utf-8",
36
+ );
37
+
38
+ describe("resume synthetics use the turn-gate (mid-turn → buffer)", () => {
39
+ it("a resume synthetic's gate shape buffers mid-turn, delivers when idle", () => {
40
+ // A resume synthetic is never steering and never an interrupt — the
41
+ // exact inputs deliverResumeSyntheticOrBuffer passes to the gate.
42
+ const shape = { isSteering: false as const, isInterrupt: false as const };
43
+ expect(decideInboundDelivery({ ...shape, turnInFlight: true })).toBe(
44
+ "buffer-until-idle",
45
+ );
46
+ expect(decideInboundDelivery({ ...shape, turnInFlight: false })).toBe(
47
+ "deliver",
48
+ );
49
+ });
50
+
51
+ it("the helper exists and gates on decideInboundDelivery before sending", () => {
52
+ const start = gatewaySrc.indexOf(
53
+ "function deliverResumeSyntheticOrBuffer",
54
+ );
55
+ expect(start, "deliverResumeSyntheticOrBuffer helper missing").toBeGreaterThan(0);
56
+ const body = gatewaySrc.slice(start, start + 900);
57
+ // Gate consulted...
58
+ expect(body).toMatch(/decideInboundDelivery\(/);
59
+ // ...and the buffer-until-idle branch buffers BEFORE any send.
60
+ const gateIdx = body.indexOf("decideInboundDelivery(");
61
+ const bufferIdx = body.indexOf("pendingInboundBuffer.push(");
62
+ const sendIdx = body.indexOf("ipcServer.sendToAgent(");
63
+ expect(gateIdx).toBeGreaterThan(0);
64
+ expect(bufferIdx, "must buffer in the helper").toBeGreaterThan(gateIdx);
65
+ expect(sendIdx, "must still deliver in the idle branch").toBeGreaterThan(gateIdx);
66
+ expect(bufferIdx, "buffer-until-idle branch precedes the send branch").toBeLessThan(sendIdx);
67
+ });
68
+
69
+ it("no resume synthetic is sent via a raw ungated ipcServer.sendToAgent", () => {
70
+ // Every resume wake-up — vault_grant_approved/denied, secret_provided/
71
+ // declined, secret_provide_failed, vault_save_completed/failed/discarded
72
+ // — must route through the helper. A raw sendToAgent of one of these
73
+ // named inbound vars would reintroduce the mid-turn strand. The helper
74
+ // deliberately names its param `inbound` (NOT any of these), so the
75
+ // ONLY legitimate raw sendToAgent is the helper's own
76
+ // `ipcServer.sendToAgent(agent, inbound)`; every resume-synthetic var
77
+ // name below must be absent as a raw send argument.
78
+ const rawResumeSends = [
79
+ ...gatewaySrc.matchAll(
80
+ /ipcServer\.sendToAgent\([^,]+,\s*(synthetic|failMsg|denyInbound|discardInbound|failInbound|okInbound)\)/g,
81
+ ),
82
+ ];
83
+ expect(
84
+ rawResumeSends.map((m) => m[1]),
85
+ "resume synthetic sent via raw sendToAgent — must use deliverResumeSyntheticOrBuffer",
86
+ ).toEqual([]);
87
+ });
88
+
89
+ it("the helper's send uses a param name distinct from every resume var (keeps the grep guard honest)", () => {
90
+ // If the helper param were renamed back to `synthetic`, the guard
91
+ // above would get a false pass (the helper's own send would mask a
92
+ // regressed callsite). Pin the param name.
93
+ const start = gatewaySrc.indexOf("function deliverResumeSyntheticOrBuffer");
94
+ const sig = gatewaySrc.slice(start, start + 120);
95
+ expect(sig).toMatch(/deliverResumeSyntheticOrBuffer\(agent: string, inbound: InboundMessage\)/);
96
+ });
97
+ });
@@ -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
@@ -374,6 +374,7 @@ export function switchroomHelpText(agentName: string): string {
374
374
  `<code>/update</code> — dry-run plan; <code>/update apply</code> — actually pull images, reconcile, restart`,
375
375
  `<code>/restart [name|all]</code> — bounce agent (drains in-flight turn by default)`,
376
376
  `<code>/version</code> — show versions + running agent health summary`,
377
+ `<code>/whoami</code> — this agent's sandbox: tools, MCP, vault key-names, powers`,
377
378
  ``,
378
379
  `<b>Auth &amp; config</b>`,
379
380
  `<code>/auth</code> — auth status or actions`,