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:
parent
6e38642082
commit
47348d13ec
138 changed files with 7283 additions and 5796 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 + L10–L12. 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 L06–L12 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{}",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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+2800–U+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");
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Reference in a new issue