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.
- package/dist/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/gateway.ts +1347 -6758
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- 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
|
-
|
|
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
|
|
121
|
-
//
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
54
|
+
const sudoOk = restartCapableOrAnnounceSkip(SCENARIO_TITLE);
|
|
61
55
|
|
|
62
56
|
(sudoOk ? describe : describe.skip)(
|
|
63
|
-
|
|
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 {
|
|
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 =
|
|
51
|
+
const sudoOk = restartCapableOrAnnounceSkip(SCENARIO_TITLE);
|
|
58
52
|
|
|
59
|
-
(sudoOk ? describe : describe.skip)(
|
|
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).
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
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
|
|
86
|
-
` "
|
|
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
|
-
`
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
801
|
-
|
|
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
|
|