Synced from monorepo

Synced from monorepo

Changes:
- Report invalid MCP server config instead of failing startup
- Keep completed terminal output when the gateway connection is lost
- Show a duration-only detail view for single-task task output
- Don't let a stale registry turn counter hide local sessions
- Raise the file-descriptor soft limit on Linux and log effective limits at startup
- Stop aborting when HTTP client construction fails
- Make session thread and runtime spawn failures recoverable
- Fix main-prompt paste parity in the question freeform input
- Fire SessionEnd hooks on /exit and headless quit
- Embed the deployment-config signing public key
- Repaint paste-chip background on inline panel inputs
- Security: prevent acceptEdits from auto-approving agent writes into the always-trusted global hook root
- Fix stacked "Worked for" markers so parks render as status and turns close with exactly one marker
- Parse hooks from config files
- Add a remote kill-switch for managed-config signature verification
- Security: fix workspace file-reference resolution bypassing workspace filesystem confinement

Source-Revision: d02693a856a54f1030695b36b91d276e96b30b23
This commit is contained in:
grokkybara[bot] 2026-07-25 18:44:42 +00:00
commit 47348d13ec
138 changed files with 7283 additions and 5796 deletions

View file

@ -81,13 +81,23 @@ async fn campaign_leader_mode_remote_dismiss_on_model_pick() {
let mut recorded = false;
'attempts: for attempt in 0..3 {
let mut h = spawn();
h.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT)
.unwrap_or_else(|_| {
panic!(
"leader-mode welcome never rendered (attempt {attempt})\nscreen:\n{}",
h.screen_contents()
)
});
// Cold leader bring-up (leader election plus an unoptimized-binary
// boot) can miss the welcome paint within LEADER_TIMEOUT under
// remote-runner load. The leader outlives this client, so a fresh spawn
// attaches to the now-live leader and paints promptly: retry like a
// missed campaign rather than hard-failing on the first loaded cold
// start, and only panic once all attempts are exhausted.
if h.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT)
.is_err()
{
let screen = h.screen_contents();
h.quit().expect("clean quit");
assert!(
attempt < 2,
"leader-mode welcome never rendered after 3 attempts\nscreen:\n{screen}"
);
continue;
}
if !wait_for_model_via_new_sessions(&mut h, CAMPAIGN_MODEL, Duration::from_secs(60)) {
// Campaign never applied on this spawn; try a fresh TUI.
h.quit().expect("clean quit");

View file

@ -2,6 +2,28 @@
#[allow(unused_imports)]
use super::common::*;
/// SGR double-click (press/release × 2) at 0-based (row, col).
fn double_click_at(harness: &mut PtyHarness, row: u16, col: u16) {
let dbl = format!(
"{}{}{}{}",
sgr_mouse(0, row, col, 'M'),
sgr_mouse(0, row, col, 'm'),
sgr_mouse(0, row, col, 'M'),
sgr_mouse(0, row, col, 'm'),
);
harness
.inject_keys(dbl.as_bytes())
.expect("inject SGR double-click");
}
/// Locate `needle` and double-click its first character cell.
fn double_click_text(harness: &mut PtyHarness, needle: &str) {
let screen = harness.screen_contents();
let (row, col) = locate_screen_text(&screen, needle)
.unwrap_or_else(|| panic!("locate {needle:?}; screen:\n{screen}"));
double_click_at(harness, row, col);
}
/// PTY, against the built binary with real SGR clicks: a finished `!`
/// command shows its full output (success and failure), double-click folds
/// the block, and a second double-click restores the full output — never
@ -36,23 +58,25 @@ async fn bash_full_output_double_click_fold_pty() {
.expect("session ready");
// 1. Success: 12 lines exceed the streaming window; all visible on finish.
//
// Truncated (default first=2, last=3) shows L01,L02 + L10L12. A middle
// line (L06) appears only after expand-on-finish — do not gate on L01:
// that passes while still truncated and races the L03/L06/L09 asserts.
harness
.inject_keys(b"! printf 'L%02d\\n' $(seq 1 12)\r")
.expect("submit bash-mode command");
harness
.wait_for_text("L12", Duration::from_secs(30))
.expect("bash output tail");
// Live tail can show L06L12 while L01 is still clipped; wait for
// expand-on-finish before asserting the head is present.
harness
.wait_for_text("L01", Duration::from_secs(15))
.wait_for_text("L06", Duration::from_secs(20))
.unwrap_or_else(|_| {
panic!(
"finished ! command must not truncate output (L01 missing)\nscreen:\n{}",
"finished ! command must expand full output (middle L06 missing)\nscreen:\n{}",
harness.screen_contents()
)
});
for line in ["L03", "L06", "L09"] {
for line in ["L01", "L03", "L09"] {
assert!(
harness.contains_text(line),
"finished ! command must not truncate output ({line} missing)\nscreen:\n{}",
@ -65,34 +89,22 @@ async fn bash_full_output_double_click_fold_pty() {
harness
.wait_for_text("Ctrl+e:", Duration::from_secs(10))
.expect("scrollback owns keys");
let screen = harness.screen_contents();
let (row, col) = locate_screen_text(&screen, "Run (user)")
.unwrap_or_else(|| panic!("locate ! block header; screen:\n{screen}"));
let dbl = format!(
"{}{}{}{}",
sgr_mouse(0, row, col, 'M'),
sgr_mouse(0, row, col, 'm'),
sgr_mouse(0, row, col, 'M'),
sgr_mouse(0, row, col, 'm'),
);
double_click_text(&mut harness, "Run (user)");
harness
.inject_keys(dbl.as_bytes())
.expect("double-click to fold");
let gone = std::time::Instant::now() + Duration::from_secs(5);
while harness.contains_text("L06") && std::time::Instant::now() < gone {
harness.update(Duration::from_millis(100));
}
assert!(
!harness.contains_text("L06"),
"double-click must collapse the ! block; got:\n{}",
harness.screen_contents()
);
harness.update(Duration::from_millis(500)); // let the multi-click window lapse
.wait_for_text_absent("L06", Duration::from_secs(15))
.unwrap_or_else(|_| {
panic!(
"double-click must collapse the ! block; got:\n{}",
harness.screen_contents()
)
});
// MULTI_CLICK_TIMEOUT_MS is 300ms; clear it before the expand gesture so
// the second double-click is not counted as click 3/4 of the first.
harness.update(Duration::from_millis(500));
// Re-locate: collapse shrinks the block and may move the header on screen.
double_click_text(&mut harness, "Run (user)");
harness
.inject_keys(dbl.as_bytes())
.expect("double-click to expand");
harness
.wait_for_text("L06", Duration::from_secs(10))
.wait_for_text("L06", Duration::from_secs(15))
.unwrap_or_else(|_| {
panic!(
"double-click must restore the FULL output (middle lines); got:\n{}",
@ -112,7 +124,7 @@ async fn bash_full_output_double_click_fold_pty() {
.wait_for_text("E12", Duration::from_secs(30))
.expect("failed bash output tail");
harness
.wait_for_text("E06", Duration::from_secs(10))
.wait_for_text("E06", Duration::from_secs(20))
.unwrap_or_else(|_| {
panic!(
"FAILED ! command must show its full output; got:\n{}",

View file

@ -28,10 +28,11 @@ pub(crate) const WELCOME_TIMEOUT: Duration = Duration::from_secs(20);
/// session spawn) on the agent's single-threaded runtime, and the client-side
/// `acp_send` has no timeout — so under the fully-parallel pty_e2e suite the
/// starved agent thread can push this well past the 20s `WELCOME_TIMEOUT`
/// (leaving the "Loading session…" placeholder up). Sized generously for the
/// same contention reason as `WRAP_TIMEOUT`, not because resume is slow when
/// run alone.
pub(crate) const RESUME_TIMEOUT: Duration = Duration::from_secs(60);
/// (leaving the "Loading session…" placeholder up). A prior 60s budget still
/// timed out under CI load with the same stuck-loading signature; match
/// [`WRAP_TIMEOUT`] (120s) for the same contention reason, not because resume
/// is slow when run alone.
pub(crate) const RESUME_TIMEOUT: Duration = Duration::from_secs(120);
/// Substring we wait for on the welcome screen. Matches the menu label `"Quit"`
/// (`render_welcome_done` / gate menus); case-sensitive, so it does **not**
@ -976,13 +977,16 @@ pub(crate) fn wait_minimal_ready(harness: &mut PtyHarness) {
/// Quit minimal cleanly. The prompt is always focused (a bare `q` would type
/// into it), so quit is Ctrl+Q pressed twice (it requires confirmation). Falls
/// back to the harness kill path if the chord doesn't take.
/// back to the harness kill path if the chord doesn't take. Give the confirm
/// chord and process exit enough time under suite load so a SIGKILL does not
/// cut off the agent mid-`updates.jsonl` flush (which breaks a subsequent
/// `--continue` resume).
pub(crate) fn quit_minimal(harness: &mut PtyHarness) {
let _ = harness.inject_keys(b"\x11"); // Ctrl+Q — arms the confirm
harness.update(Duration::from_millis(80));
harness.update(Duration::from_millis(200));
let _ = harness.inject_keys(b"\x11"); // Ctrl+Q — confirms
match harness
.wait_exit_code(Duration::from_secs(5))
.wait_exit_code(Duration::from_secs(15))
.expect("wait for minimal pager exit")
{
PtyExitPoll::Running => harness.quit().expect("kill minimal pager after timeout"),

View file

@ -56,16 +56,27 @@ async fn empty_enter_sends_top_not_last_of_two() {
.inject_keys(b"\r")
.expect("empty Enter send-now top");
turn_one.release();
// Alpha (the promoted TOP row) then bravo drain back-to-back. Each
// promoted " …" block and the intermediate TURNTWO reply is scrolled
// above the viewport by the next turn's start-adoption before a 100ms poll
// can observe it, so gating on those transient markers is inherently racy.
// Gate only on the FINAL reply (stable at the viewport head) and prove the
// top-row order + send-now silence via the recorded wire below, which is
// not subject to scrolling.
harness
.wait_for_text("TURNTHREE", Duration::from_secs(90))
.expect("all queued turns drained through to the final reply");
// Alpha (the promoted TOP row) then bravo drain back-to-back after the
// completion release. Each promoted " …" block and every reply —
// including the final TURNTHREE — can scroll above the viewport before a
// 100ms poll observes it, so gating on any on-screen marker is inherently
// racy (a flaky observation, not a real failure — same rationale as
// `removed_queued_prompt_never_sent`). Gate on the WIRE instead: wait
// until bravo's request has been sent, which is the authoritative record
// that both queued rows drained in order. Pump the event loop while
// waiting so the queued rows actually promote.
let deadline = std::time::Instant::now() + Duration::from_secs(90);
while !all_user_messages(&content)
.iter()
.any(|u| u.contains("queue-bravo-later"))
{
assert!(
std::time::Instant::now() < deadline,
"queued rows never drained through to the final turn\nscreen:\n{}",
harness.screen_contents()
);
harness.update(Duration::from_millis(100));
}
tokio::time::timeout(Duration::from_secs(10), turn_two.wait_satisfied())
.await
.expect("top queued row expectation satisfied");

View file

@ -1,29 +1,17 @@
//! PTY: a parked wait produces two static markers — the park pushes a plain
//! "Worked for X" line (the still-running work shows on the status row's
//! "… still running" cue, not in the transcript) and the turn that follows ends
//! with its own marker below. A prompt typed mid-park is cancel-and-send:
//! the shell silently cancels the parked turn (no "Turn cancelled by user"
//! marker) and runs the message as its OWN next turn, whose completion pushes
//! the second marker; the park line is never edited, so the transcript holds
//! BOTH markers with the park text intact, in order.
//!
//! Wire journey, fully flag-file driven — no timing windows: the model
//! backgrounds a flag-gated command, then runs a flag-gated foreground hold
//! while the test extracts the runtime task id from the request bodies
//! (`<task-id>` envelope; a UUID minted by the terminal actor, so it cannot
//! be scripted statically) and enqueues the blocking
//! `get_command_or_subagent_output(timeout)` on the real id — the pager
//! parks. Typing mid-park cancels-and-sends and the fixed-text reply ends
//! the new turn with the second, final marker.
//! PTY, fully flag-file driven: the model backgrounds a flag-gated command,
//! the test extracts the runtime task id and enqueues a blocking
//! `get_command_or_subagent_output` on it (park), then types mid-park
//! (cancel-and-send). Asserts exactly ONE "Worked for" marker — the new
//! turn's — with no park row and no "Turn cancelled by user" marker.
#[allow(unused_imports)]
use super::common::*;
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn endline_park_two_static_markers() {
async fn endline_park_is_markerless() {
let content = ContentController::start().await.expect("start content");
// Gates the background command the markers count (released at the end).
// Gates the background command the watching cue counts (released at the end).
let park_flag = content.home().join("endline_park_flag");
// Gates the id-extraction hold: created once the wait script is enqueued.
let id_ready_flag = content.home().join("endline_id_ready_flag");
@ -32,8 +20,7 @@ async fn endline_park_two_static_markers() {
format!("while [ ! -e {} ]; do /bin/sleep 0.2; done", flag.display())
};
// Tool call 1: a flag-gated background command — the work the watching
// cue counts ("1 command still running").
// Tool call 1: the flag-gated background command the watching cue counts.
let bg_args = json!({
"command": gated_loop(&park_flag),
"description": "flag-gated command",
@ -43,9 +30,8 @@ async fn endline_park_two_static_markers() {
let _background_turn =
expect_tool_turn(&content, "call_endline_bg", "run_terminal_command", bg_args);
// Tool call 2: the flag-gated foreground hold — the turn idles here (no
// deadline) until the test has extracted the task id and enqueued the
// wait script.
// Tool call 2: a flag-gated foreground hold keeps the turn open until the
// test has extracted the task id and enqueued the wait script.
let id_hold_args = json!({
"command": gated_loop(&id_ready_flag),
"description": "hold for id extraction"
@ -97,8 +83,7 @@ async fn endline_park_two_static_markers() {
)
});
// Tool call 3: block on the REAL task — the interruptible wait the pager
// parks on (600s survives the wait cap; the send-now cancel aborts it).
// Tool call 3: block on the real task id (600s survives the wait cap).
let wait_args = json!({
"task_ids": [task_id],
"timeout_ms": 600_000
@ -114,39 +99,29 @@ async fn endline_park_two_static_markers() {
// Everything downstream is scripted — let the id-extraction hold finish.
std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold");
// Park: the first static marker reads as a plain completion; the
// still-running work shows on the status row's watching cue instead.
harness
.wait_for_text("Worked for", Duration::from_secs(90))
.wait_for_text("1 command still running", Duration::from_secs(90))
.unwrap_or_else(|_| {
panic!(
"parked marker never appeared; screen:\n{}\n--- non-system messages ---\n{}",
"parked watching cue never appeared; screen:\n{}\n--- non-system messages ---\n{}",
harness.screen_contents(),
dump_non_system_messages(&content.request_bodies())
)
});
harness
.wait_for_text("1 command still running", Duration::from_secs(30))
.wait_for_text("send a message to interrupt", Duration::from_secs(30))
.unwrap_or_else(|_| {
panic!(
"parked watching cue never appeared; screen:\n{}",
"parked interrupt cue never appeared; screen:\n{}",
harness.screen_contents()
)
});
// The status row's cue is the only "still running" on screen — the
// parked marker line itself stays a plain "Worked for X".
let screen = harness.screen_contents();
assert!(
screen
.lines()
.filter(|l| l.contains("Worked for"))
.all(|l| !l.contains("still running")),
"the parked marker carries no still-running suffix; screen:\n{screen}"
!screen.contains("Worked for"),
"a park must write no marker; screen:\n{screen}"
);
// Type mid-park: Enter is cancel-and-send (the wait makes it a sendable
// parked turn) — the parked turn is cancelled silently and the message
// runs as its own next turn.
harness
.inject_keys(b"hurry up please")
.expect("type mid-park");
@ -162,47 +137,29 @@ async fn endline_park_two_static_markers() {
)
});
// The turn-start adoption scrolls the promoted " hurry up please" block
// to the viewport top, pushing the park marker above the screen. Scroll
// the transcript back to its head so both markers are inspectable.
// Scroll to the transcript head so the whole journey is on one screen.
harness.inject_keys(b"\t").expect("focus scrollback (tab)");
harness.update(Duration::from_millis(300));
harness.inject_keys(b"g").expect("goto transcript top");
// Two static markers: the park line unchanged above the promoted prompt
// and the new turn's final marker below it — both plain "Worked for X"
// lines (no still-running suffix; the bg command is still gated, so the
// status row legitimately shows "1 command still running" — scope the
// suffix check to the marker lines) — with NO cancelled marker anywhere
// (silent send-now cancel).
let two_markers = wait_until(Duration::from_secs(90), || {
let single_marker = wait_until(Duration::from_secs(90), || {
harness.update(Duration::from_millis(100));
let screen = harness.screen_contents();
// Positional: park marker ABOVE the promoted prompt ABOVE the final
// marker (screen text is row-major), both markers intact.
screen.matches("Worked for").count() == 2
&& screen
.lines()
.filter(|l| l.contains("Worked for"))
.all(|l| !l.contains("still running"))
screen.matches("Worked for").count() == 1
&& !screen.contains("Turn cancelled by user")
&& matches!(
(
screen.find("Worked for"),
screen.find("hurry up please"),
screen.rfind("Worked for"),
),
(Some(park), Some(prompt), Some(fin)) if park < prompt && prompt < fin
(screen.find("hurry up please"), screen.find("Worked for")),
(Some(prompt), Some(fin)) if prompt < fin
)
});
assert!(
two_markers,
"expected park marker, promoted prompt, then the fresh final marker in order \
(and no cancelled marker); screen:\n{}",
single_marker,
"expected the promoted prompt then ONE final marker (no park marker, \
no cancelled marker); screen:\n{}",
harness.screen_contents()
);
write_cast_if_requested(&harness, "endline_park_two_static_markers.cast");
write_cast_if_requested(&harness, "endline_park_is_markerless.cast");
// Release the flag-gated command so nothing outlives the harness teardown.
std::fs::write(&park_flag, b"done").expect("release flag");

View file

@ -1,13 +1,7 @@
//! PTY: auto-wake turns close MARKERLESS — a turn ends with three flag-gated
//! background commands running (one plain "Worked for" marker), and each
//! released flag lands a completion chip and the auto-wake response with NO
//! wake-end marker after it, while every earlier line stays unchanged above
//! (nothing mutates). The persistent "N commands still running" status row
//! above the prompt counts the remaining work down between wakes and
//! disappears once nothing is left; markers never carry that copy as a suffix.
//!
//! Positional chain asserted at the end: marker < chip < wake reply < chip <
//! reply < chip < reply — exactly ONE "Worked for" total (the user turn's).
//! PTY: a turn ends with three flag-gated background commands running; each
//! released flag lands a completion chip, the auto-wake reply, and that wake
//! turn's own closing marker. Asserts the positional chain marker < chip <
//! reply per round — FOUR "Worked for" total — and the cue counting 3→2→1→gone.
#[allow(unused_imports)]
use super::common::*;
@ -23,7 +17,7 @@ const ROWS: u16 = 70;
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn endline_wakeups_are_markerless() {
async fn endline_wakeups_close_with_markers() {
let content = ContentController::start().await.expect("start content");
let flags: Vec<std::path::PathBuf> = (0..TASKS)
.map(|i| content.home().join(format!("endline_status_flag_{i}")))
@ -78,8 +72,6 @@ async fn endline_wakeups_are_markerless() {
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
// The turn ends with all three commands running: one plain marker, and
// the status row's watching cue carrying the count.
harness
.wait_for_text("STATUS_TURN_SETTLED", Duration::from_secs(60))
.unwrap_or_else(|_| {
@ -106,57 +98,49 @@ async fn endline_wakeups_are_markerless() {
)
});
// Release flag 0: chip → wake reply, NO wake marker; the watching cue
// counts down to 2 while the one user-turn marker stays intact above
// (screen text is row-major, so find offsets order the lines).
std::fs::write(&flags[0], b"done").expect("release flag 0");
let wake_one = wait_until(Duration::from_secs(45), || {
harness.update(Duration::from_millis(100));
let screen = harness.screen_contents();
screen.contains("WAKE_REPLY_ONE")
&& screen.matches("Worked for").count() == 1
&& screen.matches("Worked for").count() == 2
&& screen.contains("2 commands still running")
});
assert!(
wake_one,
"expected chip → wake reply with no wake marker, watching cue at 2; screen:\n{}",
"expected chip → wake reply → the wake's closing marker, watching cue at 2; screen:\n{}",
harness.screen_contents()
);
// Release flag 1: the second wake chain joins below; cue counts 1.
std::fs::write(&flags[1], b"done").expect("release flag 1");
let wake_two = wait_until(Duration::from_secs(45), || {
harness.update(Duration::from_millis(100));
let screen = harness.screen_contents();
screen.contains("WAKE_REPLY_TWO")
&& screen.matches("Worked for").count() == 1
&& screen.matches("Worked for").count() == 3
&& screen.contains("1 command still running")
});
assert!(
wake_two,
"expected the second markerless wake chain below the earlier lines; screen:\n{}",
"expected the second wake chain (with its marker) below the earlier lines; screen:\n{}",
harness.screen_contents()
);
// Release flag 2: zero left — still exactly one marker, and the watching
// cue disappears entirely (its "still running" copy leaves the screen).
std::fs::write(&flags[2], b"done").expect("release flag 2");
let wake_three = wait_until(Duration::from_secs(45), || {
harness.update(Duration::from_millis(100));
let screen = harness.screen_contents();
screen.contains("WAKE_REPLY_THREE")
&& screen.matches("Worked for").count() == 1
&& screen.matches("Worked for").count() == 4
&& !screen.contains("still running")
});
assert!(
wake_three,
"the last wake must stay markerless and retire the watching cue; screen:\n{}",
"the last chatty wake must close with its marker and retire the watching cue; screen:\n{}",
harness.screen_contents()
);
// Full chain, positional: marker < chip < reply < chip < reply < chip <
// reply — one marker total, and no marker carries a "still running"
// suffix (that copy belongs to the status row's cue, retired above).
// Screen text is row-major, so match offsets order the lines.
let screen = harness.screen_contents();
let chips: Vec<usize> = screen
.match_indices("Task completed")
@ -170,8 +154,8 @@ async fn endline_wakeups_are_markerless() {
let markers: Vec<usize> = screen.match_indices("Worked for").map(|(i, _)| i).collect();
assert_eq!(
markers.len(),
1,
"exactly one marker — the user turn's; screen:\n{screen}"
4,
"four markers — the user turn's plus one per chatty wake; screen:\n{screen}"
);
let w1 = screen.find("WAKE_REPLY_ONE").expect("wake reply 1");
let w2 = screen.find("WAKE_REPLY_TWO").expect("wake reply 2");
@ -179,10 +163,13 @@ async fn endline_wakeups_are_markerless() {
assert!(
markers[0] < chips[0]
&& chips[0] < w1
&& w1 < chips[1]
&& w1 < markers[1]
&& markers[1] < chips[1]
&& chips[1] < w2
&& w2 < chips[2]
&& chips[2] < w3,
&& w2 < markers[2]
&& markers[2] < chips[2]
&& chips[2] < w3
&& w3 < markers[3],
"chain out of order; screen:\n{screen}"
);
assert!(
@ -193,5 +180,5 @@ async fn endline_wakeups_are_markerless() {
"markers must never carry a still-running suffix; screen:\n{screen}"
);
write_cast_if_requested(&harness, "endline_wakeups_are_markerless.cast");
write_cast_if_requested(&harness, "endline_wakeups_close_with_markers.cast");
}

View file

@ -26,6 +26,14 @@ async fn minimal_continue_reprints_transcript() {
first
.wait_for_full_text(&turn_sentinel(1), Duration::from_secs(30))
.expect("turn 1 committed to scrollback");
// Idle before quit so the agent finishes turn completion + updates.jsonl
// flush. Quitting mid-finalize under suite load left `--continue` loading a
// session with the user message but no assistant payload (resume then
// shows "Loading session…" / empty chrome past RESUME_TIMEOUT).
first
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(15))
.expect("turn 1 returned to idle before quit");
first.update(Duration::from_millis(300));
quit_minimal(&mut first);
// Resume the same session. The transcript is reprinted into native

View file

@ -1,14 +1,7 @@
//! PTY: the core "queued message appears 2x" regression. A message queued
//! mid-turn HOLDS through the turn's sendable wait, rendering exactly once
//! as a queue row (id/kind+text reconciled — no optimistic-echo duplicate),
//! suppressing the park marker, and advertising send-now on the status row;
//! after the wait returns and the turn ends it drains as its own turn and
//! renders exactly once as a " " block.
//!
//! Flag-file driven like `endline_park_two_static_markers`: background a
//! flag-gated command, hold the turn on a flag-gated foreground command
//! (queueing happens in this window), then block on
//! `get_command_or_subagent_output(timeout_ms: 600000)`.
//! PTY, flag-file driven like `endline_park_is_markerless`: the "queued
//! message appears 2x" regression. A message queued mid-turn holds through
//! the turn's sendable wait, then drains as its own turn — asserting it
//! renders exactly once as a queue row and exactly once as a " " block.
#[allow(unused_imports)]
use super::common::*;
@ -112,7 +105,7 @@ async fn queued_message_renders_once_not_twice() {
// The wait parks the turn with the row HELD: the status row explains the
// hold ("1 queued — Enter to send now"; the top row is a sendable server
// row), the park marker is suppressed, and the row renders exactly once.
// row), the park writes no marker, and the row renders exactly once.
harness
.wait_for_text("1 queued \u{2014} Enter to send now", Duration::from_secs(60))
.unwrap_or_else(|_| {
@ -124,7 +117,7 @@ async fn queued_message_renders_once_not_twice() {
});
assert!(
!harness.contains_text("Worked for"),
"held queued rows must suppress the park marker\nscreen:\n{}",
"a park writes no marker\nscreen:\n{}",
harness.screen_contents()
);
assert_eq!(

View file

@ -1,14 +1,7 @@
//! PTY: a re-parked wait (new parent output between parks) pushes a fresh
//! parked marker for the new park episode, so the transcript keeps a
//! boundary where each park began, while the persistent "… still running"
//! status row explains the still-running background work.
//!
//! Wire journey, flag-file driven like `endline_park_two_static_markers`:
//! background a flag-gated command, hold on a flag-gated foreground command
//! while the runtime task id is extracted, then script three more rounds on
//! the real id — a short wait (`timeout_ms: 4000`) that expires with the
//! task still running (park #1 + marker), a quick foreground echo, and a
//! long wait (park #2: chrome hidden and a fresh marker for the new episode).
//! PTY, flag-file driven like `endline_park_is_markerless`: a short wait that
//! expires (park #1), foreground work between the parks, then a long wait on
//! the same still-running task (park #2). Asserts neither park writes a
//! transcript row and only the real turn end pushes the single "Worked for X".
#[allow(unused_imports)]
use super::common::*;
@ -28,7 +21,7 @@ const FINAL: &str = "REPARK_FINAL_ANSWER";
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn reparked_wait_repushes_buried_marker() {
async fn reparked_wait_stays_markerless() {
let content = ContentController::start().await.expect("start content");
// Gates the background command both waits block on (released at the end).
let park_flag = content.home().join("repark_flag");
@ -99,8 +92,7 @@ async fn reparked_wait_repushes_buried_marker() {
)
});
// Tool call 3 — park #1: a short wait that expires with the task still
// running.
// Tool call 3 — park #1: a short wait that expires with the task still running.
let short_wait_args = json!({
"task_ids": [task_id],
"timeout_ms": 4_000
@ -113,8 +105,7 @@ async fn reparked_wait_repushes_buried_marker() {
short_wait_args,
);
// Tool call 4: foreground work between the parks (`MIDWORK` is the
// on-screen sentinel).
// Tool call 4: foreground work between the parks (`MIDWORK` is the on-screen sentinel).
let midwork_args = json!({
"command": "echo repark-midwork-done",
"description": MIDWORK
@ -143,27 +134,20 @@ async fn reparked_wait_repushes_buried_marker() {
// Everything downstream is scripted — release the id-extraction hold.
std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold");
// Park #1 marker (plain "Worked for X" — no still-running suffix).
harness
.wait_for_text("Worked for", Duration::from_secs(90))
.unwrap_or_else(|_| {
panic!(
"park #1 marker never appeared; screen:\n{}\n--- non-system messages ---\n{}",
harness.screen_contents(),
dump_non_system_messages(&content.request_bodies())
)
});
// The parked status row carries the still-running story instead.
harness
.wait_for_text("1 command still running", Duration::from_secs(30))
.unwrap_or_else(|_| {
panic!(
"parked watching cue never appeared; screen:\n{}",
harness.screen_contents()
)
});
let park_one = wait_until(Duration::from_secs(90), || {
harness.update(Duration::from_millis(100));
let screen = harness.screen_contents();
screen.contains("1 command still running")
&& screen.contains("send a message to interrupt")
&& !screen.contains("Worked for")
});
assert!(
park_one,
"park #1 must show the parked cue with no marker; screen:\n{}\n--- non-system messages ---\n{}",
harness.screen_contents(),
dump_non_system_messages(&content.request_bodies())
);
// The short wait expires and the same turn resumes.
harness
.wait_for_text(MIDWORK, Duration::from_secs(60))
.unwrap_or_else(|_| {
@ -174,7 +158,6 @@ async fn reparked_wait_repushes_buried_marker() {
)
});
// Park #2: the running chrome drops again.
let chrome_hidden = wait_until(Duration::from_secs(30), || {
harness.update(Duration::from_millis(100));
!harness.contains_text(CANCEL_HINT)
@ -185,44 +168,21 @@ async fn reparked_wait_repushes_buried_marker() {
harness.screen_contents()
);
// Park #2 pushes a second marker below the between-parks content (a new
// park episode after new parent output).
let repushed = wait_until(Duration::from_secs(30), || {
let park_two = wait_until(Duration::from_secs(30), || {
harness.update(Duration::from_millis(100));
harness.screen_contents().matches("Worked for").count() == 2
let screen = harness.screen_contents();
screen.contains("1 command still running") && !screen.contains("Worked for")
});
assert!(
repushed,
"re-park after buried marker must push a fresh marker; screen:\n{}",
park_two,
"park #2 must stay markerless with the parked cue up; screen:\n{}",
harness.screen_contents()
);
let screen = harness.screen_contents();
// Screen text is row-major: marker, content, re-pushed marker in order.
let first_marker = screen.find("Worked for").expect("first marker");
// U+2800U+28FF = the braille spinner glyphs.
let midwork_at = screen
.rfind(MIDWORK)
.expect("between-parks content on screen");
let second_marker = screen.rfind("Worked for").expect("re-pushed marker");
assert!(
first_marker < midwork_at && midwork_at < second_marker,
"expected marker, content, then the re-pushed marker in order; screen:\n{screen}"
);
// The still-running story lives in the status row, not the transcript:
// no "Worked for" line carries the suffix (line-scoped like the sibling
// suites — other surfaces may legitimately use the phrase).
assert!(
screen
.lines()
.filter(|l| l.contains("Worked for"))
.all(|l| !l.contains("still running")),
"no marker line may carry the still-running suffix; screen:\n{screen}"
);
assert!(
screen.contains("1 command still running"),
"the parked status row keeps the still-running cue during park #2; screen:\n{screen}"
);
// The parked look still hides spinner and chrome.
let below_midwork = &screen[midwork_at..];
assert!(
!below_midwork
@ -235,10 +195,9 @@ async fn reparked_wait_repushes_buried_marker() {
"parked look keeps the running chrome hidden during park #2; screen:\n{screen}"
);
eprintln!("── re-park with buried marker: tail explains the park ──\n{screen}\n── end ──");
eprintln!("── re-park stays markerless: status cue explains the park ──\n{screen}\n── end ──");
// Liveness: releasing the flag completes the wait and the same turn
// streams the final answer.
// Releasing the flag completes the wait and lets the turn finish.
std::fs::write(&park_flag, b"done").expect("release flag");
harness
.wait_for_text(FINAL, Duration::from_secs(90))
@ -253,11 +212,20 @@ async fn reparked_wait_repushes_buried_marker() {
harness
.wait_for_turn_idle(Duration::from_secs(15))
.expect("turn idle");
let one_final_marker = wait_until(Duration::from_secs(30), || {
harness.update(Duration::from_millis(100));
harness.screen_contents().matches("Worked for").count() == 1
});
assert!(
one_final_marker,
"exactly one marker — the real turn end's; screen:\n{}",
harness.screen_contents()
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
write_cast_if_requested(&harness, "reparked_wait_repushes_buried_marker.cast");
write_cast_if_requested(&harness, "reparked_wait_stays_markerless.cast");
harness.quit().expect("clean quit");
}

View file

@ -1,14 +1,7 @@
//! PTY: the parked look is not sticky — when a parked sendable wait RETURNS
//! and the model resumes streaming in the SAME turn, the running chrome
//! (turn-status row + cancel keybar) must come back while the continuation
//! streams (regression: stale tracker waits kept the idle look after resume).
//!
//! Flag-file driven like `endline_park_two_static_markers`: background a
//! flag-gated command, hold the turn on a flag-gated foreground command while
//! the runtime task id is extracted, then block on
//! `get_command_or_subagent_output(timeout_ms: 600000)` — the pager parks.
//! Releasing the flag completes the task, the wait returns, and the scripted
//! slow continuation streams in the same turn.
//! PTY, flag-file driven like `endline_park_is_markerless`: the pager parks
//! on a blocking wait; releasing the flag completes the task and a slow
//! continuation streams in the SAME turn. Asserts the running chrome returns
//! (regression: stale tracker waits kept the idle look after resume).
#[allow(unused_imports)]
use super::common::*;
@ -105,23 +98,23 @@ async fn spinner_reappears_after_wait_resumes() {
std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold");
// Parked look: the plain marker renders, the "… still running" cue takes
// the status row, and the running chrome (cancel keybar) drops — the
// Parked look: the parked cue takes the status row (parks write no
// transcript row) and the running chrome (cancel keybar) drops — the
// session reads as stopped.
harness
.wait_for_text("Worked for", Duration::from_secs(60))
.wait_for_text("1 command still running", Duration::from_secs(60))
.unwrap_or_else(|_| {
panic!(
"parked marker never appeared; screen:\n{}\n--- non-system messages ---\n{}",
"parked watching cue never appeared; screen:\n{}\n--- non-system messages ---\n{}",
harness.screen_contents(),
dump_non_system_messages(&content.request_bodies())
)
});
harness
.wait_for_text("1 command still running", Duration::from_secs(30))
.wait_for_text("send a message to interrupt", Duration::from_secs(30))
.unwrap_or_else(|_| {
panic!(
"parked watching cue never appeared; screen:\n{}",
"parked interrupt cue never appeared; screen:\n{}",
harness.screen_contents()
)
});

View file

@ -13,14 +13,14 @@ mod common;
mod background_task_reaped_on_quit;
#[path = "pty_e2e/continue_resumes_session_with_history.rs"]
mod continue_resumes_session_with_history;
#[path = "pty_e2e/endline_park_two_static_markers.rs"]
mod endline_park_two_static_markers;
#[path = "pty_e2e/endline_wakeups_are_markerless.rs"]
mod endline_wakeups_are_markerless;
#[path = "pty_e2e/endline_park_is_markerless.rs"]
mod endline_park_is_markerless;
#[path = "pty_e2e/endline_wakeups_close_with_markers.rs"]
mod endline_wakeups_close_with_markers;
#[path = "pty_e2e/rename_title_shows_in_prompt_border.rs"]
mod rename_title_shows_in_prompt_border;
#[path = "pty_e2e/reparked_wait_repushes_buried_marker.rs"]
mod reparked_wait_repushes_buried_marker;
#[path = "pty_e2e/reparked_wait_stays_markerless.rs"]
mod reparked_wait_stays_markerless;
#[path = "pty_e2e/spinner_reappears_after_wait_resumes.rs"]
mod spinner_reappears_after_wait_resumes;
#[path = "pty_e2e/storage_upload_parks_on_401_and_drains_after_recovery.rs"]