switchroom 0.18.32 → 0.19.0

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 (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared restart-capability probe + loud-skip announcer for the
3
+ * restart/resume UAT scenarios.
4
+ *
5
+ * These scenarios (`jtbd-deliberate-restart-resumes-dm`,
6
+ * `jtbd-interrupted-turn-resumes-dm`, `jtbd-always-on-after-restart-dm`)
7
+ * can only exercise a REAL restart when the runner has NOPASSWD `sudo` and
8
+ * the `switchroom` CLI on PATH. On a sandboxed runner that lacks those,
9
+ * `vitest`'s `describe.skip` marks them skipped — but a bare skip reads as
10
+ * a plain green in a summarised board, which is exactly how the v0.18.32
11
+ * UAT canary over-claimed "#3315 restart/resume validated" when in fact
12
+ * these three self-skipped (#3334 item d).
13
+ *
14
+ * The durable fix has two parts. Part 2 (a runner that can actually
15
+ * restart) is infrastructure and is tracked separately. Part 1 — this file
16
+ * — makes the skip LOUD and unmistakable: a single-source-of-truth probe
17
+ * plus a banner printed to stderr at collection time, so a green that is
18
+ * really a skip can never be silently indistinguishable from a green that
19
+ * ran live.
20
+ */
21
+
22
+ import { execSync } from "node:child_process";
23
+
24
+ /**
25
+ * True only when the runner can drive a real agent restart: NOPASSWD
26
+ * `sudo` is available (the scenarios shell out to
27
+ * `sudo -n switchroom agent restart …`). A sandboxed CI runner returns
28
+ * false, which routes the scenario to `describe.skip`.
29
+ */
30
+ export function canShellSudo(): boolean {
31
+ try {
32
+ execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Print a loud, unmissable banner to stderr when a restart/resume scenario
41
+ * is about to self-skip because the runner cannot exercise a real restart.
42
+ * Call this at module-collection time (top level of the scenario file) so
43
+ * the banner lands in CI stdout/stderr regardless of the reporter's
44
+ * skip-count rendering.
45
+ *
46
+ * The banner deliberately spells out that a GREEN here is a SKIP, not live
47
+ * proof — the exact confusion #3334 item d flags as "the dangerous one".
48
+ */
49
+ export function announceRestartSkip(scenarioTitle: string): void {
50
+ console.warn(
51
+ "\n" +
52
+ "════════════════════════════════════════════════════════════════════\n" +
53
+ " ⚠️ UAT SKIPPED — NOT LIVE-VALIDATED (restart capability absent)\n" +
54
+ ` scenario: ${scenarioTitle}\n` +
55
+ " reason: this runner has no NOPASSWD sudo + switchroom CLI, so\n" +
56
+ " it cannot exercise a real agent restart. The scenario\n" +
57
+ " self-skips. Its GREEN is a SKIP, not live proof.\n" +
58
+ " see: #3334 item (d) / #3315 — restart/resume assurance from\n" +
59
+ " this run rests on the unit/vitest layer, not this UAT.\n" +
60
+ "════════════════════════════════════════════════════════════════════\n",
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Convenience: probe capability once and, when absent, emit the loud-skip
66
+ * banner. Returns the capability boolean so the caller can gate
67
+ * `describe` vs `describe.skip`:
68
+ *
69
+ * const sudoOk = restartCapableOrAnnounceSkip(TITLE);
70
+ * (sudoOk ? describe : describe.skip)(TITLE, () => { … });
71
+ */
72
+ export function restartCapableOrAnnounceSkip(scenarioTitle: string): boolean {
73
+ const ok = canShellSudo();
74
+ if (!ok) announceRestartSkip(scenarioTitle);
75
+ return ok;
76
+ }
@@ -89,7 +89,17 @@ const BG_DISPATCH_PROMPT =
89
89
  `brief reply saying you've kicked off the background worker so I can ` +
90
90
  `watch the progress feed.`;
91
91
 
92
- const WORKER_RUNNING_RE = /running\s*·/i;
92
+ // Single-worker RUNNING headers show NO literal "running" — they render
93
+ // `<elapsed> · <n> tools[ · <tok> tok][ · <model>]`
94
+ // (`tool-activity-summary.ts` renderActivityHeader, running branch); the
95
+ // word "running" only appears on the MULTI-worker combined header
96
+ // (`🛠 Workers · N running`). The old `/running\s*·/i` oracle matched only
97
+ // by accident when the multi-worker header happened to render (failed live
98
+ // 2026-07-18, ci-uat run 29634400115, against a healthy in-flight card).
99
+ // In-flight signal = either header shape's live metric, paired with the
100
+ // `not.toMatch(WORKER_DONE_RE)` terminal exclusion below — a skeleton that
101
+ // paints only `🛠 Worker · <name>` with no metric line still fails.
102
+ const WORKER_RUNNING_RE = /\brunning\b|·\s*\d+\s+tools?\b/i;
93
103
  const WORKER_DONE_RE = /finished\s*·\s*(completed|failed)/i;
94
104
 
95
105
  describe("uat: background sub-agent visibility (#709/#776/#782/#788)", () => {
@@ -117,9 +127,9 @@ describe("uat: background sub-agent visibility (#709/#776/#782/#788)", () => {
117
127
  expect(feed.messageId).toBeGreaterThan(0);
118
128
  expect(feed.text).toMatch(WORKER_FEED_RE);
119
129
 
120
- // AC-2 step 1: feed body MUST show "running ·" (the in-flight
121
- // status), NOT the terminal "finished ·" the worker hasn't
122
- // completed yet.
130
+ // AC-2 step 1: feed body MUST show an in-flight signal (live
131
+ // metric line, or multi-worker "N running"), NOT the terminal
132
+ // "finished ·" — the worker hasn't completed yet.
123
133
  expect(feed.text).toMatch(WORKER_RUNNING_RE);
124
134
  expect(feed.text).not.toMatch(WORKER_DONE_RE);
125
135
 
@@ -123,8 +123,18 @@ describe("uat: bridge-flap resilience — agent stays responsive, gateway does n
123
123
  `overall deadline hit before DM ${i} — earlier turns were too slow`,
124
124
  ).toBeGreaterThan(0);
125
125
 
126
+ // Skip empty-text observations: the Bot API cannot send an
127
+ // empty text message (Telegram rejects it), so an empty-text
128
+ // fromBot observation is by construction a SERVICE message —
129
+ // e.g. the `[pinned_message]` event from the progress-card pin.
130
+ // One of those latched here as "the reply" on 2026-07-18
131
+ // (ci-uat run 29634400115: pinChatMessage 06:54:36.420Z → rx
132
+ // [pinned_message] 06:54:37.012Z, exactly at the DM-2 failure).
133
+ // A genuinely eaten turn_end still fails loudly: no non-empty
134
+ // reply arrives and this expectMessage times out.
126
135
  const reply = await sc.expectMessage(
127
- (m: ObservedMessage) => m.fromBot && !m.edited,
136
+ (m: ObservedMessage) =>
137
+ m.fromBot && !m.edited && m.text.length > 0,
128
138
  { from: "bot", timeout: remaining },
129
139
  );
130
140
  expect(
@@ -81,8 +81,16 @@ interface TrailEntry {
81
81
  text: string;
82
82
  }
83
83
 
84
+ // Mirrors the production matcher (`pending-work-progress.ts` SUFFIX_RE):
85
+ // the CURRENT emit is the italic rich-markdown form
86
+ // `\n\n_still working (Nm) · message me anytime, I'll keep you posted_`
87
+ // (#2669) — mtcute's parsed `Message.text` strips the italic markers, so
88
+ // the observed text is `\n\nstill working (Nm) · …`. The legacy em-dash
89
+ // prefix (`— still working`) and a literal-underscore render are also
90
+ // tolerated. The old mandatory-em-dash regex could not match ANY current
91
+ // production edit (stale oracle, diagnosed 2026-07-18).
84
92
  const SUFFIX_RE =
85
- /\n\n still working \(\d+m\)( · message me anytime, I'll keep you posted)?$/;
93
+ /\n\n(?:— |_)?still working \(\d+m\)( · message me anytime, I'll keep you posted)?_?$/;
86
94
 
87
95
  function pad(s: string, n: number): string {
88
96
  return s.length >= n ? s : s + " ".repeat(n - s.length);
@@ -139,11 +147,20 @@ describe("uat: cross-turn pending-async ambient progress (#1445)", () => {
139
147
  if (firstAnchorMsgId == null && entry.kind === "fresh") {
140
148
  firstAnchorMsgId = entry.messageId;
141
149
  }
150
+ // EXACT match only. The prompt demands the completion signal be
151
+ // the single word "done" and nothing else; the loose `/\bdone\b/`
152
+ // fallback false-positived on the model's beat-1 ACK ("…I'll
153
+ // reply \"done\" when it finishes"), tripping a 10s wind-down that
154
+ // quit the test ~22s BEFORE the turn even ended — so the ambient
155
+ // edit mechanism was never observable (#3334 item b, observed UAT
156
+ // v0.18.32 r2). Requiring `trim() === "done"` makes the terminal
157
+ // signal deterministic and un-spoofable by an ack that merely
158
+ // mentions the word.
142
159
  const trimmedFinal = entry.text.trim().toLowerCase();
143
160
  const looksLikeDone =
144
161
  entry.kind === "fresh" &&
145
162
  entry.messageId !== firstAnchorMsgId &&
146
- (trimmedFinal === "done" || /\bdone\b/.test(trimmedFinal));
163
+ trimmedFinal === "done";
147
164
  if (looksLikeDone) {
148
165
  sawDone = true;
149
166
  quiescenceDeadline = Date.now() + 10_000;
@@ -55,8 +55,10 @@
55
55
  import { describe, it, expect, beforeAll } from "vitest";
56
56
  import { execSync } from "node:child_process";
57
57
  import { spinUp } from "../harness.js";
58
+ import { restartCapableOrAnnounceSkip } from "../restart-capability.js";
58
59
 
59
60
  const AGENT = "test-harness";
61
+ const SCENARIO_TITLE = "uat: always-on after restart";
60
62
 
61
63
  // Budget for the marker-safe restart itself (per
62
64
  // feedback_agent_restart_needs_sudo_when_running.md, restart blocks
@@ -74,15 +76,6 @@ const HARD_REPLY_BUDGET_MS = 120_000;
74
76
  // contract but worth logging for forensic visibility.
75
77
  const VISION_REPLY_BUDGET_MS = 30_000;
76
78
 
77
- function canShellSudo(): boolean {
78
- try {
79
- execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
80
- return true;
81
- } catch {
82
- return false;
83
- }
84
- }
85
-
86
79
  function restartAgent(name: string): void {
87
80
  // Marker-safe restart per memory feedback_compose_rollout.md +
88
81
  // feedback_agent_restart_needs_sudo_when_running.md. Apply step
@@ -96,11 +89,12 @@ function restartAgent(name: string): void {
96
89
  }
97
90
 
98
91
  // This scenario requires NOPASSWD sudo + the switchroom CLI on PATH on
99
- // the harness host. Skip on CI runners that don't expose those.
100
- const sudoOk = canShellSudo();
92
+ // the harness host. Skip on CI runners that don't expose those — and when
93
+ // we skip, announce it LOUDLY so a green isn't mistaken for live proof.
94
+ const sudoOk = restartCapableOrAnnounceSkip(SCENARIO_TITLE);
101
95
 
102
96
  (sudoOk ? describe : describe.skip)(
103
- "uat: always-on after restart",
97
+ SCENARIO_TITLE,
104
98
  () => {
105
99
  beforeAll(() => {
106
100
  restartAgent(AGENT);
@@ -21,10 +21,13 @@
21
21
  */
22
22
 
23
23
  import { describe, it, expect } from "vitest";
24
- import { execSync, spawn } from "node:child_process";
24
+ import { spawn } from "node:child_process";
25
25
  import { spinUp } from "../harness.js";
26
+ import { restartCapableOrAnnounceSkip } from "../restart-capability.js";
26
27
 
27
28
  const AGENT = "test-harness";
29
+ const SCENARIO_TITLE =
30
+ "uat: deliberate restart mid-turn resumes exactly once (DM, #2988)";
28
31
  const MID_TURN_MS = 10_000; // let the turn get in-flight before the bounce
29
32
  const RESUME_BUDGET_MS = 180_000; // boot + resume + reply
30
33
  const QUIET_WINDOW_MS = 90_000; // after the resume completes, no second resume may fire
@@ -36,15 +39,6 @@ const SAME_TURN_GRACE_MS = 20_000;
36
39
 
37
40
  const RESUME_FRAMING = /resum|picking .*back|interrupted|cut off|just restarted/i;
38
41
 
39
- function canShellSudo(): boolean {
40
- try {
41
- execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
42
- return true;
43
- } catch {
44
- return false;
45
- }
46
- }
47
-
48
42
  function kickRestartDetached(name: string): void {
49
43
  // A deliberate operator restart: clean SIGTERM path, clean-shutdown marker
50
44
  // written — the exact class #2988 makes resume in-flight work through.
@@ -57,10 +51,10 @@ function kickRestartDetached(name: string): void {
57
51
  child.unref();
58
52
  }
59
53
 
60
- const sudoOk = canShellSudo();
54
+ const sudoOk = restartCapableOrAnnounceSkip(SCENARIO_TITLE);
61
55
 
62
56
  (sudoOk ? describe : describe.skip)(
63
- "uat: deliberate restart mid-turn resumes exactly once (DM, #2988)",
57
+ SCENARIO_TITLE,
64
58
  () => {
65
59
  it(
66
60
  "an operator restart mid-turn resumes the work once, and never a second time",
@@ -23,26 +23,20 @@
23
23
  */
24
24
 
25
25
  import { describe, it, expect } from "vitest";
26
- import { execSync, spawn } from "node:child_process";
26
+ import { spawn } from "node:child_process";
27
27
  import { spinUp } from "../harness.js";
28
+ import { restartCapableOrAnnounceSkip } from "../restart-capability.js";
28
29
 
29
30
  const AGENT = "test-harness";
30
31
  const MID_TURN_MS = 10_000; // let the turn enqueue (become a recorded interrupted turn)
31
32
  const RESUME_BUDGET_MS = 180_000; // boot + resume + reply
32
33
 
34
+ const SCENARIO_TITLE = "uat: interrupted turn resumes after restart (DM)";
35
+
33
36
  // The resume builder tells the model to "briefly let the user know you're
34
37
  // resuming what was interrupted" — so the reply always opens with this framing.
35
38
  const RESUME_FRAMING = /resum|picking .*back|interrupted|cut off|just restarted/i;
36
39
 
37
- function canShellSudo(): boolean {
38
- try {
39
- execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
40
- return true;
41
- } catch {
42
- return false;
43
- }
44
- }
45
-
46
40
  function kickRestartDetached(name: string): void {
47
41
  // --force WITHOUT --wait → recreate now, interrupting the in-flight turn.
48
42
  const child = spawn(
@@ -54,9 +48,9 @@ function kickRestartDetached(name: string): void {
54
48
  child.unref();
55
49
  }
56
50
 
57
- const sudoOk = canShellSudo();
51
+ const sudoOk = restartCapableOrAnnounceSkip(SCENARIO_TITLE);
58
52
 
59
- (sudoOk ? describe : describe.skip)("uat: interrupted turn resumes after restart (DM)", () => {
53
+ (sudoOk ? describe : describe.skip)(SCENARIO_TITLE, () => {
60
54
  it(
61
55
  "a turn interrupted by a restart is resumed and the resume turn completes",
62
56
  async () => {
@@ -70,23 +70,47 @@ const TAIL = "MULTITAIL9";
70
70
 
71
71
  // We need the composed reply to exceed the 32768-char cap so the gateway
72
72
  // chunks it. Asking a model to emit ~40k literal chars is flaky (it'll
73
- // summarize or truncate). Instead we ask it to REPEAT a fixed, cheap-to-
74
- // generate padding block a deterministic number of times a task an agent
75
- // reliably obeys because it's mechanical, not generative.
76
- const PAD_LINE = "The quick brown fox jumps over the lazy dog. ";
77
- // Overshoot the cap by a comfortable margin so a slightly-short reply still
78
- // splits: (cap / line) rounded up, times a 1.3 safety factor.
79
- const REPEATS = Math.ceil((RICH_MESSAGE_MAX_CHARS / PAD_LINE.length) * 1.3);
73
+ // summarize or truncate). We keep the task MECHANICAL (cycle a fixed
74
+ // sentence list, prefix each line with its index) so an agent reliably
75
+ // obeys but every emitted line is UNIQUE. Verbatim repetition of one
76
+ // sentence 900+ times reads as degenerate output to Anthropic's content
77
+ // filter and 4xx-kills the turn before any reply exists to send (observed
78
+ // live 2026-07-18, ci-uat run 29634400115: four "unknown-4xx / Output
79
+ // blocked by content filtering policy" events, gateway-supervisor.log
80
+ // 06:43–06:53Z — the reply never reached the send path).
81
+ const PAD_SENTENCES = [
82
+ "The quick brown fox jumps over the lazy dog.",
83
+ "Pack my box with five dozen liquor jugs.",
84
+ "How vexingly quick daft zebras jump.",
85
+ "Sphinx of black quartz, judge my vow.",
86
+ "The five boxing wizards jump quickly.",
87
+ "Jackdaws love my big sphinx of quartz.",
88
+ "Waltz, bad nymph, for quick jigs vex.",
89
+ "Glib jocks quiz nymph to vex dwarf.",
90
+ "Bright vixens jump; dozy fowl quack.",
91
+ "Quick zephyrs blow, vexing daft Jim.",
92
+ ];
93
+ // Conservative per-line floor: the SHORTEST pad sentence plus the numeric
94
+ // prefix. Overshoot the cap by a 1.3 safety factor so a slightly-short
95
+ // reply still splits.
96
+ const PER_LINE_MIN =
97
+ Math.min(...PAD_SENTENCES.map((s) => s.length)) + "Line 1: ".length;
98
+ const LINE_COUNT = Math.ceil((RICH_MESSAGE_MAX_CHARS / PER_LINE_MIN) * 1.3);
80
99
 
81
100
  const PROMPT = [
82
101
  `I need a LONG reply to test message chunking. Do EXACTLY this, nothing else:`,
83
102
  ``,
84
103
  `1. Start your reply with: ${HEAD}: **head bold marker**`,
85
- `2. Then output this exact sentence ${REPEATS} times, each on its own line:`,
86
- ` "${PAD_LINE.trim()}"`,
104
+ `2. Then output ${LINE_COUNT} numbered lines. Line i (1-based) must be:`,
105
+ ` "Line i: " followed by sentence number (i mod 10) from this list`,
106
+ ` (list index 0-9):`,
107
+ ...PAD_SENTENCES.map((s, idx) => ` ${idx}: "${s}"`),
108
+ ` So line 1 is "Line 1: ${PAD_SENTENCES[1]}", line 2 is`,
109
+ ` "Line 2: ${PAD_SENTENCES[2]}", and so on, wrapping the list.`,
87
110
  `3. End your reply with: ${TAIL}: **tail bold marker**`,
88
111
  ``,
89
- `Do not summarize or shorten. Emit all ${REPEATS} repetitions verbatim.`,
112
+ `Every line is unique because of its number. Do not summarize, do not`,
113
+ `stop early — emit all ${LINE_COUNT} lines.`,
90
114
  ].join("\n");
91
115
 
92
116
  function kinds(msg: ObservedMessage): Set<string> {
@@ -104,9 +128,17 @@ function kinds(msg: ObservedMessage): Set<string> {
104
128
  await sc.sendDM(PROMPT);
105
129
 
106
130
  // Chunk 1: the part carrying the HEAD marker.
131
+ //
132
+ // Budget: 240s, not 120s. When the model stages the long output
133
+ // via a file Write+Read (a 900+-line, ~90k-token turn), the
134
+ // inbound→chunks wall time runs ~3m49s (observed UAT v0.18.32 r2:
135
+ // test gave up at 120s / 08:42:13; the correctly-chunked reply
136
+ // actually landed 08:44:02 — a timing-budget oracle defect, not a
137
+ // delivery failure, since both ordered chunks delivered with zero
138
+ // 4xx). 240s clears that staging path with headroom (#3334 item a).
107
139
  const first = await sc.expectMessage(
108
140
  (m: ObservedMessage) => m.text.includes(HEAD) || m.text === "\x01",
109
- { from: "bot", timeout: 120_000 },
141
+ { from: "bot", timeout: 240_000 },
110
142
  );
111
143
  // Chunk 2 (final): the part carrying the TAIL marker. This is a
112
144
  // DIFFERENT message than `first` — if the reply hadn't chunked,
@@ -116,7 +148,7 @@ function kinds(msg: ObservedMessage): Set<string> {
116
148
  (m: ObservedMessage) =>
117
149
  (m.text.includes(TAIL) || m.text === "\x01") &&
118
150
  m.messageId !== first.messageId,
119
- { from: "bot", timeout: 120_000 },
151
+ { from: "bot", timeout: 240_000 },
120
152
  );
121
153
 
122
154
  // (4) Neither part is the unsupported-media sentinel.
@@ -163,7 +195,9 @@ function kinds(msg: ObservedMessage): Set<string> {
163
195
  await sc.tearDown();
164
196
  }
165
197
  },
166
- 180_000,
198
+ // Outer budget must exceed the two sequential 240s expectMessage
199
+ // windows (chunk 1, then chunk 2) plus spinUp settle + slack.
200
+ 540_000,
167
201
  );
168
202
  },
169
203
  );
@@ -54,11 +54,12 @@ import {
54
54
  stripMarkdown,
55
55
  truncate,
56
56
  } from './card-format.js'
57
- import { STATUS_ROLLING_LINES } from './status-no-truncate.js'
57
+ import { WORKER_HISTORY_MAX } from './status-no-truncate.js'
58
58
  import {
59
59
  renderStatusCard,
60
60
  formatStepSuffix,
61
61
  renderCombinedWorkerFeed,
62
+ workerHistoryDepth,
62
63
  type CombinedWorkerRow,
63
64
  } from './tool-activity-summary.js'
64
65
  import { isSendGateShed } from './send-gate.js'
@@ -213,6 +214,9 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
213
214
  final: finished,
214
215
  liveSuffix: finished ? '' : liveSuffix,
215
216
  result,
217
+ // Lone-worker card: window to the w=1 point of Ken's curve (6) so it shows
218
+ // the full recent trail, not the 5-line agent-card default (#3349).
219
+ historyWindow: workerHistoryDepth(1),
216
220
  })
217
221
  if (card == null) {
218
222
  // Unreachable (header always present) — defensive.
@@ -420,7 +424,7 @@ interface WorkerRow {
420
424
  agentId: string
421
425
  /**
422
426
  * Accumulated narrative lines (oldest→newest), deduped within the whole
423
- * rolling window. Rolling-window capped to STATUS_ROLLING_LINES. Grows the
427
+ * rolling window. Rolling-window capped to WORKER_HISTORY_MAX. Grows the
424
428
  * live render so the feed reads like the main agent's answer.
425
429
  */
426
430
  narrative: string[]
@@ -797,8 +801,10 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
797
801
  row.narrative.push(line)
798
802
  // The `→` current-step line just CHANGED — reset the per-step timer.
799
803
  row.stepStartedAtMs = nowFn()
800
- if (row.narrative.length > STATUS_ROLLING_LINES) {
801
- row.narrative.splice(0, row.narrative.length - STATUS_ROLLING_LINES)
804
+ // Retain up to WORKER_HISTORY_MAX (6) — the lone-worker card's deepest
805
+ // window (#3349). STATUS_ROLLING_LINES (5) governs the agent card, not this.
806
+ if (row.narrative.length > WORKER_HISTORY_MAX) {
807
+ row.narrative.splice(0, row.narrative.length - WORKER_HISTORY_MAX)
802
808
  }
803
809
  }
804
810