Synced from monorepo

Changes:
- Gate session-lifecycle heap steady state with a dhat soak
- Unbreak merge lifecycle e2e after default model → grok-4.5
- Scan home-scope rules dirs at <root>/rules
- Complete text-input paste and terminal parity
- Gate project roles and personas
- Use canonical editing in dialogs
- Use canonical editing in search bars
- Reject ambiguous MCP tool IDs
- Harden Git operands for plugins
- Simplify queue drain API
- Pass RFC 9207 iss through MCP OAuth token exchange
- Show leader roster when local agents map is empty
- Use canonical editing in Persona views
- Remove marketplace default-skills auto-install and purge old installs
- Use canonical editing in extension forms
- Add canonical dashboard text editing
- Use canonical editing in settings
- Add /summarize as a /recap alias
- Restore previous agent when exiting dashboard
- Use tool_choice auto for compaction
- Settings toggle for snap-prompt-to-top on send
- Update default models to grok-4.5
- Source login shell once for local bash (env + alias/function snapshot)
- Template hardcoded param names in server-native tool descriptions
- Fix System-Reminder XML tag injection in CLAUDE.md via agents_md
- Fix remote workspace-server hardcoding LSP trust (repo code execution risk)
- Clear orphaned tool-call updates at turn end
- Suppress task wake after cancel
- Send x-grok-client-identifier on direct API tool calls
- Harden dashboard peek lease transitions
- Host /btw side panel in live region (minimal mode)
- Bound scroll presentation latency
- Highlight multi-line constructs correctly in diffs and the file viewer
- Block web_fetch non-public IPs; local opt-in is explicit-host only
- Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness
- Follow up clipboard delivery feedback
- Use canonical editing in pickers
- Route TextArea through canonical editor
- Persistent "watching" status row; quieter turn markers
- Gate sensitive edit targets
- Expose agent registry counts and gate session churn on them
- Default coding data sharing to opt-out until server preference applies
- Wire chat attachment ids through gateway prompts
- On auth refresh failure, issue retry
- Forward preview provenance and computer lifecycle state
- Document independent privacy controls and scope /privacy output
- Strip SamplingError Display prefix on rate-limit UI copy
- Stop dumping Cloudflare HTML into Retry failed
- Disable in-place prompt edit (scroll jank on enter)
- Strip forced ANSI color from gh pr view JSON
- Plumb bash tool description onto ToolUsageCard wire
This commit is contained in:
grokkybara[bot] 2026-07-18 19:48:28 +01:00
commit 7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions

View file

@ -22,6 +22,12 @@ use super::common::*;
#[cfg(unix)]
const CLARIFY_MARKER: &str = "CLARIFY_MARKER_XYZ";
#[cfg(unix)]
const POST_CANCEL_MARKER: &str = "POST_CANCEL_MARKER_XYZ";
#[cfg(unix)]
const UNWANTED_AUTO_WAKE_SENTINEL: &str = "UNWANTED_AUTO_WAKE_SENTINEL_XYZ";
/// Background sleep that triggers the auto-wake on completion. Long enough
/// that turn 1 settles and the auto-wake scripts are enqueued before it fires,
/// even on a loaded CI host.
@ -260,3 +266,203 @@ async fn auto_wake_cancel_preserves_queued_user_prompt() {
full contents:\n{resumed_full_text}"
);
}
#[cfg(unix)]
fn unified_log_diagnostics(content: &ContentController) -> String {
let path = content.home().join(".grok/logs/unified.jsonl");
let log = std::fs::read_to_string(path).unwrap_or_default();
let mut tail: Vec<&str> = log.lines().rev().take(80).collect();
tail.reverse();
let relevant = log
.lines()
.filter(|line| line.contains("task_wake") || line.contains("shell.cancel"))
.collect::<Vec<_>>()
.join("\n");
format!(
"{}\n--- all task_wake / shell.cancel lines ---\n{relevant}",
tail.join("\n")
)
}
#[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 cancel_before_task_completion_defers_auto_wake_until_user_prompt() {
let content = ContentController::start().await.expect("start content");
let bg_done_flag = content.home().join("post_cancel_bg_done");
let bg_command = format!(
"while [ ! -e {} ]; do /bin/sleep 0.2; done",
bg_done_flag.display()
);
let bg_args = json!({
"command": bg_command,
"description": "post-cancel completion",
"is_background": true
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_bg_after_cancel",
"run_terminal_command",
&bg_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_bg_after_cancel",
"run_terminal_command",
&bg_args,
)),
);
let hold_started_flag = content.home().join("post_cancel_hold_started");
let hold_command = format!(
": > {}; while true; do /bin/sleep 0.2; done",
hold_started_flag.display()
);
let hold_args = json!({
"command": hold_command,
"description": "ordinary turn hold"
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_hold_after_bg",
"run_terminal_command",
&hold_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_hold_after_bg",
"run_terminal_command",
&hold_args,
)),
);
content.set_response(UNWANTED_AUTO_WAKE_SENTINEL);
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--yolo", "--trust"],
Some(content.home()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
let task_id = poll_for(Duration::from_secs(30), || {
content
.request_bodies()
.iter()
.find_map(|body| extract_task_id(&body.to_string()))
})
.unwrap_or_else(|| {
panic!(
"background task never started\n--- non-system messages ---\n{}",
dump_non_system_messages(&content.request_bodies())
)
});
let follow_up_started = poll_for(Duration::from_secs(15), || {
hold_started_flag.exists().then_some(())
})
.is_some();
assert!(
follow_up_started,
"foreground hold never started\n--- non-system messages ---\n{}",
dump_non_system_messages(&content.request_bodies())
);
harness.inject_keys(keys::CTRL_C).expect("press ctrl+c");
harness
.wait_for_text("Turn cancelled by user", Duration::from_secs(15))
.expect("ordinary turn cancelled");
harness
.wait_for_turn_idle(Duration::from_secs(15))
.expect("cancelled turn idle");
assert!(
!harness.contains_full_text("Task completed in"),
"background task completed before release"
);
std::fs::write(&bg_done_flag, b"done").expect("complete background task");
harness
.wait_for_full_text("Task completed in", Duration::from_secs(15))
.expect("background completion chip");
harness
.wait_until_stable(
"no auto-wake response after background completion",
Duration::from_secs(2),
Duration::from_secs(2),
|h| !h.contains_full_text(UNWANTED_AUTO_WAKE_SENTINEL),
)
.unwrap_or_else(|error| {
panic!(
"{error}\n--- unified diagnostics ---\n{}",
unified_log_diagnostics(&content)
)
});
harness
.inject_keys(POST_CANCEL_MARKER.as_bytes())
.expect("type post-cancel prompt");
harness.update(Duration::from_millis(300));
harness
.inject_keys(b"\r")
.expect("submit post-cancel prompt");
let reminder_on_wire = poll_for(Duration::from_secs(30), || {
content.request_bodies().iter().find_map(|body| {
let serialized = body.to_string();
(serialized.contains(POST_CANCEL_MARKER)
&& serialized.contains("Background task")
&& serialized.contains("completed")
&& serialized.contains(&task_id))
.then_some(())
})
})
.is_some();
harness
.wait_for_full_text(UNWANTED_AUTO_WAKE_SENTINEL, Duration::from_secs(15))
.expect("genuine user turn response");
harness
.wait_for_turn_idle(Duration::from_secs(15))
.expect("genuine user turn idle");
harness
.wait_until_stable(
"no second completion request after the user turn",
Duration::from_secs(2),
Duration::from_secs(2),
|_| {
content
.request_bodies()
.iter()
.filter(|body| body.to_string().contains(POST_CANCEL_MARKER))
.count()
== 1
},
)
.expect("deferred completion consumed atomically");
write_cast_if_requested(&harness, "auto_wake_cancel_before_completion.cast");
harness.quit().expect("quit pager");
assert!(
reminder_on_wire,
"the next genuine user request must include the deferred task-completion reminder\n\
--- non-system messages ---\n{}\n--- unified diagnostics ---\n{}",
dump_non_system_messages(&content.request_bodies()),
unified_log_diagnostics(&content)
);
}

View file

@ -1,5 +1,6 @@
//! PTY: a parked wait produces two static markers — the park pushes "Turn
//! completed in X. 1 command still running…" and the turn that follows ends
//! 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
//! "watching · …" 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
@ -31,8 +32,8 @@ 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 both markers
// snapshot ("1 command still running…").
// Tool call 1: a flag-gated background command — the work the watching
// cue counts ("watching · 1 command").
let bg_args = json!({
"command": gated_loop(&park_flag),
"description": "flag-gated command",
@ -147,9 +148,10 @@ 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 completion with the count.
// 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("1 command still running", Duration::from_secs(90))
.wait_for_text("Worked for", Duration::from_secs(90))
.unwrap_or_else(|_| {
panic!(
"parked marker never appeared; screen:\n{}\n--- non-system messages ---\n{}",
@ -157,9 +159,17 @@ async fn endline_park_two_static_markers() {
dump_non_system_messages(&content.request_bodies())
)
});
harness
.wait_for_text("watching · 1 command", Duration::from_secs(30))
.unwrap_or_else(|_| {
panic!(
"parked watching cue never appeared; screen:\n{}",
harness.screen_contents()
)
});
assert!(
harness.screen_contents().contains("Worked for"),
"the parked marker keeps the completion prefix; screen:\n{}",
!harness.contains_text("still running"),
"the parked marker carries no still-running suffix; screen:\n{}",
harness.screen_contents()
);
@ -189,15 +199,16 @@ async fn endline_park_two_static_markers() {
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 (also counting the still-gated command)
// below it — with NO cancelled marker anywhere (silent send-now cancel).
// and the new turn's final marker below it — both plain "Worked for X"
// lines (no still-running suffix) — with NO cancelled marker anywhere
// (silent send-now cancel).
let two_markers = 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.matches("1 command still running").count() == 2
&& !screen.contains("still running")
&& !screen.contains("Turn cancelled by user")
&& matches!(
(

View file

@ -1,14 +1,13 @@
//! PTY: every wake turn ends with a real marker — a turn ends with three
//! flag-gated background commands running ("3 commands still running…"), and
//! each released flag lands a completion chip, the auto-wake response, then a
//! FRESH wake-end marker snapshotting the remaining counts ("2 …", "1 …"),
//! while every earlier line stays unchanged above (nothing mutates). The last
//! wake's marker is the plain form ("Worked for X." — zero left), and
//! no after-chip work-only status lines appear anywhere: the shell stamps
//! `will_wake` on each completion, so the wake markers carry the counts.
//! 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 "watching · N commands" status row above
//! the prompt counts the remaining work down between wakes and disappears
//! once nothing is left; no "still running" copy appears anywhere.
//!
//! Positional chain asserted at the end: marker(3) < chip < wake reply <
//! marker(2) < chip < reply < marker(1) < chip < reply < plain final marker.
//! Positional chain asserted at the end: marker < chip < wake reply < chip <
//! reply < chip < reply — exactly ONE "Worked for" total (the user turn's).
#[allow(unused_imports)]
use super::common::*;
@ -24,7 +23,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_wake_markers_close_each_wakeup() {
async fn endline_wakeups_are_markerless() {
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}")))
@ -98,8 +97,8 @@ async fn endline_wake_markers_close_each_wakeup() {
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
// The turn ends with all three commands running: the final marker
// carries the snapshot count.
// 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(|_| {
@ -110,79 +109,72 @@ async fn endline_wake_markers_close_each_wakeup() {
)
});
harness
.wait_for_text("3 commands still running", Duration::from_secs(30))
.wait_for_text("Worked for", Duration::from_secs(30))
.unwrap_or_else(|_| {
panic!(
"marker never showed the snapshot count; screen:\n{}",
"the end marker never appeared; screen:\n{}",
harness.screen_contents()
)
});
harness
.wait_for_text("watching · 3 commands", Duration::from_secs(30))
.unwrap_or_else(|_| {
panic!(
"the watching cue never showed the running count; screen:\n{}",
harness.screen_contents()
)
});
assert!(
harness.screen_contents().contains("Worked for"),
"the marker keeps the completion prefix; screen:\n{}",
harness.screen_contents()
);
// Release flag 0: chip → wake reply → a fresh "2 commands" wake-end
// marker below, with the original "3 commands" marker intact above
// 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();
matches!(
(
screen.find("3 commands still running"),
screen.find("WAKE_REPLY_ONE"),
screen.find("2 commands still running"),
),
(Some(three), Some(reply), Some(two)) if three < reply && reply < two
)
screen.contains("WAKE_REPLY_ONE")
&& screen.matches("Worked for").count() == 1
&& screen.contains("watching · 2 commands")
});
assert!(
wake_one,
"expected chip → wake reply → fresh '2 commands' marker below the intact '3 commands' one; screen:\n{}",
"expected chip → wake reply with no wake marker, watching cue at 2; screen:\n{}",
harness.screen_contents()
);
// Release flag 1: the "1 command" wake marker joins below its reply.
// 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();
matches!(
(
screen.find("2 commands still running"),
screen.find("WAKE_REPLY_TWO"),
screen.find("1 command still running"),
),
(Some(two), Some(reply), Some(one)) if two < reply && reply < one
)
screen.contains("WAKE_REPLY_TWO")
&& screen.matches("Worked for").count() == 1
&& screen.contains("watching · 1 command")
});
assert!(
wake_two,
"expected the second wake chain below the earlier lines; screen:\n{}",
"expected the second markerless wake chain below the earlier lines; screen:\n{}",
harness.screen_contents()
);
// Release flag 2: zero left — the last wake ends with the PLAIN marker
// (fourth "Worked for", no new "still running" suffix).
// Release flag 2: zero left — still exactly one marker, and the watching
// cue disappears entirely.
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() == 4
screen.contains("WAKE_REPLY_THREE")
&& screen.matches("Worked for").count() == 1
&& !screen.contains("watching ·")
});
assert!(
wake_three,
"the final plain wake marker never landed; screen:\n{}",
"the last wake must stay markerless and retire the watching cue; screen:\n{}",
harness.screen_contents()
);
// Full chain, positional: marker(3) < chip < reply < marker(2) < chip <
// reply < marker(1) < chip < reply < plain final marker — and exactly
// three "still running" lines total (the markers'), i.e. the stamped
// `will_wake` suppressed every after-chip work-only status line.
// Full chain, positional: marker < chip < reply < chip < reply < chip <
// reply — one marker total, and ZERO "still running" lines anywhere.
let screen = harness.screen_contents();
let chips: Vec<usize> = screen
.match_indices("Task completed")
@ -193,34 +185,29 @@ async fn endline_wake_markers_close_each_wakeup() {
TASKS,
"one completion chip per task; screen:\n{screen}"
);
let m3 = screen.find("3 commands still running").expect("marker 3");
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}"
);
let w1 = screen.find("WAKE_REPLY_ONE").expect("wake reply 1");
let m2 = screen.find("2 commands still running").expect("marker 2");
let w2 = screen.find("WAKE_REPLY_TWO").expect("wake reply 2");
let m1 = screen.find("1 command still running").expect("marker 1");
let w3 = screen.find("WAKE_REPLY_THREE").expect("wake reply 3");
let final_marker = screen
.match_indices("Worked for")
.map(|(i, _)| i)
.last()
.expect("final marker");
assert!(
m3 < chips[0]
markers[0] < chips[0]
&& chips[0] < w1
&& w1 < m2
&& m2 < chips[1]
&& w1 < chips[1]
&& chips[1] < w2
&& w2 < m1
&& m1 < chips[2]
&& chips[2] < w3
&& w3 < final_marker,
&& w2 < chips[2]
&& chips[2] < w3,
"chain out of order; screen:\n{screen}"
);
assert_eq!(
screen.matches("still running").count(),
3,
"wake-bound completions must not add work-only status lines; screen:\n{screen}"
0,
"no still-running copy may appear in the transcript; screen:\n{screen}"
);
write_cast_if_requested(&harness, "endline_wake_markers_close_each_wakeup.cast");
write_cast_if_requested(&harness, "endline_wakeups_are_markerless.cast");
}

View file

@ -0,0 +1,150 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
const CTRL_BACKSLASH: &[u8] = b"\x1b[92;5u";
const OPTION_BACKSPACE: &[u8] = b"\x1b\x7f";
const META_B: &[u8] = b"\x1bb";
const META_F: &[u8] = b"\x1bf";
const ALT_LEFT: &[u8] = b"\x1b[1;3D";
const ALT_RIGHT: &[u8] = b"\x1b[1;3C";
const ROW_TITLE: &str = "ITERMROW";
fn click_visible_text(harness: &mut PtyHarness, text: &str) {
harness
.wait_for_text(text, Duration::from_secs(10))
.unwrap_or_else(|_| panic!("{text:?} did not render\n{}", harness.screen_contents()));
let screen = harness.screen_contents();
let (row, col) = screen
.lines()
.enumerate()
.find_map(|(row, line)| {
let byte = line.find(text)?;
let prefix_width = unicode_width::UnicodeWidthStr::width(&line[..byte]) as u16;
let text_width = unicode_width::UnicodeWidthStr::width(text) as u16;
Some((row as u16, prefix_width + text_width / 2))
})
.unwrap_or_else(|| panic!("could not locate {text:?}\n{screen}"));
let click = format!(
"{}{}",
sgr_mouse(0, row, col, 'M'),
sgr_mouse(0, row, col, 'm')
);
harness
.inject_keys(click.as_bytes())
.unwrap_or_else(|error| panic!("click {text:?}: {error}"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; CI runs the ignored pty_e2e suite"]
async fn iterm_raw_readline_sequences_edit_picker_and_dashboard_rename() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} iTerm editing turn."));
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push(("TERM_PROGRAM".into(), "iTerm.app".into()));
let env_refs: Vec<(&str, &str)> = env
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let mut harness =
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit setup prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("turn rendered");
inject_keys_paced(&mut harness, format!("/rename {ROW_TITLE}").as_bytes());
harness
.inject_keys(keys::ENTER)
.expect("seed session title");
harness
.wait_for_text("Session renamed to", Duration::from_secs(15))
.expect("seeded title persisted");
harness.inject_keys(b"\x10").expect("Ctrl+P");
harness
.wait_for_text("Commands", Duration::from_secs(10))
.expect("command palette open");
inject_keys_paced(&mut harness, b"ITERMONE ITERMDELETE");
harness
.wait_for_text("ITERMONE ITERMDELETE", Duration::from_secs(10))
.expect("palette query rendered");
harness
.inject_keys(OPTION_BACKSPACE)
.expect("iTerm Option+Backspace");
wait_for_labels_absent(&mut harness, &["ITERMDELETE"], Duration::from_secs(10));
inject_keys_paced(&mut harness, b"ITERMWORD");
harness.inject_keys(META_B).expect("iTerm Meta-B");
inject_keys_paced(&mut harness, b"MID");
harness.inject_keys(META_F).expect("iTerm Meta-F");
inject_keys_paced(&mut harness, b"END");
harness
.wait_for_text("ITERMONE MIDITERMWORDEND", Duration::from_secs(10))
.expect("raw Meta editing changed palette query");
harness.inject_keys(keys::ESC).expect("clear palette query");
wait_for_labels_absent(
&mut harness,
&["ITERMONE MIDITERMWORDEND"],
Duration::from_secs(10),
);
harness
.inject_keys(keys::ESC)
.expect("close command palette");
wait_for_labels_absent(&mut harness, &["Commands"], Duration::from_secs(10));
harness.inject_keys(CTRL_BACKSLASH).expect("open dashboard");
harness
.wait_for_text("+ New Agent", Duration::from_secs(10))
.expect("dashboard open");
click_visible_text(&mut harness, ROW_TITLE);
harness
.wait_for_text("[Dashboard]", Duration::from_secs(10))
.expect("row click attached the dashboard overlay");
harness
.inject_keys(keys::ESC)
.expect("close attached dashboard row");
wait_for_labels_absent(&mut harness, &["[Dashboard]"], Duration::from_secs(10));
harness
.inject_keys(b"\x12")
.expect("dashboard Ctrl+R rename");
harness
.wait_for_text("rename:", Duration::from_secs(10))
.expect("empty rename editor opened for the titled session row");
inject_keys_paced(&mut harness, b"LEFT RIGHT");
harness.inject_keys(ALT_LEFT).expect("iTerm Alt+Left");
inject_keys_paced(&mut harness, b"MID");
harness.inject_keys(ALT_RIGHT).expect("iTerm Alt+Right");
inject_keys_paced(&mut harness, b"END");
harness
.wait_for_text("rename: LEFT MIDRIGHTEND", Duration::from_secs(10))
.expect("raw Alt arrows changed rename draft");
harness.inject_keys(keys::ENTER).expect("commit rename");
wait_for_labels_absent(&mut harness, &["rename:"], Duration::from_secs(10));
harness
.wait_for_text("LEFT MIDRIGHTEND", Duration::from_secs(10))
.expect("committed dashboard rename visible");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.inject_keys(b"\x11").expect("Ctrl+Q arm");
harness
.wait_for_text("press again to quit", Duration::from_secs(10))
.expect("quit confirmation rendered");
harness.inject_keys(b"\x11").expect("Ctrl+Q confirm");
assert_eq!(
harness.wait_exit_code(Duration::from_secs(10)),
Some(0),
"pager must exit cleanly"
);
}

View file

@ -0,0 +1,91 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
// Default (unset): send pins the new prompt at the viewport top.
// `[ui] page_flip_on_send = false`: send does not move the viewport.
const TAIL_SENTINEL: &str = "TAILSENTINEL_T1";
const SECOND_PROMPT: &str = "second-prompt-marker";
fn tall_first_response() -> String {
let mut s = String::from("```\n");
for i in 0..80 {
s.push_str(&format!("line {i} payload\n"));
}
s.push_str(TAIL_SENTINEL);
s.push_str("\n```\n");
s
}
/// Welcome → tall turn 1 → submit turn 2 while holding turn 2 open.
async fn drive_to_second_send(content: &ContentController) -> PtyHarness {
content.set_response(tall_first_response());
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit first prompt");
harness
.wait_for_text(TAIL_SENTINEL, Duration::from_secs(30))
.expect("turn 1 tail visible");
content.hold_agent_completions();
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} second turn."));
harness
.inject_keys(format!("{SECOND_PROMPT}\r").as_bytes())
.expect("submit second prompt");
harness
.wait_for_text(SECOND_PROMPT, Duration::from_secs(15))
.expect("second prompt rendered");
harness.update(Duration::from_millis(600));
harness
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn send_page_flips_by_default() {
let content = ContentController::start().await.expect("start content");
let mut harness = drive_to_second_send(&content).await;
assert!(
!harness.contains_text(TAIL_SENTINEL),
"default send should page-flip turn 1's tail off screen\nscreen:\n{}",
harness.screen_contents()
);
let screen = harness.screen_contents();
let prompt_row = screen
.lines()
.position(|l| l.contains(SECOND_PROMPT))
.expect("second prompt visible");
assert!(
prompt_row < (DEFAULT_ROWS as usize) / 2,
"flipped prompt should be in the top half (row {prompt_row})\nscreen:\n{screen}"
);
content.release_agent_completions();
harness.quit().expect("clean quit");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn send_keeps_viewport_when_page_flip_disabled() {
let content = ContentController::start().await.expect("start content");
seed_ui_config(&content, "page_flip_on_send = false");
let mut harness = drive_to_second_send(&content).await;
assert!(
harness.contains_text(TAIL_SENTINEL),
"page_flip_on_send=false must leave turn 1's tail on screen\nscreen:\n{}",
harness.screen_contents()
);
content.release_agent_completions();
harness.quit().expect("clean quit");
}

View file

@ -72,10 +72,10 @@ async fn reasoning_efforts_menu_renders_and_remaps_on_wire() {
let sent_xhigh = content
.request_bodies()
.iter()
.any(|b| b.pointer("/reasoning_effort").and_then(|v| v.as_str()) == Some("xhigh"));
.any(|b| b.pointer("/reasoning/effort").and_then(|v| v.as_str()) == Some("xhigh"));
assert!(
sent_xhigh,
"`/effort deep` must send the mapped canonical reasoning_effort=xhigh\nbodies: {:#?}",
"`/effort deep` must send the mapped canonical reasoning.effort=xhigh\nbodies: {:#?}",
content.request_bodies()
);

View file

@ -1,13 +1,14 @@
//! PTY: a re-parked wait re-pushes the parked marker when intervening
//! content buried the previous one, so the transcript tail keeps explaining
//! the idle-looking parked chrome.
//! 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 "watching · …"
//! 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 at the tail).
//! long wait (park #2: chrome hidden and a fresh marker for the new episode).
#[allow(unused_imports)]
use super::common::*;
@ -196,9 +197,9 @@ 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.
// Park #1 marker (plain "Worked for X" — no still-running suffix).
harness
.wait_for_text("1 command still running", Duration::from_secs(90))
.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{}",
@ -206,6 +207,15 @@ async fn reparked_wait_repushes_buried_marker() {
dump_non_system_messages(&content.request_bodies())
)
});
// The parked status row carries the still-running story instead.
harness
.wait_for_text("watching · 1 command", Duration::from_secs(30))
.unwrap_or_else(|_| {
panic!(
"parked watching cue never appeared; screen:\n{}",
harness.screen_contents()
)
});
// The short wait expires and the same turn resumes.
harness
@ -229,14 +239,15 @@ async fn reparked_wait_repushes_buried_marker() {
harness.screen_contents()
);
// Park #2 re-pushes a second marker below the between-parks content.
// 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), || {
harness.update(Duration::from_millis(100));
harness.screen_contents().matches("Worked for").count() == 2
});
assert!(
repushed,
"re-park with a buried marker must re-push a second marker; screen:\n{}",
"re-park after buried marker must push a fresh marker; screen:\n{}",
harness.screen_contents()
);
let screen = harness.screen_contents();
@ -251,10 +262,14 @@ async fn reparked_wait_repushes_buried_marker() {
first_marker < midwork_at && midwork_at < second_marker,
"expected marker, content, then the re-pushed marker in order; screen:\n{screen}"
);
// The re-pushed marker still counts the running work.
// The still-running story lives in the status row, not the transcript.
assert!(
screen[second_marker..].contains("1 command still running"),
"the re-pushed marker carries the live work count; screen:\n{screen}"
!screen.contains("still running"),
"no still-running suffix anywhere in the transcript; screen:\n{screen}"
);
assert!(
screen.contains("watching · 1 command"),
"the parked status row keeps the watching cue during park #2; screen:\n{screen}"
);
// The parked look still hides spinner and chrome.
let below_midwork = &screen[midwork_at..];

View file

@ -139,10 +139,11 @@ async fn spinner_reappears_after_wait_resumes() {
std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold");
// Parked look: the marker renders and the running chrome (turn-status
// row / cancel keybar) drops — the session reads as stopped.
// Parked look: the plain marker renders, the "watching · …" cue takes
// the status row, and the running chrome (cancel keybar) drops — the
// session reads as stopped.
harness
.wait_for_text("1 command still running", Duration::from_secs(60))
.wait_for_text("Worked for", Duration::from_secs(60))
.unwrap_or_else(|_| {
panic!(
"parked marker never appeared; screen:\n{}\n--- non-system messages ---\n{}",
@ -150,11 +151,14 @@ async fn spinner_reappears_after_wait_resumes() {
dump_non_system_messages(&content.request_bodies())
)
});
assert!(
harness.contains_text("Worked for"),
"the parked marker keeps the completion prefix; screen:\n{}",
harness.screen_contents()
);
harness
.wait_for_text("watching · 1 command", Duration::from_secs(30))
.unwrap_or_else(|_| {
panic!(
"parked watching cue never appeared; screen:\n{}",
harness.screen_contents()
)
});
let chrome_hidden = wait_until(Duration::from_secs(10), || {
harness.update(Duration::from_millis(100));
!harness.contains_text(CANCEL_HINT)

View file

@ -24,6 +24,8 @@ mod critical_announcement_session_banner_pty;
mod dashboard_overlay_tab_esc_backout_and_ctrl_backslash;
#[path = "pty_e2e/extensions_modal_copy_hints_pty.rs"]
mod extensions_modal_copy_hints_pty;
#[path = "pty_e2e/iterm_readline_editing.rs"]
mod iterm_readline_editing;
#[path = "pty_e2e/prompt_suggestion_ghost_tab_accepts.rs"]
mod prompt_suggestion_ghost_tab_accepts;
#[path = "pty_e2e/reasoning_efforts_fallback_menu_matches_builtin.rs"]

View file

@ -15,8 +15,8 @@ mod background_task_reaped_on_quit;
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_wake_markers_close_each_wakeup.rs"]
mod endline_wake_markers_close_each_wakeup;
#[path = "pty_e2e/endline_wakeups_are_markerless.rs"]
mod endline_wakeups_are_markerless;
#[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"]

View file

@ -38,6 +38,8 @@ mod mouse_reporting_toggle_inactive_without_config_pty;
mod mouse_reporting_toggle_sticky_persists_pty;
#[path = "pty_e2e/nested_quote_drag_copy_excludes_bars_pty.rs"]
mod nested_quote_drag_copy_excludes_bars_pty;
#[path = "pty_e2e/page_flip_on_send_pty.rs"]
mod page_flip_on_send_pty;
#[path = "pty_e2e/quote_block_drag_copy_excludes_bars_pty.rs"]
mod quote_block_drag_copy_excludes_bars_pty;
#[path = "pty_e2e/quote_block_raw_mode_copy_keeps_source_pty.rs"]

File diff suppressed because it is too large Load diff