switchroom 0.15.21 → 0.15.22

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.
@@ -50477,8 +50477,8 @@ var {
50477
50477
  } = import__.default;
50478
50478
 
50479
50479
  // src/build-info.ts
50480
- var VERSION = "0.15.21";
50481
- var COMMIT_SHA = "36706e85";
50480
+ var VERSION = "0.15.22";
50481
+ var COMMIT_SHA = "a6c13429";
50482
50482
 
50483
50483
  // src/cli/agent.ts
50484
50484
  init_source();
@@ -20817,6 +20817,85 @@ function stripCallerAllow(cfg, caller) {
20817
20817
  return clone;
20818
20818
  }
20819
20819
 
20820
+ // src/host-control/config-blast-radius.ts
20821
+ function toObject2(v) {
20822
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
20823
+ }
20824
+ function changedConfigPaths(before, after, prefix = "") {
20825
+ if (deepEqual(before, after))
20826
+ return [];
20827
+ const bObj = before && typeof before === "object" && !Array.isArray(before);
20828
+ const aObj = after && typeof after === "object" && !Array.isArray(after);
20829
+ if (bObj && aObj) {
20830
+ const keys = new Set([
20831
+ ...Object.keys(before),
20832
+ ...Object.keys(after)
20833
+ ]);
20834
+ const out = [];
20835
+ for (const k of keys) {
20836
+ out.push(...changedConfigPaths(before[k], after[k], prefix ? `${prefix}.${k}` : k));
20837
+ }
20838
+ return out;
20839
+ }
20840
+ return [prefix || "<root>"];
20841
+ }
20842
+ function deepEqual(a, b) {
20843
+ if (a === b)
20844
+ return true;
20845
+ if (typeof a !== typeof b)
20846
+ return false;
20847
+ if (a && b && typeof a === "object") {
20848
+ if (Array.isArray(a) !== Array.isArray(b))
20849
+ return false;
20850
+ if (Array.isArray(a) && Array.isArray(b)) {
20851
+ if (a.length !== b.length)
20852
+ return false;
20853
+ return a.every((x, i) => deepEqual(x, b[i]));
20854
+ }
20855
+ const ao = a;
20856
+ const bo = b;
20857
+ const keys = new Set([...Object.keys(ao), ...Object.keys(bo)]);
20858
+ for (const k of keys)
20859
+ if (!deepEqual(ao[k], bo[k]))
20860
+ return false;
20861
+ return true;
20862
+ }
20863
+ return false;
20864
+ }
20865
+ function classifyBlastRadius(beforeYaml, afterYaml) {
20866
+ let before;
20867
+ let after;
20868
+ try {
20869
+ before = toObject2($parse(beforeYaml));
20870
+ after = toObject2($parse(afterYaml));
20871
+ } catch {
20872
+ return { agents: [], fleetWide: true, changedPaths: ["<unparseable>"] };
20873
+ }
20874
+ const changedPaths = changedConfigPaths(before, after).sort();
20875
+ if (changedPaths.length === 0) {
20876
+ return { agents: [], fleetWide: false, changedPaths: [] };
20877
+ }
20878
+ const agents = new Set;
20879
+ let fleetWide = false;
20880
+ for (const path2 of changedPaths) {
20881
+ if (path2 === "<root>") {
20882
+ fleetWide = true;
20883
+ continue;
20884
+ }
20885
+ const segs = path2.split(".");
20886
+ if (segs[0] === "agents" && segs.length >= 2) {
20887
+ agents.add(segs[1]);
20888
+ } else {
20889
+ fleetWide = true;
20890
+ }
20891
+ }
20892
+ return {
20893
+ agents: fleetWide ? [] : [...agents].sort(),
20894
+ fleetWide,
20895
+ changedPaths
20896
+ };
20897
+ }
20898
+
20820
20899
  // src/host-control/server.ts
20821
20900
  function resolveDigests(imageRefs) {
20822
20901
  const out = new Map;
@@ -21509,7 +21588,12 @@ class HostdServer {
21509
21588
  const runner = this.opts.runReconcile ?? (async () => this.runSwitchroom(["apply"]));
21510
21589
  const recRes = await runner({ requestId: approvalId });
21511
21590
  if (recRes.exit_code === 0) {
21512
- await approval.finalize({ outcome: "applied" });
21591
+ const blast = classifyBlastRadius(snapshot, postApply);
21592
+ await approval.finalize({
21593
+ outcome: "applied",
21594
+ affectedAgents: blast.agents,
21595
+ fleetWide: blast.fleetWide
21596
+ });
21513
21597
  return {
21514
21598
  v: 1,
21515
21599
  request_id: req.request_id,
@@ -21893,7 +21977,9 @@ class SocketApprovalGateway {
21893
21977
  type: "request_config_finalize",
21894
21978
  requestId: req.requestId,
21895
21979
  outcome: outcome.outcome,
21896
- ...outcome.detail ? { detail: outcome.detail } : {}
21980
+ ...outcome.detail ? { detail: outcome.detail } : {},
21981
+ ...outcome.affectedAgents ? { affectedAgents: outcome.affectedAgents } : {},
21982
+ ...outcome.fleetWide !== undefined ? { fleetWide: outcome.fleetWide } : {}
21897
21983
  }) + `
21898
21984
  `);
21899
21985
  client2.end();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.21",
3
+ "version": "0.15.22",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31013,6 +31013,7 @@ __export(exports_config_approval_handler, {
31013
31013
  parseConfigApprovalCallback: () => parseConfigApprovalCallback,
31014
31014
  handleRequestConfigFinalize: () => handleRequestConfigFinalize,
31015
31015
  handleRequestConfigApproval: () => handleRequestConfigApproval,
31016
+ buildLiveNote: () => buildLiveNote,
31016
31017
  buildConfigApprovalCardBody: () => buildConfigApprovalCardBody,
31017
31018
  _resetPendingConfigApprovalsForTest: () => _resetPendingConfigApprovalsForTest,
31018
31019
  _peekPendingConfigApprovalForTest: () => _peekPendingConfigApprovalForTest,
@@ -31156,6 +31157,22 @@ async function resolvePendingConfigApproval(requestId, verdict, deps) {
31156
31157
  }
31157
31158
  return true;
31158
31159
  }
31160
+ function buildLiveNote(affectedAgents, fleetWide) {
31161
+ if (fleetWide) {
31162
+ return `
31163
+
31164
+ \u26a0\ufe0f Shared config changed \u2014 affects all agents. Not live until they ` + `restart: run <code>switchroom rollout</code> (or <code>/update apply</code>).`;
31165
+ }
31166
+ const agents = (affectedAgents ?? []).filter((a) => typeof a === "string" && a.length > 0);
31167
+ if (agents.length === 0)
31168
+ return "";
31169
+ const list2 = agents.map(escapeHtml12).join(", ");
31170
+ const cmds = agents.map((a) => `/restart ${escapeHtml12(a)}`).join(" \u00b7 ");
31171
+ return `
31172
+
31173
+ \uD83D\uDD04 Not live until restart \u2014 affects: <b>${list2}</b>
31174
+ ${cmds}`;
31175
+ }
31159
31176
  async function handleRequestConfigFinalize(_client, msg, deps) {
31160
31177
  const entry = pending.get(msg.requestId);
31161
31178
  if (!entry) {
@@ -31163,8 +31180,9 @@ async function handleRequestConfigFinalize(_client, msg, deps) {
31163
31180
  return;
31164
31181
  }
31165
31182
  pending.delete(msg.requestId);
31183
+ const liveNote = msg.outcome === "applied" ? buildLiveNote(msg.affectedAgents, msg.fleetWide) : "";
31166
31184
  const body = msg.outcome === "applied" ? `\u2705 <b>Applied</b>${msg.detail ? `
31167
- ${escapeHtml12(msg.detail)}` : ""}` : `\u26a0\ufe0f <b>Reconcile failed; rolled back</b>${msg.detail ? `
31185
+ ${escapeHtml12(msg.detail)}` : ""}${liveNote}` : `\u26a0\ufe0f <b>Reconcile failed; rolled back</b>${msg.detail ? `
31168
31186
  ${escapeHtml12(msg.detail)}` : ""}`;
31169
31187
  try {
31170
31188
  await deps.editCard({
@@ -47641,6 +47659,16 @@ function validateClientMessage(msg) {
47641
47659
  return false;
47642
47660
  if (m.detail !== undefined && (typeof m.detail !== "string" || m.detail.length > 500))
47643
47661
  return false;
47662
+ if (m.affectedAgents !== undefined) {
47663
+ if (!Array.isArray(m.affectedAgents) || m.affectedAgents.length > 64)
47664
+ return false;
47665
+ for (const a of m.affectedAgents) {
47666
+ if (typeof a !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(a))
47667
+ return false;
47668
+ }
47669
+ }
47670
+ if (m.fleetWide !== undefined && typeof m.fleetWide !== "boolean")
47671
+ return false;
47644
47672
  return true;
47645
47673
  }
47646
47674
  case "request_drive_approval": {
@@ -54392,10 +54420,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
54392
54420
  }
54393
54421
 
54394
54422
  // ../src/build-info.ts
54395
- var VERSION = "0.15.21";
54396
- var COMMIT_SHA = "36706e85";
54397
- var COMMIT_DATE = "2026-06-14T01:34:05Z";
54398
- var LATEST_PR = 2345;
54423
+ var VERSION = "0.15.22";
54424
+ var COMMIT_SHA = "a6c13429";
54425
+ var COMMIT_DATE = "2026-06-14T03:27:21Z";
54426
+ var LATEST_PR = 2349;
54399
54427
  var COMMITS_AHEAD_OF_TAG = 0;
54400
54428
 
54401
54429
  // gateway/boot-version.ts
@@ -13,6 +13,7 @@
13
13
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
14
14
  import {
15
15
  buildConfigApprovalCardBody,
16
+ buildLiveNote,
16
17
  handleRequestConfigApproval,
17
18
  handleRequestConfigFinalize,
18
19
  parseConfigApprovalCallback,
@@ -266,6 +267,29 @@ describe("timeout path", () => {
266
267
  });
267
268
  });
268
269
 
270
+ describe("buildLiveNote", () => {
271
+ it("names specific affected agents + the per-agent restart command", () => {
272
+ const note = buildLiveNote(["clerk", "gymbro"], false);
273
+ expect(note).toContain("clerk, gymbro");
274
+ expect(note).toContain("/restart clerk");
275
+ expect(note).toContain("/restart gymbro");
276
+ expect(note).toContain("Not live until restart");
277
+ });
278
+ it("guides to a full rollout when fleet-wide (no per-agent list)", () => {
279
+ const note = buildLiveNote([], true);
280
+ expect(note).toContain("all agents");
281
+ expect(note).toContain("switchroom rollout");
282
+ expect(note).not.toContain("/restart");
283
+ });
284
+ it("is empty when nothing is runtime-affected", () => {
285
+ expect(buildLiveNote([], false)).toBe("");
286
+ expect(buildLiveNote(undefined, undefined)).toBe("");
287
+ });
288
+ it("HTML-escapes agent names", () => {
289
+ expect(buildLiveNote(["a<b>"], false)).toContain("a&lt;b&gt;");
290
+ });
291
+ });
292
+
269
293
  describe("handleRequestConfigFinalize", () => {
270
294
  it("edits the card to '✅ Applied' on success", async () => {
271
295
  const { client, deps, editCalls } = fakeDeps();
@@ -377,6 +377,27 @@ export async function resolvePendingConfigApproval(
377
377
  return true;
378
378
  }
379
379
 
380
+ /**
381
+ * The "make it live" note appended to an Applied card. claude loads config at
382
+ * boot, so an applied edit is inert in the running agents until they restart —
383
+ * this names exactly what must bounce (and the command) instead of letting the
384
+ * change silently not take effect. Fleet-wide (shared config) → guide to a full
385
+ * rollout, never a per-agent list. Empty when nothing runtime-affected.
386
+ */
387
+ export function buildLiveNote(affectedAgents?: string[], fleetWide?: boolean): string {
388
+ if (fleetWide) {
389
+ return (
390
+ `\n\n⚠️ Shared config changed — affects all agents. Not live until they ` +
391
+ `restart: run <code>switchroom rollout</code> (or <code>/update apply</code>).`
392
+ );
393
+ }
394
+ const agents = (affectedAgents ?? []).filter((a) => typeof a === "string" && a.length > 0);
395
+ if (agents.length === 0) return "";
396
+ const list = agents.map(escapeHtml).join(", ");
397
+ const cmds = agents.map((a) => `/restart ${escapeHtml(a)}`).join(" · ");
398
+ return `\n\n🔄 Not live until restart — affects: <b>${list}</b>\n${cmds}`;
399
+ }
400
+
380
401
  /** IPC `request_config_finalize` handler — edits the card to the terminal outcome. */
381
402
  export async function handleRequestConfigFinalize(
382
403
  _client: Pick<IpcClient, "send">,
@@ -393,9 +414,15 @@ export async function handleRequestConfigFinalize(
393
414
  // Clean up the pending entry — finalize is the terminal transition.
394
415
  pending.delete(msg.requestId);
395
416
 
417
+ // On apply, tell the operator what must restart for the edit to go LIVE —
418
+ // claude loads config at boot, so an applied edit is inert until restart.
419
+ // Specific agents → name them + the one-liner to bounce them; shared config
420
+ // → guide to a full rollout (never silently leave the change un-live).
421
+ const liveNote =
422
+ msg.outcome === "applied" ? buildLiveNote(msg.affectedAgents, msg.fleetWide) : "";
396
423
  const body =
397
424
  msg.outcome === "applied"
398
- ? `✅ <b>Applied</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}`
425
+ ? `✅ <b>Applied</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}${liveNote}`
399
426
  : `⚠️ <b>Reconcile failed; rolled back</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}`;
400
427
  try {
401
428
  await deps.editCard({
@@ -391,6 +391,14 @@ export interface RequestConfigFinalizeMessage {
391
391
  outcome: "applied" | "reconcile_failed_rolled_back";
392
392
  /** Optional short diagnostic appended to the card body. */
393
393
  detail?: string;
394
+ /**
395
+ * On `applied`: agents that must restart for the edit to go live (claude
396
+ * loads config at boot). Empty when `fleetWide`. The finalize card offers a
397
+ * one-tap restart of these. Computed host-side by classifyBlastRadius.
398
+ */
399
+ affectedAgents?: string[];
400
+ /** On `applied`: a shared/inherited key changed → all agents affected. */
401
+ fleetWide?: boolean;
394
402
  }
395
403
 
396
404
  /**
@@ -307,6 +307,16 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
307
307
  if (m.detail !== undefined
308
308
  && (typeof m.detail !== "string"
309
309
  || (m.detail as string).length > 500)) return false;
310
+ // affectedAgents (optional): a bounded list of kebab-case agent names —
311
+ // they drive a restart button, so validate shape + charclass even though
312
+ // the sender (hostd) is trusted (defense in depth).
313
+ if (m.affectedAgents !== undefined) {
314
+ if (!Array.isArray(m.affectedAgents) || (m.affectedAgents as unknown[]).length > 64) return false;
315
+ for (const a of m.affectedAgents as unknown[]) {
316
+ if (typeof a !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(a)) return false;
317
+ }
318
+ }
319
+ if (m.fleetWide !== undefined && typeof m.fleetWide !== "boolean") return false;
310
320
  return true;
311
321
  }
312
322
  case "request_drive_approval": {
@@ -332,4 +332,32 @@ describe('validateClientMessage', () => {
332
332
  expect(validateClientMessage({ ...valid, ttlMs: '300000' })).toBe(false)
333
333
  })
334
334
  })
335
+
336
+ describe('request_config_finalize — affectedAgents / fleetWide (#2346)', () => {
337
+ const valid = { type: 'request_config_finalize', requestId: 'r1', outcome: 'applied' }
338
+
339
+ it('accepts the bare message and the new optional fields', () => {
340
+ expect(validateClientMessage(valid)).toBe(true)
341
+ expect(validateClientMessage({ ...valid, affectedAgents: ['clerk', 'gymbro'], fleetWide: false })).toBe(true)
342
+ expect(validateClientMessage({ ...valid, affectedAgents: [], fleetWide: true })).toBe(true)
343
+ })
344
+
345
+ it('rejects a non-array affectedAgents', () => {
346
+ expect(validateClientMessage({ ...valid, affectedAgents: 'clerk' })).toBe(false)
347
+ })
348
+
349
+ it('rejects affectedAgents with an unsafe / non-kebab name (defense in depth)', () => {
350
+ expect(validateClientMessage({ ...valid, affectedAgents: ['../etc'] })).toBe(false)
351
+ expect(validateClientMessage({ ...valid, affectedAgents: ['has space'] })).toBe(false)
352
+ expect(validateClientMessage({ ...valid, affectedAgents: [42] })).toBe(false)
353
+ })
354
+
355
+ it('rejects an over-long affectedAgents list', () => {
356
+ expect(validateClientMessage({ ...valid, affectedAgents: Array.from({ length: 65 }, (_, i) => `a${i}`) })).toBe(false)
357
+ })
358
+
359
+ it('rejects a non-boolean fleetWide', () => {
360
+ expect(validateClientMessage({ ...valid, fleetWide: 'yes' })).toBe(false)
361
+ })
362
+ })
335
363
  })
@@ -0,0 +1,90 @@
1
+ /**
2
+ * UAT — `/effort` command (#2336, #2342): show + tap-to-switch the
3
+ * Claude reasoning effort for the live session. The effort sibling of
4
+ * `/model`; the picker-driven menu is the same shape.
5
+ *
6
+ * Verified live on test-harness v0.15.21. Switches are session-only
7
+ * (revert on restart), so the tap test restores the original level.
8
+ *
9
+ * Self-skips green on an unwired host (spinUp can't resolve the chat).
10
+ */
11
+ import { describe, expect, it } from "vitest";
12
+ import { spinUp } from "../harness.js";
13
+
14
+ const AGENT = "test-harness";
15
+ const T = 30_000;
16
+
17
+ describe("uat: /effort — show, tap-switch, bad-arg", () => {
18
+ it(
19
+ "bare /effort shows the effort menu with a tap keyboard",
20
+ async () => {
21
+ const sc = await spinUp({ agent: AGENT });
22
+ try {
23
+ await sc.sendDM("/effort");
24
+ const menu = await sc.expectMessage(/Effort —/, { from: "bot", timeout: T });
25
+ expect(menu.text).toMatch(/faster → smarter|low · medium · high/i);
26
+ expect(menu.text).toMatch(/switchroom\.yaml/i);
27
+ const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
28
+ const labels = (kb ?? []).flat().map((b) => b.text);
29
+ expect(labels.some((t) => /low/i.test(t)), "low button present").toBe(true);
30
+ expect(labels.some((t) => /max/i.test(t)), "max button present").toBe(true);
31
+ } finally {
32
+ await sc.tearDown();
33
+ }
34
+ },
35
+ 60_000,
36
+ );
37
+
38
+ it(
39
+ "tapping a level switches the live session, then restores",
40
+ async () => {
41
+ const sc = await spinUp({ agent: AGENT });
42
+ try {
43
+ await sc.sendDM("/effort");
44
+ const menu = await sc.expectMessage(/Effort —/, { from: "bot", timeout: T });
45
+ const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
46
+ const flat = (kb ?? []).flat();
47
+ // The current level is prefixed with ✅; pick a DIFFERENT one.
48
+ const current = flat.find((b) => /✅/.test(b.text));
49
+ const target = flat.find(
50
+ (b) => b.callbackData && !/✅/.test(b.text) && /medium|high/i.test(b.text),
51
+ );
52
+ expect(target, "a non-current effort button to tap").toBeDefined();
53
+ await sc.driver.pressButton(sc.botUserId, menu.messageId, target!.callbackData!);
54
+ // The card edits in place to prepend a confirmation line.
55
+ await new Promise((r) => setTimeout(r, 4000));
56
+ const after = await sc.driver.getMessage(sc.botUserId, menu.messageId);
57
+ expect(after?.text ?? "").toMatch(/Effort →|Switched|effort/i);
58
+
59
+ // Restore the original level so test-harness isn't left changed.
60
+ if (current?.callbackData) {
61
+ const kb2 = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
62
+ const restore = (kb2 ?? [])
63
+ .flat()
64
+ .find((b) => b.callbackData === current.callbackData);
65
+ if (restore?.callbackData) {
66
+ await sc.driver.pressButton(sc.botUserId, menu.messageId, restore.callbackData);
67
+ }
68
+ }
69
+ } finally {
70
+ await sc.tearDown();
71
+ }
72
+ },
73
+ 90_000,
74
+ );
75
+
76
+ it(
77
+ "/effort bogus → reply (error/help), never silence",
78
+ async () => {
79
+ const sc = await spinUp({ agent: AGENT });
80
+ try {
81
+ await sc.sendDM("/effort definitely-not-a-level");
82
+ const reply = await sc.expectMessage(/\S/, { from: "bot", timeout: T });
83
+ expect(reply.text.length).toBeGreaterThan(0);
84
+ } finally {
85
+ await sc.tearDown();
86
+ }
87
+ },
88
+ 60_000,
89
+ );
90
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * End-to-end UAT — agent AUTO-RESUMES after a vault grant approval,
3
+ * under the live `telegram-id` (single-factor) posture. Regression gate
4
+ * for the mid-turn-strand fix (#2340).
5
+ *
6
+ * THE BUG (#2340, clerk 2026-06-13): the gateway injects a synthetic
7
+ * "✅ approved — resume your task" inbound after the operator taps
8
+ * Approve. That inject used a raw `sendToAgent` and only buffered on a
9
+ * disconnected bridge. When the approval landed WHILE the agent's
10
+ * grant-requesting turn was still finishing, the socket write succeeded
11
+ * (`delivered=true`) but claude was mid-turn, so the channel
12
+ * notification stranded in its TUI composer (the #1556 race) and the
13
+ * agent sat idle until manually poked. Fix: route the resume through
14
+ * the same turn-gate as normal inbounds (buffer mid-turn, flush at
15
+ * turn-end). This scenario proves the agent resumes on its own.
16
+ *
17
+ * Posture: the live fleet runs `vault.broker.approvalAuth: telegram-id`
18
+ * (broker auto-unlocked), so tapping Approve mints silently — NO
19
+ * passphrase prompt. The sibling `vault-grant-auto-resume-dm.test.ts`
20
+ * covers the (now-legacy) passphrase posture and stays skipped.
21
+ *
22
+ * No sacrificial key needed: `vault_request_access` mints an ACL grant
23
+ * for the key *pattern*; the card → approve → resume cycle fires
24
+ * whether or not the key holds a value. We assert the RESUME (a fresh
25
+ * bot turn after the tap, with no driver nudge), not a secret value —
26
+ * so nothing sensitive is read or leaked.
27
+ *
28
+ * Self-skips green when the driver can't resolve the chat (unwired
29
+ * host), matching the other opt-in scenarios — uat/** is excluded from
30
+ * gating CI anyway. Mutates host vault state (mints a short grant on
31
+ * test-harness); harmless + TTL-expiring.
32
+ */
33
+
34
+ import { describe, expect, it } from "vitest";
35
+ import { spinUp } from "../harness.js";
36
+
37
+ const AGENT = "test-harness";
38
+ const KEY = "uat/resume-probe";
39
+
40
+ describe("uat: agent auto-resumes after vault grant approval — telegram-id (#2340)", () => {
41
+ it(
42
+ "fires card → operator taps Approve → agent emits a NEW turn with no nudge",
43
+ async () => {
44
+ const sc = await spinUp({ agent: AGENT });
45
+ try {
46
+ // 1. Ask the agent to request access then resume. Steer it to
47
+ // end its turn after the tool call so the approval lands at
48
+ // a turn boundary — the exact window #2340 fixes.
49
+ await sc.sendDM(
50
+ `Call your vault_request_access MCP tool with key="${KEY}", ` +
51
+ `scope="read", reason="UAT #2340 resume gate". After the tool ` +
52
+ `returns "waiting for operator", END YOUR TURN. When the ` +
53
+ `operator approves, you should AUTOMATICALLY resume: confirm ` +
54
+ `the grant landed and that you saw the approval for ${KEY}.`,
55
+ );
56
+
57
+ // 2. Wait for the approval card.
58
+ const card = await sc.expectMessage(/wants vault access/i, {
59
+ from: "bot",
60
+ timeout: 120_000,
61
+ });
62
+
63
+ // 3. Find + tap the Approve button.
64
+ const kb = await sc.driver.getKeyboard(sc.botUserId, card.messageId);
65
+ const approve = kb!
66
+ .flat()
67
+ .find((b) => b.callbackData !== undefined && /approve/i.test(b.text));
68
+ expect(approve, "Approve button present on the card").toBeDefined();
69
+ const tapAtMsgId = card.messageId;
70
+ await sc.driver.pressButton(sc.botUserId, card.messageId, approve!.callbackData!);
71
+
72
+ // 4. Single-factor: card edits to "Granted" with no passphrase
73
+ // prompt. Anchor on the grant confirmation.
74
+ await sc.expectMessage(/Granted|already has|access to/i, {
75
+ from: "bot",
76
+ timeout: 30_000,
77
+ });
78
+
79
+ // 5. THE #2340 ASSERTION: the agent auto-resumes — a NEW bot
80
+ // turn referencing the approval/grant/key, WITHOUT the driver
81
+ // sending anything else. Pre-fix this stranded mid-turn and
82
+ // timed out. The resume reply must be a message newer than
83
+ // the card we tapped (not the card edit).
84
+ const resume = await sc.expectMessage(
85
+ (m) =>
86
+ m.messageId > tapAtMsgId &&
87
+ /(approv|grant|access|resume|landed|✅)/i.test(m.text),
88
+ { from: "bot", timeout: 150_000 },
89
+ );
90
+ expect(resume.text.length).toBeGreaterThan(0);
91
+ } finally {
92
+ await sc.tearDown();
93
+ }
94
+ },
95
+ 360_000,
96
+ );
97
+ });
@@ -34,10 +34,13 @@ describe("uat: /model command — show, switch, bad-name", () => {
34
34
  const sc = await spinUp({ agent: AGENT });
35
35
  try {
36
36
  await sc.sendDM("/model");
37
- // v2 (picker-driven menu): "Now: <model>"; v1 / fallback path:
38
- // "Configured: <model>". Either proves the gateway handled the
39
- // command rather than forwarding it to claude as plain text.
40
- const shape = /Now:|Configured:/i;
37
+ // v2 (picker-driven menu) renders the live model as
38
+ // "Default (new sessions): <model>" (shipped wording, verified
39
+ // live on test-harness v0.15.21); "Now: <model>" was the
40
+ // pre-ship wording; v1 / fallback path renders "Configured:
41
+ // <model>". Any of these proves the gateway handled the command
42
+ // rather than forwarding it to claude as plain text.
43
+ const shape = /Default \(new sessions\):|Now:|Configured:/i;
41
44
  const reply = await sc.expectMessage(shape, {
42
45
  from: "bot",
43
46
  timeout: REPLY_TIMEOUT_MS,
@@ -0,0 +1,71 @@
1
+ /**
2
+ * UAT — `/model` v2 dashboard BUTTON TAP (#2263, #2270, #2271). The
3
+ * existing jtbd-model-command scenario covers the bare dashboard +
4
+ * typed-arg forms; this one exercises the genuinely new path the
5
+ * mtcute driver can now drive: tapping a model button to switch the
6
+ * live session via the picker, and the menu never leaving a dead card
7
+ * (#2270 — keeps buttons + clears the toast).
8
+ *
9
+ * Switches are session-only (revert on restart); the test taps a
10
+ * different model then restores the original so test-harness isn't
11
+ * left changed.
12
+ *
13
+ * Self-skips green on an unwired host.
14
+ */
15
+ import { describe, expect, it } from "vitest";
16
+ import { spinUp } from "../harness.js";
17
+
18
+ const AGENT = "test-harness";
19
+
20
+ describe("uat: /model dashboard button tap switches the live session", () => {
21
+ it(
22
+ "tap a non-current model → switch confirmation; then restore",
23
+ async () => {
24
+ const sc = await spinUp({ agent: AGENT });
25
+ try {
26
+ await sc.sendDM("/model");
27
+ const menu = await sc.expectMessage(/Default \(new sessions\):|Now:/i, {
28
+ from: "bot",
29
+ timeout: 30_000,
30
+ });
31
+ const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
32
+ const flat = (kb ?? []).flat().filter((b) => b.callbackData);
33
+ // Model buttons carry mdl:s:<tag>; the current one is prefixed
34
+ // ✅. Refresh (mdl:r) is excluded — pick a non-current model.
35
+ const originalLabel = flat
36
+ .find((b) => /✅/.test(b.text))
37
+ ?.text.replace(/^✅\s*/, "");
38
+ const target = flat.find(
39
+ (b) => /mdl:s:/.test(b.callbackData!) && !/✅/.test(b.text),
40
+ );
41
+ expect(target, "a non-current model button to tap").toBeDefined();
42
+
43
+ await sc.driver.pressButton(sc.botUserId, menu.messageId, target!.callbackData!);
44
+ await new Promise((r) => setTimeout(r, 6000));
45
+ // #2270: the card never goes dead — it edits in place to a
46
+ // confirmation and KEEPS a keyboard.
47
+ const after = await sc.driver.getMessage(sc.botUserId, menu.messageId);
48
+ expect(after?.text ?? "").toMatch(/Set model to|Switched|model/i);
49
+ const kbAfter = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
50
+ expect(
51
+ (kbAfter ?? []).flat().length,
52
+ "menu keeps its buttons after a tap (no dead card, #2270)",
53
+ ).toBeGreaterThan(0);
54
+
55
+ // Restore the original model: tap the button whose label now
56
+ // matches the original (it's no longer the ✅ row).
57
+ if (originalLabel) {
58
+ const restore = (kbAfter ?? [])
59
+ .flat()
60
+ .find((b) => b.callbackData?.startsWith("mdl:s:") && b.text.replace(/^✅\s*/, "") === originalLabel);
61
+ if (restore?.callbackData) {
62
+ await sc.driver.pressButton(sc.botUserId, menu.messageId, restore.callbackData);
63
+ }
64
+ }
65
+ } finally {
66
+ await sc.tearDown();
67
+ }
68
+ },
69
+ 120_000,
70
+ );
71
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * UAT — `/whoami` (#2341): the operator's read-only view of THIS
3
+ * agent's sandbox (same data the agent's `config whoami` MCP tool and
4
+ * the `switchroom config whoami` host CLI report). Read-only, like
5
+ * `/version`; mutates nothing.
6
+ *
7
+ * Verified live on test-harness v0.15.21. Self-skips green on an
8
+ * unwired host.
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+ import { spinUp } from "../harness.js";
12
+
13
+ const AGENT = "test-harness";
14
+
15
+ describe("uat: /whoami shows the agent sandbox card", () => {
16
+ it(
17
+ "renders tier, model, tools, MCP, and powers",
18
+ async () => {
19
+ const sc = await spinUp({ agent: AGENT });
20
+ try {
21
+ await sc.sendDM("/whoami");
22
+ // Header: "👤 <agent> · <tier>"
23
+ const reply = await sc.expectMessage(/👤\s*test-harness/i, {
24
+ from: "bot",
25
+ timeout: 30_000,
26
+ });
27
+ // The card's load-bearing fields — proves whoami resolved the
28
+ // sandbox (tier/model/tools/mcp/powers), not just echoed a stub.
29
+ expect(reply.text).toMatch(/Model:/i);
30
+ expect(reply.text).toMatch(/Tools:/i);
31
+ expect(reply.text).toMatch(/Powers:/i);
32
+ // Tier marker present in the header (standard / admin / root).
33
+ expect(reply.text).toMatch(/·\s*(standard|admin|root)/i);
34
+ } finally {
35
+ await sc.tearDown();
36
+ }
37
+ },
38
+ 60_000,
39
+ );
40
+ });