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.
- package/dist/cli/switchroom.js +519 -336
- package/package.json +1 -1
- package/profiles/_shared/agent-self-service.md.hbs +24 -15
- package/telegram-plugin/dist/gateway/gateway.js +217 -88
- package/telegram-plugin/gateway/effort-command.ts +56 -47
- package/telegram-plugin/gateway/gateway.ts +178 -39
- package/telegram-plugin/gateway/grant-restart.ts +30 -0
- package/telegram-plugin/tests/effort-command.test.ts +43 -34
- package/telegram-plugin/tests/grant-restart.test.ts +38 -0
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +15 -4
- package/telegram-plugin/tests/vault-resume-turn-gated.test.ts +97 -0
- package/telegram-plugin/welcome-text.ts +2 -1
|
@@ -21,40 +21,29 @@ import {
|
|
|
21
21
|
EFFORT_CALLBACK_PREFIX,
|
|
22
22
|
type EffortCommandDeps,
|
|
23
23
|
} from "../gateway/effort-command.js";
|
|
24
|
-
import type {
|
|
25
|
-
|
|
26
|
-
function
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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;
|
|
38
|
+
const calls: Array<{ agent: string; level: string }> = [];
|
|
49
39
|
const deps: EffortCommandDeps = {
|
|
50
|
-
|
|
51
|
-
calls.push({ agent,
|
|
52
|
-
return
|
|
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
|
|
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",
|
|
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
|
|
139
|
-
const { deps } = makeDeps({
|
|
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("
|
|
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
|
|
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>
|
|
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",
|
|
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
|
|
178
|
-
const { deps } = makeDeps({
|
|
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("
|
|
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
|
-
|
|
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("
|
|
61
|
+
const sendIdx = block.indexOf("deliverResumeSyntheticOrBuffer(");
|
|
51
62
|
expect(errorReturn).toBeGreaterThan(0);
|
|
52
|
-
expect(sendIdx, "
|
|
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 & config</b>`,
|
|
379
380
|
`<code>/auth</code> — auth status or actions`,
|