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:
parent
98c3b2438a
commit
7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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..];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue