switchroom 0.18.7 → 0.18.8

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.
Files changed (54) hide show
  1. package/dist/cli/switchroom.js +905 -758
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/profiles/_base/start.sh.hbs +111 -34
  5. package/skills/switchroom-runtime/SKILL.md +2 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1403 -657
  7. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  8. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  9. package/telegram-plugin/gateway/boot-card.ts +27 -0
  10. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  11. package/telegram-plugin/gateway/gateway.ts +564 -85
  12. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  13. package/telegram-plugin/gateway/model-command.ts +23 -11
  14. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  15. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  16. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  17. package/telegram-plugin/hooks/hooks.json +10 -10
  18. package/telegram-plugin/hooks/run-hook.sh +84 -0
  19. package/telegram-plugin/model-unavailable.ts +26 -0
  20. package/telegram-plugin/pty-partial-handler.ts +39 -0
  21. package/telegram-plugin/render/rich-render.ts +79 -1
  22. package/telegram-plugin/retry-api-call.ts +62 -0
  23. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  24. package/telegram-plugin/silence-poke.ts +14 -0
  25. package/telegram-plugin/stream-controller.ts +156 -38
  26. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  27. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  28. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  29. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  30. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  31. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  32. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
  33. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  34. package/telegram-plugin/tests/model-command.test.ts +2 -2
  35. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  36. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  37. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  38. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  39. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  40. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  41. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  42. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  43. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  44. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  45. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  46. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  47. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  48. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  49. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  50. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  51. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  52. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  53. package/telegram-plugin/voice-ondemand.ts +25 -1
  54. package/telegram-plugin/voice-send.ts +154 -0
@@ -0,0 +1,201 @@
1
+ /**
2
+ * JTBD scenario — mid-flight busy ack (#2995).
3
+ *
4
+ * Serves: `reference/jobs/steer-or-queue-mid-flight.md` — the third
5
+ * mid-flight class (a quick question that neither steers nor queues
6
+ * work of its own) and its latency promise: a mid-flight message
7
+ * arriving while the agent sits inside ONE long blocking tool call
8
+ * gets a visible, silent ack naming the blocking activity within
9
+ * seconds — never minutes of dead air behind a `--watch`-style call.
10
+ *
11
+ * ## What the scenario drives
12
+ *
13
+ * 1. Prompt the agent into a deliberately slow BLOCKING foreground
14
+ * Bash (`sleep`) — explicitly instructed NOT to background it.
15
+ * 2. Wait past the busy-ack step-age threshold (12s), then send a
16
+ * trivial mid-turn status question. It buffers (unmarked mid-turn
17
+ * follow-up = queue).
18
+ * 3. Assert a bot message matching the deterministic busy-ack shape
19
+ * ("⏳ Queued — currently inside `…`") lands within ACK_SLA_MS of
20
+ * the ping, and is SILENT (disable_notification).
21
+ * 4. Assert the real answer to the question arrives later (after the
22
+ * blocking step returns and the buffer flushes).
23
+ * 5. Assert the busy-ack card was cleaned up (deleted) once the
24
+ * answer landed — never a dangling "Queued" line.
25
+ *
26
+ * Negative case: a ping during a SHORT blocking step (well under the 12s
27
+ * threshold, and finished before the deferred re-check could ever see a
28
+ * 12s-old step) gets NO busy-ack card at any point — an ack that fires
29
+ * when the agent was seconds from answering is the job spec's explicit
30
+ * Bad bullet. (A young-step ping during a LONG step DOES get a deferred
31
+ * card once the step ages past the threshold — that is the re-check
32
+ * behaviour, covered by the positive case's latency budget.)
33
+ *
34
+ * ## Conventions
35
+ *
36
+ * - mtcute harness (`spinUp`), skip discipline like siblings: env
37
+ * handled by load-env; the suite hard-fails without creds by design
38
+ * (CI path-gates it to the uat-host runner).
39
+ * - `!m.edited` filters on every observation (PR #2991 lesson — the
40
+ * live draft/feed edits stream constantly; edits must never satisfy
41
+ * a fresh-message expectation).
42
+ */
43
+
44
+ import { describe, expect, it } from "vitest";
45
+ import { spinUp } from "../harness.js";
46
+ import type { ObservedMessage } from "../driver.js";
47
+
48
+ const AGENT = "test-harness";
49
+
50
+ // The gateway constant is 12s (BUSY_ACK_STEP_AGE_THRESHOLD_MS). The
51
+ // warmup is anchored to the SEND, not to observed tool start — the agent
52
+ // needs a few seconds to open the turn and enter the Bash step — so it
53
+ // carries a large margin: even if the tool starts ~15s after the send,
54
+ // the step is comfortably past the threshold when the ping lands.
55
+ const STEP_WARMUP_MS = 35_000;
56
+
57
+ // Latency promise: user-visible ack within seconds of the ping. The card
58
+ // is posted synchronously by the gateway (no model). When the ping lands
59
+ // past the threshold the card is immediate; if the step turns out to be
60
+ // just under, the deferred re-check posts it within (threshold − age).
61
+ // 10s + jitter headroom covers both.
62
+ const ACK_SLA_MS = 10_000;
63
+
64
+ // The blocking sleep. Long enough that warmup + ping + ack all land while
65
+ // the step is still open (even with a late tool start); short enough to
66
+ // keep wall-clock sane.
67
+ const SLEEP_SECS = 120;
68
+
69
+ const BUSY_ACK_RE = /⏳ Queued — currently inside `.+`.*I'll answer when this step finishes/s;
70
+
71
+ const SLOW_PROMPT =
72
+ `For a latency test, run this exact command in the FOREGROUND with your ` +
73
+ `Bash tool (do NOT use run_in_background, do NOT split it up): ` +
74
+ `\`sleep ${SLEEP_SECS} && echo done\`. When it completes, reply with ` +
75
+ `exactly: SLEEP_DONE. Do not send any message before it completes.`;
76
+
77
+ const PING = "Quick question while you work: are you still there? One word is fine.";
78
+
79
+ describe("uat: mid-flight busy ack during a long blocking tool call (#2995)", () => {
80
+ it(
81
+ "a mid-turn ping behind a long blocking Bash gets a silent Queued ack naming the activity, then the real answer, then cleanup",
82
+ async () => {
83
+ const sc = await spinUp({ agent: AGENT });
84
+ try {
85
+ await sc.sendDM(SLOW_PROMPT);
86
+
87
+ // Let the blocking step age past the busy-ack threshold.
88
+ await new Promise((r) => setTimeout(r, STEP_WARMUP_MS));
89
+
90
+ const pingAt = Date.now();
91
+ await sc.sendDM(PING);
92
+
93
+ // The deterministic busy-ack must land within the SLA, silent,
94
+ // naming the blocking activity. `!m.edited` — feed/draft edits
95
+ // must never satisfy this.
96
+ const ack = await sc.expectMessage(
97
+ (m: ObservedMessage) => m.fromBot && !m.edited && BUSY_ACK_RE.test(m.text),
98
+ { from: "bot", timeout: ACK_SLA_MS + 5_000 },
99
+ );
100
+ const ackLatency = Date.now() - pingAt;
101
+ expect(
102
+ ackLatency,
103
+ `busy-ack landed ${ackLatency}ms after the ping — the <10s ` +
104
+ `latency promise for a mid-flight quick question is broken.`,
105
+ ).toBeLessThan(ACK_SLA_MS + 5_000);
106
+ expect(
107
+ ack.silent,
108
+ `busy-ack pinged the user's device (silent=false) — the card ` +
109
+ `must carry disable_notification:true.`,
110
+ ).toBe(true);
111
+ // Names the blocking activity (the Bash sleep), not a generic line.
112
+ expect(ack.text).toMatch(/`.*(Bash|sleep).*`/i);
113
+
114
+ // The real answer to the ping arrives AFTER the blocking step
115
+ // returns and the buffer flushes. Any fresh non-card bot message
116
+ // that isn't the busy-ack or the SLEEP_DONE completion counts —
117
+ // the SLEEP_DONE exclusion is explicit (it answers the FIRST
118
+ // prompt, not the ping).
119
+ const answer = await sc.expectMessage(
120
+ (m: ObservedMessage) =>
121
+ m.fromBot &&
122
+ !m.edited &&
123
+ m.messageId !== ack.messageId &&
124
+ !BUSY_ACK_RE.test(m.text) &&
125
+ !/SLEEP_DONE/i.test(m.text) &&
126
+ m.text.trim().length > 0,
127
+ { from: "bot", timeout: (SLEEP_SECS + 90) * 1000 },
128
+ );
129
+ expect(answer.text.trim().length).toBeGreaterThan(0);
130
+
131
+ // Cleanup: once answers land and the turns wrap, the busy-ack
132
+ // card is deleted (delete-on-answer lifecycle). Poll briefly —
133
+ // the reap is fire-and-forget after the answer/turn-end.
134
+ const cleanupDeadline = Date.now() + 60_000;
135
+ let cardGone = false;
136
+ while (Date.now() < cleanupDeadline) {
137
+ const still = await sc.driver.getMessage(sc.botUserId, ack.messageId);
138
+ if (still == null) {
139
+ cardGone = true;
140
+ break;
141
+ }
142
+ await new Promise((r) => setTimeout(r, 3_000));
143
+ }
144
+ expect(
145
+ cardGone,
146
+ `busy-ack card (msg ${ack.messageId}) still present 60s after ` +
147
+ `the answer — a stale "Queued" line is dangling in the chat.`,
148
+ ).toBe(true);
149
+ } finally {
150
+ await sc.tearDown();
151
+ }
152
+ },
153
+ // warmup + sleep + flush + answer + cleanup poll + settle headroom
154
+ (STEP_WARMUP_MS + SLEEP_SECS * 1000 + 200_000),
155
+ );
156
+
157
+ it(
158
+ "negative: a ping during a SHORT step (finishes under the threshold) never gets a busy-ack card",
159
+ async () => {
160
+ const sc = await spinUp({ agent: AGENT });
161
+ try {
162
+ // An 8s sleep can NEVER age past the 12s threshold — neither the
163
+ // direct evaluation nor the deferred re-check may produce a card
164
+ // (the re-check re-reads live state and finds the step gone).
165
+ await sc.sendDM(
166
+ `For a latency test, run this exact command in the FOREGROUND ` +
167
+ `with your Bash tool (do NOT use run_in_background): ` +
168
+ `\`sleep 8 && echo done\`. When it completes, reply with ` +
169
+ `exactly: SHORT_SLEEP_DONE. Do not send any message before it completes.`,
170
+ );
171
+
172
+ // Give the agent a beat to enter the Bash step, then ping while
173
+ // the short step is (at most) a few seconds old.
174
+ await new Promise((r) => setTimeout(r, 4_000));
175
+ await sc.sendDM("Still with me?");
176
+
177
+ // Watch through the step end + the widest possible re-check window
178
+ // + the answer. No message matching the busy-ack shape may appear.
179
+ let prematureAck: ObservedMessage | null = null;
180
+ try {
181
+ prematureAck = await sc.expectMessage(
182
+ (m: ObservedMessage) => m.fromBot && !m.edited && BUSY_ACK_RE.test(m.text),
183
+ { from: "bot", timeout: 45_000 },
184
+ );
185
+ } catch {
186
+ // timeout = the correct outcome (no ack, ever)
187
+ }
188
+ expect(
189
+ prematureAck,
190
+ `a busy-ack fired for a step that never reached the threshold ` +
191
+ `(${JSON.stringify(prematureAck?.text.slice(0, 120))}) — the ` +
192
+ `agent was seconds from answering; the threshold/re-check ` +
193
+ `gates are not holding.`,
194
+ ).toBeNull();
195
+ } finally {
196
+ await sc.tearDown();
197
+ }
198
+ },
199
+ 150_000,
200
+ );
201
+ });
@@ -0,0 +1,208 @@
1
+ /**
2
+ * JTBD — worker-pin lifecycle + the restart rule (#3001, DM).
3
+ *
4
+ * The job spec (`reference/jobs/know-what-my-agent-is-doing.md`) sanctions
5
+ * exactly one pin: silently pinning the already-rendered status message while
6
+ * its work is in flight, auto-unpinned when the work completes. #3001's
7
+ * operator report was the failure mode: worker (`wk:`) pins with no
8
+ * mid-session reaper and a forfeit-on-failure boot sweep left a long tail of
9
+ * stale pinned `🛠 Worker` messages. The restart rule this scenario pins:
10
+ * **agent restart = reset any pinned message whose work didn't finish.**
11
+ *
12
+ * Two acceptance criteria, shared setup (a long background dispatch):
13
+ *
14
+ * AC-1 — lifecycle: the `🛠 Worker` feed message is PINNED while the
15
+ * background worker runs, and UNPINNED after it completes (the
16
+ * normal completion unpin — no restart involved).
17
+ * AC-2 — restart rule: with a SECOND dispatch pinned mid-flight, a forced
18
+ * gateway restart orphans the pin; the next boot's sweep
19
+ * (statusPinBootCleanup, reading status-pins.json) unpins it. The
20
+ * restart half self-skips green without NOPASSWD sudo, mirroring
21
+ * jtbd-message-during-restart-dm.
22
+ *
23
+ * Observation shape: pins/unpins are SERVICE events, not message sends or
24
+ * edits — the worker card EDITS in place the whole time it is pinned, so a
25
+ * message-observer predicate would fire on edits and prove nothing. We
26
+ * assert via `driver.observePins` (raw `updatePinnedMessages`), keyed to the
27
+ * exact pinned messageId, and explicitly ignore `m.edited` traffic.
28
+ *
29
+ * Env note: requires the harness agent's default
30
+ * `pin_status_while_working` (ON) and the worker activity feed enabled —
31
+ * same env as bg-sub-agent-dispatch-dm (SETUP.md §6).
32
+ */
33
+
34
+ import { describe, it, expect } from "vitest";
35
+ import { execSync, spawn } from "node:child_process";
36
+ import { spinUp } from "../harness.js";
37
+ import type { ObservedPin } from "../driver.js";
38
+ import { WORKER_FEED_RE } from "../assertions.js";
39
+
40
+ const AGENT = "test-harness";
41
+
42
+ // Same deterministic Option-1 dispatch prompt shape as
43
+ // bg-sub-agent-dispatch-dm: this scenario verifies the PIN infra, not the
44
+ // model's delegation judgment. ~60s of paced background work — long enough
45
+ // for the feed to paint (8s first-paint floor) and the wk: pin to land.
46
+ function bgDispatchPrompt(steps: number): string {
47
+ return (
48
+ `Use the Agent tool with subagent_type "general-purpose" and ` +
49
+ `run_in_background: true to dispatch a worker with this exact task: ` +
50
+ `"Do ${steps} steps, ONE AT A TIME, k = 1 through ${steps}. Before each ` +
51
+ `step write a brief one-sentence narration, then run \`sleep 2\` via the ` +
52
+ `Bash tool, then run \`echo step-k\` via the Bash tool (substitute the ` +
53
+ `real number for k). Run every sleep and every echo as its OWN separate ` +
54
+ `Bash call — never batch or chain them with && — and narrate before ` +
55
+ `each. Do not stop early; complete all ${steps} steps." After ` +
56
+ `dispatching, send a brief reply saying you've kicked off the worker.`
57
+ );
58
+ }
59
+
60
+ /** Wait for the next pin/unpin event matching `pred`. observePins yields
61
+ * service-level pin transitions only — message EDITS never surface here,
62
+ * which is exactly why this scenario observes pins, not messages. */
63
+ async function nextPinEvent(
64
+ iter: AsyncIterator<ObservedPin>,
65
+ pred: (p: ObservedPin) => boolean,
66
+ timeoutMs: number,
67
+ what: string,
68
+ ): Promise<ObservedPin> {
69
+ const deadline = Date.now() + timeoutMs;
70
+ while (Date.now() < deadline) {
71
+ const remaining = deadline - Date.now();
72
+ const race = await Promise.race([
73
+ iter.next(),
74
+ new Promise<"timeout">((r) => setTimeout(() => r("timeout"), remaining)),
75
+ ]);
76
+ if (race === "timeout") break;
77
+ if (race.done === true) break;
78
+ if (pred(race.value)) return race.value;
79
+ }
80
+ throw new Error(`nextPinEvent: no ${what} within ${timeoutMs}ms`);
81
+ }
82
+
83
+ function canShellSudo(): boolean {
84
+ try {
85
+ execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ /** Kick a marker-safe restart and return immediately (detached) — same shape
93
+ * as jtbd-message-during-restart-dm. */
94
+ function kickRestartDetached(name: string): void {
95
+ const child = spawn(
96
+ "sudo",
97
+ ["-n", "env", `PATH=${process.env.PATH}`, `HOME=${process.env.HOME}`,
98
+ "switchroom", "agent", "restart", name, "--force"],
99
+ { detached: true, stdio: "ignore" },
100
+ );
101
+ child.unref();
102
+ }
103
+
104
+ const sudoOk = canShellSudo();
105
+
106
+ describe("uat: worker-pin lifecycle + restart rule (#3001, DM)", () => {
107
+ it(
108
+ "AC-1: the worker feed message is pinned while the bg dispatch runs and unpinned after completion",
109
+ async () => {
110
+ const sc = await spinUp({ agent: AGENT });
111
+ const pinIter = sc.driver
112
+ .observePins(sc.botUserId)
113
+ [Symbol.asyncIterator]();
114
+ try {
115
+ await sc.sendDM(bgDispatchPrompt(10));
116
+
117
+ // Parent ack — the dispatch happened.
118
+ await sc.expectMessage(/.+/, { from: "bot", timeout: 45_000 });
119
+
120
+ // The wk: pin lands once the worker feed paints (~8s first-paint) —
121
+ // a PIN service event, not a send/edit.
122
+ const pinned = await nextPinEvent(
123
+ pinIter,
124
+ (p) => p.pinned,
125
+ 90_000,
126
+ "worker pin",
127
+ );
128
+ expect(pinned.messageId).toBeGreaterThan(0);
129
+
130
+ // Sanity: what got pinned is the 🛠 Worker feed message (fetch by id
131
+ // — the card keeps EDITING in place; we never assert on edits).
132
+ const msg = await sc.driver.getMessage(sc.botUserId, pinned.messageId);
133
+ expect(msg, "pinned message vanished").not.toBeNull();
134
+ expect(msg!.text).toMatch(WORKER_FEED_RE);
135
+
136
+ // Completion unpins it. Budget mirrors bg-sub-agent-dispatch-dm's
137
+ // done-window: worker (~60s) + watcher stall detection (60s) + slack.
138
+ const unpinned = await nextPinEvent(
139
+ pinIter,
140
+ (p) => !p.pinned && p.messageId === pinned.messageId,
141
+ 240_000,
142
+ `unpin of worker pin msg=${pinned.messageId}`,
143
+ );
144
+ expect(unpinned.pinned).toBe(false);
145
+ } finally {
146
+ await pinIter.return?.();
147
+ await sc.tearDown();
148
+ }
149
+ },
150
+ // 45s ack + 90s pin + 240s unpin + spinUp/teardown slack.
151
+ 420_000,
152
+ );
153
+
154
+ (sudoOk ? it : it.skip)(
155
+ "AC-2 (restart rule): a worker pin orphaned by a forced gateway restart is unpinned by the next boot's sweep",
156
+ async () => {
157
+ const sc = await spinUp({ agent: AGENT });
158
+ const pinIter = sc.driver
159
+ .observePins(sc.botUserId)
160
+ [Symbol.asyncIterator]();
161
+ try {
162
+ // Longer dispatch (~2min of steps) so the restart reliably lands
163
+ // while the worker — and its pin — are still in flight.
164
+ await sc.sendDM(bgDispatchPrompt(30));
165
+ await sc.expectMessage(/.+/, { from: "bot", timeout: 45_000 });
166
+
167
+ // Latch onto the 🛠 Worker feed pin specifically — the parent turn's
168
+ // own fg: status pin can land first, and a bare `p.pinned` predicate
169
+ // would seize it and then wait on the wrong messageId's unpin. Keep
170
+ // consuming pin events until one resolves to a worker-feed message
171
+ // (AC-1's sanity fetch, applied inside the wait loop).
172
+ const pinDeadline = Date.now() + 90_000;
173
+ let pinned: ObservedPin | null = null;
174
+ while (pinned == null) {
175
+ const candidate = await nextPinEvent(
176
+ pinIter,
177
+ (p) => p.pinned,
178
+ Math.max(1, pinDeadline - Date.now()),
179
+ "worker-feed pin",
180
+ );
181
+ const msg = await sc.driver
182
+ .getMessage(sc.botUserId, candidate.messageId)
183
+ .catch(() => null);
184
+ if (msg != null && WORKER_FEED_RE.test(msg.text)) pinned = candidate;
185
+ }
186
+
187
+ // Force the restart MID-dispatch: the wk: claim is persisted in
188
+ // status-pins.json; the dying session never runs its completion
189
+ // unpin. The next boot's statusPinBootCleanup must unpin it.
190
+ kickRestartDetached(AGENT);
191
+
192
+ const unpinned = await nextPinEvent(
193
+ pinIter,
194
+ (p) => !p.pinned && p.messageId === pinned.messageId,
195
+ // Restart (docker recreate + gateway boot + startup mutex + boot
196
+ // sweep) comfortably inside this window.
197
+ 240_000,
198
+ `boot-sweep unpin of orphaned worker pin msg=${pinned.messageId}`,
199
+ );
200
+ expect(unpinned.pinned).toBe(false);
201
+ } finally {
202
+ await pinIter.return?.();
203
+ await sc.tearDown();
204
+ }
205
+ },
206
+ 420_000,
207
+ );
208
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * UAT scenario named by `reference/jobs/approve-what-my-agent-can-touch.md`
3
+ * § Prove it — "Card survives a gateway restart (DM)".
4
+ *
5
+ * Contract under test (#2989 durability fix): an agent-initiated
6
+ * `vault_request_access` card issued BEFORE a gateway restart is still
7
+ * tappable AFTER it. The gateway persists card metadata to the durable
8
+ * pending-card store, restores it on boot, and a tap on the still-valid card
9
+ * grants and resumes the parked agent exactly like a pre-restart tap —
10
+ * never the "Card expired — ask the agent to re-request" tombstone while the
11
+ * agent is parked and structurally cannot re-request.
12
+ *
13
+ * Load-bearing assertions:
14
+ * 1. The post-restart tap does NOT produce the expired-card tombstone edit.
15
+ * 2. The approve flow completes (passphrase → Granted card edit).
16
+ * 3. The parked agent resumes on the grant with no driver nudge.
17
+ *
18
+ * **Skipped by default.** To unskip:
19
+ *
20
+ * 1. Standard UAT preflight (`uat/SETUP.md` §5-6).
21
+ * 2. NOPASSWD sudo on the runner host (the scenario restarts test-harness).
22
+ * 3. `TELEGRAM_UAT_VAULT_PASSPHRASE` set in env.
23
+ * 4. Pre-create a sacrificial vault key:
24
+ *
25
+ * ```bash
26
+ * TMPF=$(mktemp) && printf '%s' 'sentinel-2989-value' > "$TMPF" && \
27
+ * switchroom vault set uat/card-survives-restart --file "$TMPF" \
28
+ * --format string ; shred -u "$TMPF"
29
+ * ```
30
+ *
31
+ * 5. Remove `describe.skip` below.
32
+ *
33
+ * Why skipped: mutates vault state (mints a grant) and bounces the harness
34
+ * agent. Cleanup is operator-side (`switchroom vault revoke <grant-id>`).
35
+ *
36
+ * Module-level twin (every CI run): the restart round-trip in
37
+ * `tests/approval-card-restart-outcome.test.ts` and the store contract in
38
+ * `tests/pending-card-store.test.ts`.
39
+ */
40
+
41
+ import { describe, expect, it } from "vitest";
42
+ import { execSync, spawn } from "node:child_process";
43
+ import { spinUp } from "../harness.js";
44
+
45
+ const AGENT = "test-harness";
46
+ const KEY = "uat/card-survives-restart";
47
+ const CARD_BUDGET_MS = 120_000;
48
+ const BOOT_BUDGET_MS = 180_000;
49
+ const RESUME_BUDGET_MS = 180_000;
50
+
51
+ function canShellSudo(): boolean {
52
+ try {
53
+ execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function kickRestartDetached(name: string): void {
61
+ const child = spawn(
62
+ "sudo",
63
+ ["-n", "env", `PATH=${process.env.PATH}`, `HOME=${process.env.HOME}`,
64
+ "switchroom", "agent", "restart", name, "--force"],
65
+ { detached: true, stdio: "ignore" },
66
+ );
67
+ child.unref();
68
+ }
69
+
70
+ describe.skip("uat: approval card survives a gateway restart (DM, #2989)", () => {
71
+ it(
72
+ "card issued pre-restart is tappable post-restart: grant lands and the agent resumes",
73
+ async () => {
74
+ if (!canShellSudo()) {
75
+ throw new Error("NOPASSWD sudo required — see header to set up.");
76
+ }
77
+ const passphrase = process.env.TELEGRAM_UAT_VAULT_PASSPHRASE;
78
+ if (!passphrase) {
79
+ throw new Error(
80
+ "TELEGRAM_UAT_VAULT_PASSPHRASE must be set in env (see uat/SETUP.md).",
81
+ );
82
+ }
83
+ const sc = await spinUp({ agent: AGENT, settleMs: 0 });
84
+ try {
85
+ // 1. Park the agent on an approval card.
86
+ await sc.sendDM(
87
+ `Please call your vault_request_access MCP tool for the key ` +
88
+ `\`${KEY}\` (scope read, 30d, reason "UAT #2989 card durability"). ` +
89
+ `END YOUR TURN cleanly and wait; when the operator approves, resume ` +
90
+ `by running \`switchroom vault get ${KEY}\` and reporting success.`,
91
+ );
92
+ const card = await sc.expectMessage(/wants vault access/, {
93
+ from: "bot",
94
+ timeout: CARD_BUDGET_MS,
95
+ });
96
+
97
+ // 2. Bounce the gateway BEFORE anyone taps.
98
+ kickRestartDetached(AGENT);
99
+ await new Promise((r) => setTimeout(r, BOOT_BUDGET_MS));
100
+
101
+ // 3. Tap Approve on the ORIGINAL, pre-restart card message.
102
+ const kb = await sc.driver.getKeyboard(sc.botUserId, card.messageId);
103
+ const approveButton = kb!
104
+ .flat()
105
+ .find((b) => b.callbackData !== undefined && /approve/i.test(b.text));
106
+ expect(approveButton).toBeDefined();
107
+ await sc.driver.pressButton(
108
+ sc.botUserId,
109
+ card.messageId,
110
+ approveButton!.callbackData!,
111
+ );
112
+
113
+ // 4. The restored card must run the REAL approve flow — the expired
114
+ // tombstone ("expired before you tapped") is the pre-#2989 defect.
115
+ const next = await sc.expectMessage(
116
+ (m) =>
117
+ /Reply with your passphrase|Granted|expired before you tapped/i.test(m.text),
118
+ { from: "bot", timeout: 60_000 },
119
+ );
120
+ expect(next.text).not.toMatch(/expired before you tapped/i);
121
+
122
+ if (/passphrase/i.test(next.text)) {
123
+ await sc.sendDM(passphrase);
124
+ }
125
+
126
+ // 5. Grant lands and the parked agent resumes without a nudge.
127
+ // Edits excluded — the "✅ Granted" edit of the card itself must
128
+ // not satisfy this; only a NEW message from the resumed turn does.
129
+ const resumed = await sc.expectMessage(
130
+ (m) => !m.edited && /(granted|access|vault get|succe)/i.test(m.text) && m.text.length > 40,
131
+ { from: "bot", timeout: RESUME_BUDGET_MS },
132
+ );
133
+ expect(resumed.text.length).toBeGreaterThan(40);
134
+ } finally {
135
+ await sc.tearDown();
136
+ }
137
+ },
138
+ CARD_BUDGET_MS + BOOT_BUDGET_MS + RESUME_BUDGET_MS + 120_000,
139
+ );
140
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * UAT scenario named by `reference/jobs/approve-what-my-agent-can-touch.md`
3
+ * § Prove it — "Denied request resumes the turn (DM)".
4
+ *
5
+ * Contract under test: the operator taps Deny on an agent-initiated
6
+ * `vault_request_access` card; the parked turn RESUMES (the gateway injects
7
+ * the `vault_grant_denied` synthetic), and the agent states plainly what it
8
+ * now can't do and continues or degrades — never left waiting on a card it
9
+ * already got an answer to, and never treating the denial as a reason to
10
+ * silently strand.
11
+ *
12
+ * Load-bearing assertion: after the Deny tap, the DRIVER sees a NEW bot turn
13
+ * that acknowledges the denial/degraded path WITHOUT the driver sending any
14
+ * further message. (The unit-level twin pins the synthetic's envelope in
15
+ * `tests/vault-grant-inbound-builders.test.ts`; this scenario proves the
16
+ * user-visible outcome over real Telegram.)
17
+ *
18
+ * Needs only the standard UAT preflight (`uat/SETUP.md` §5-6) — a Deny mints
19
+ * nothing and mutates no vault state, so no passphrase or sacrificial key is
20
+ * required. The requested key deliberately does not exist.
21
+ */
22
+
23
+ import { describe, expect, it } from "vitest";
24
+ import { spinUp } from "../harness.js";
25
+
26
+ const KEY = "uat/deny-resume-nonexistent-key";
27
+ const CARD_BUDGET_MS = 120_000;
28
+ const RESUME_BUDGET_MS = 120_000;
29
+
30
+ // The deny synthetic steers the model toward naming the blocked capability
31
+ // and a fallback — this framing is the stable signal of a resumed turn.
32
+ const DENY_RESUME_FRAMING =
33
+ /den(?:ied|ial)|can(?:'|no)t|won'?t be able|without (?:that|the) (?:key|access|credential)|not (?:been )?grant/i;
34
+
35
+ describe("uat: denied vault request resumes the parked turn (DM)", () => {
36
+ it(
37
+ "operator taps Deny → parked turn resumes and the agent names what it can't do, with no nudge",
38
+ async () => {
39
+ const sc = await spinUp({ agent: "test-harness" });
40
+ try {
41
+ // 1. Park the agent on an approval card.
42
+ await sc.sendDM(
43
+ `Please call your vault_request_access MCP tool for the key ` +
44
+ `\`${KEY}\` (scope read, reason "UAT deny-resumes-turn"). Then END ` +
45
+ `YOUR TURN cleanly and wait. When you hear the operator's decision, ` +
46
+ `resume: if denied, tell me plainly that you can't read that key ` +
47
+ `and what you'd do instead.`,
48
+ );
49
+
50
+ // 2. Wait for the approval card and find the Deny button.
51
+ const card = await sc.expectMessage(/wants vault access/, {
52
+ from: "bot",
53
+ timeout: CARD_BUDGET_MS,
54
+ });
55
+ const kb = await sc.driver.getKeyboard(sc.botUserId, card.messageId);
56
+ const denyButton = kb!
57
+ .flat()
58
+ .find((b) => b.callbackData !== undefined && /deny/i.test(b.text));
59
+ expect(denyButton).toBeDefined();
60
+
61
+ // 3. Tap Deny. NO further driver message after this point.
62
+ await sc.driver.pressButton(
63
+ sc.botUserId,
64
+ card.messageId,
65
+ denyButton!.callbackData!,
66
+ );
67
+
68
+ // 4. The deny synthetic must wake the parked turn: a substantive
69
+ // bot reply that acknowledges the denial arrives unprompted.
70
+ // Edits are excluded — the gateway's own "🚫 Denied" edit of the
71
+ // approval card must not satisfy this; only a genuinely NEW
72
+ // message from a resumed turn counts.
73
+ const reply = await sc.expectMessage(
74
+ (m) => !m.edited && DENY_RESUME_FRAMING.test(m.text) && m.text.length > 40,
75
+ { from: "bot", timeout: RESUME_BUDGET_MS },
76
+ );
77
+ expect(reply.text).toMatch(DENY_RESUME_FRAMING);
78
+ } finally {
79
+ await sc.tearDown();
80
+ }
81
+ },
82
+ CARD_BUDGET_MS + RESUME_BUDGET_MS + 60_000,
83
+ );
84
+ });