Synced from monorepo

Synced from monorepo

Changes:
- Shell: accept target response id on rewind execute
- Shell: stamp response id on chat user message chunks
- Worktree: optional rebuild and stale git registration cleanup in auto-GC
- Worktree: kind-aware auto-GC TTLs and config knobs
- Worktree: macOS process CWD scan and Unix PID liveness for GC guards
- Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only)
- Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups
- Shell: stop overwriting user skills
- Tools: read markdown in `skills/` directories untruncated
- `/usage` shows per-session token and dollar usage in the TUI
- Security: prompt on environment-dumping `ps` variants
- Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission
- Tools: make scheduler deletion durable
- Shell: add relocation storage primitives
- Shell: give side model calls their own conversation ids
- Fix five workflow-runtime bugs (budget, pause, cancel, reconnect)
- Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask)
- Pager: expose doctor in the TUI
- Security: block unauthorized RCE via abused safe commands
- Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent"
- Security: block `rg --pre` arbitrary code execution in auto-mode
- Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section
- App builder deployer: `allow_forking` and `show_built_with_grok`
- Pager: stop stacking duplicate "Worked for" markers on parked turns
- Shell: support `max` as a distinct reasoning effort tier
- Tools: serialize background `/loop` fires on the whole work unit
- Shell: add working-directory relocation state primitives
- Proto: `ClientToolResult` and `ChatConfig` client-side tools
- Shell: model providers
- Chat: select App Builder product on the Build path
- Shell: attach author identity to feedback when the deployment opts in
- Doctor: fix for SSH wrap setup
- Workflow authoring skills: create-workflow and import-claude-workflow docs
- Add read-only grok doctor
- Sandbox: apply Landlock without a controlling TTY
- Pager: recover image paste over grok wrap on headless remotes
- Pager: make actions screen-mode aware
- Shell: resume sessions when the working directory moves
- Pager: centralize terminal diagnostics
- Workspace: gate inline shell file access
- Pager: centralize terminal probes
- Pager: edit minimal prompts in an external editor
- Pager: standardize backgrounding on Ctrl+B
- Shell: recap rides the parent turn's prompt cache
- Tools: add scheduler lifecycle version clock

Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
grokkybara[bot] 2026-07-21 18:10:23 +00:00
commit 3af4d5d398
556 changed files with 56609 additions and 21892 deletions

View file

@ -0,0 +1,273 @@
use std::collections::HashSet;
use std::process::{Command, Stdio};
fn pager_binary() -> Result<std::path::PathBuf, String> {
for key in ["PAGER_BINARY", "CARGO_BIN_EXE_xai-grok-pager"] {
if let Some(value) = std::env::var_os(key) {
let path = std::path::PathBuf::from(value);
if path.exists() {
return Ok(path);
}
}
}
Err("PAGER_BINARY/CARGO_BIN_EXE_xai-grok-pager not set".to_owned())
}
#[test]
#[ignore = "spawns the real pager binary; CI/Bazel provides PAGER_BINARY"]
fn doctor_json_bypasses_unrelated_startup_state() {
let binary = pager_binary().expect("real pager binary is required when this test is selected");
let temp = tempfile::tempdir().expect("tempdir");
let home = temp.path().join("home");
let grok_home = temp.path().join("grok-home");
std::fs::create_dir_all(&home).expect("create HOME");
std::fs::create_dir_all(&grok_home).expect("create GROK_HOME");
let version_path = grok_home.join("version.json");
std::fs::write(
&version_path,
br#"{"stable":{"version":"999.0.0"},"checked_at":0}"#,
)
.expect("write valid hostile version state");
let before = directory_entries(&grok_home);
let output = run_pager(
&binary,
&home,
&grok_home,
"/bin/sh",
&["doctor", "--json"],
&[],
);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.stderr.is_empty(),
"stderr must be clean: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout is one JSON document");
assert_eq!(json["schemaVersion"], "1");
assert!(!String::from_utf8_lossy(&output.stdout).contains("Grok Doctor"));
let after = directory_entries(&grok_home);
assert_eq!(after, before, "doctor must not create startup artifacts");
assert_eq!(
std::fs::read(&version_path).unwrap(),
br#"{"stable":{"version":"999.0.0"},"checked_at":0}"#
);
for absent in ["docs", "crash", "memtrace", "active_sessions.json"] {
assert!(
!grok_home.join(absent).exists(),
"unexpected startup artifact: {absent}"
);
}
}
#[test]
#[ignore = "spawns the real pager binary; CI/Bazel provides PAGER_BINARY"]
fn doctor_fix_yes_writes_only_actual_home_shell_rc() {
let binary = pager_binary().expect("real pager binary is required when this test is selected");
let temp = tempfile::tempdir().expect("tempdir");
let home = temp.path().join("home");
let grok_home = temp.path().join("grok-home");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&grok_home).unwrap();
let output = run_pager(
&binary,
&home,
&grok_home,
"/bin/bash",
&["doctor", "fix", "terminal.ssh-wrap", "--yes"],
&[],
);
assert!(
output.status.success(),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("Doctor fix: terminal.ssh-wrap"));
assert!(stdout.contains("ssh -f"));
assert!(stdout.contains("ControlPersist"));
assert!(stdout.contains("~^Z"));
assert!(stdout.contains("command ssh"));
assert_eq!(
std::fs::read_to_string(home.join(".bashrc")).unwrap(),
"# >>> grok doctor >>>\n# >>> terminal.ssh-wrap >>>\nalias ssh='grok wrap ssh'\n# <<< terminal.ssh-wrap <<<\n# <<< grok doctor <<<"
);
assert!(!grok_home.join(".bashrc").exists());
}
#[test]
#[ignore = "spawns the real pager binary; CI/Bazel provides PAGER_BINARY"]
fn doctor_fix_safety_boundaries_are_process_isolated() {
let binary = pager_binary().expect("real pager binary is required when selected");
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
let grok_home = temp.path().join("qhome");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&grok_home).unwrap();
let conflict = home.join(".zshrc");
std::fs::write(&conflict, "alias ssh='ssh -A'\n").unwrap();
let output = run_pager(
&binary,
&home,
&grok_home,
"/bin/zsh",
&["doctor", "fix", "ssh-wrap", "--yes"],
&[],
);
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("existing SSH alias/function"));
assert_eq!(
std::fs::read_to_string(&conflict).unwrap(),
"alias ssh='ssh -A'\n"
);
std::fs::remove_file(&conflict).unwrap();
let output = run_pager(
&binary,
&home,
&grok_home,
"/bin/zsh",
&["doctor", "fix", "ssh-wrap"],
&[],
);
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stdout).contains("Doctor fix: terminal.ssh-wrap"));
assert!(
String::from_utf8_lossy(&output.stderr).contains("non-interactive stdin without --yes")
);
assert!(!conflict.exists());
let output = run_pager(
&binary,
&home,
&grok_home,
"/bin/zsh",
&["doctor", "fix", "ssh-wrap", "--yes"],
&[("SSH_CONNECTION", "1 2 3 4")],
);
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("run this fix on your local machine"));
assert!(!conflict.exists());
}
#[cfg(unix)]
#[test]
#[ignore = "spawns the real pager binary; CI/Bazel provides PAGER_BINARY"]
fn restrictive_umask_still_preserves_exact_rc_mode() {
use std::os::unix::fs::PermissionsExt as _;
let binary = pager_binary().expect("real pager binary is required when selected");
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
let grok_home = temp.path().join("qhome");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&grok_home).unwrap();
let rc = home.join(".bashrc");
std::fs::write(&rc, "export KEEP=1\n").unwrap();
std::fs::set_permissions(&rc, std::fs::Permissions::from_mode(0o666)).unwrap();
let mut command = base_pager_command(&binary, &home, &grok_home, "/bin/bash");
command.args(["doctor", "fix", "terminal.ssh-wrap", "--yes"]);
use std::os::unix::process::CommandExt as _;
// SAFETY: umask is async-signal-safe and runs only in the isolated child.
unsafe {
command.pre_exec(|| {
libc::umask(0o077);
Ok(())
});
}
let output = command.output().expect("run restrictive-umask pager");
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
std::fs::metadata(&rc).unwrap().permissions().mode() & 0o7777,
0o666
);
}
#[test]
#[ignore = "spawns the real pager binary; CI/Bazel provides PAGER_BINARY"]
fn wrap_non_tty_true_exec_preserves_argv_and_exit() {
let binary = pager_binary().expect("real pager binary is required when selected");
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
let grok_home = temp.path().join("qhome");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&grok_home).unwrap();
let output = run_pager(
&binary,
&home,
&grok_home,
"/bin/sh",
&[
"wrap",
"/bin/sh",
"-c",
"printf '%s' \"$1\"; exit 7",
"sh",
"argv-ok",
],
&[],
);
assert_eq!(output.status.code(), Some(7));
assert_eq!(output.stdout, b"argv-ok");
}
fn run_pager(
binary: &std::path::Path,
home: &std::path::Path,
grok_home: &std::path::Path,
shell: &str,
args: &[&str],
extra_env: &[(&str, &str)],
) -> std::process::Output {
let mut command = base_pager_command(binary, home, grok_home, shell);
command.args(args).envs(extra_env.iter().copied());
command.output().expect("run isolated pager binary")
}
fn base_pager_command(
binary: &std::path::Path,
home: &std::path::Path,
grok_home: &std::path::Path,
shell: &str,
) -> Command {
let mut command = Command::new(binary);
command
.env_clear()
.env("HOME", home)
.env("GROK_HOME", grok_home)
.env("SHELL", shell)
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.env("TERM", "xterm-256color")
.env("NO_COLOR", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.envs(xai_tty_utils::pager_env());
xai_tty_utils::detach_std_command(&mut command);
command
}
fn directory_entries(path: &std::path::Path) -> HashSet<std::ffi::OsString> {
std::fs::read_dir(path)
.expect("read directory")
.map(|entry| entry.expect("directory entry").file_name())
.collect()
}

View file

@ -56,22 +56,8 @@ async fn auto_wake_cancel_preserves_queued_user_prompt() {
"is_background": true
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_bg_wake",
"run_terminal_command",
&bg_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_bg_wake",
"run_terminal_command",
&bg_args,
)),
);
let _background_turn =
expect_tool_turn(&content, "call_bg_wake", "run_terminal_command", bg_args);
content.set_response("TURN1_SETTLED");
let binary = pager_binary().expect("resolve pager binary");
@ -122,42 +108,22 @@ async fn auto_wake_cancel_preserves_queued_user_prompt() {
// consumed-completion sweep), then a foreground sleep that pins the turn
// running while the user message and Ctrl+C land.
let poll_args = json!({ "task_ids": [task_id.clone()] }).to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_wake_poll",
"get_command_or_subagent_output",
&poll_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_wake_poll",
"get_command_or_subagent_output",
&poll_args,
)),
let _poll_turn = expect_tool_turn(
&content,
"call_wake_poll",
"get_command_or_subagent_output",
poll_args,
);
let hold_args = json!({
"command": format!("/bin/sleep {HOLD_SLEEP_SECS}"),
"description": "hold turn"
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_wake_hold",
"run_terminal_command",
&hold_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_wake_hold",
"run_terminal_command",
&hold_args,
)),
let _hold_turn = expect_tool_turn(
&content,
"call_wake_hold",
"run_terminal_command",
hold_args,
);
// Fallback for every unscripted request after the queues drain (and the
// response the surviving user prompt streams on the fixed path).

View file

@ -42,21 +42,7 @@ async fn background_task_reaped_on_quit() {
"is_background": true
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_bg",
"run_terminal_command",
&args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events(
"run_terminal_command",
&args,
)),
);
let _background_turn = expect_tool_turn(&content, "call_bg", "run_terminal_command", args);
// Follow-up turns settle to plain text so the session goes idle.
content.set_response("BG_TASK_STARTED");

View file

@ -48,17 +48,6 @@ fn write_asciicast(path: &Path, cols: u16, rows: u16, events: &[(f64, String)])
}
}
fn enqueue_tool_turn(content: &ContentController, call_id: &str, name: &str, args: String) {
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(call_id, name, &args)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events(name, &args)),
);
}
/// PTY demo: long abs path → collapsed header shows basename only; open block
/// viewer and show full path in modal preamble. Dumps asciicast for video.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@ -74,7 +63,7 @@ async fn basename_path_demo_pty() {
let abs = dunce::canonicalize(&target).unwrap_or(target.clone());
let full_path = abs.to_string_lossy().into_owned();
enqueue_tool_turn(
let _tool_turn = expect_tool_turn(
&content,
"call_basename_read",
"read_file",

View file

@ -19,7 +19,7 @@ async fn bash_queued_mid_turn_drains_as_bash() {
}
s
};
content.set_turns([step_one]);
let _turn_one = content.expect_agent_turn("running turn before queued bash send-now", step_one);
let project = tempfile::tempdir().expect("create project dir");
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");

View file

@ -14,11 +14,16 @@ use super::common::*;
async fn cancel_discards_buffered_interjection() {
let content = ContentController::start().await.expect("start content");
content.set_chunk_delay(Some(Duration::from_millis(150)));
content.set_turns([
slow_turn_text("CANCELTURN"),
let _cancelled_turn =
content.expect_agent_turn("turn cancelled by send-now", slow_turn_text("CANCELTURN"));
let _explicitly_cancelled_turn = content.expect_agent_turn(
"send-now turn cancelled explicitly",
slow_turn_text("STEERTURN"),
"FRESHTURN after cancel.".to_owned(),
]);
);
let _fresh_turn = content.expect_agent_turn(
"fresh turn after explicit cancel",
"FRESHTURN after cancel.",
);
let binary = pager_binary().expect("resolve pager binary");
let mut harness =

View file

@ -15,10 +15,10 @@ async fn cancel_then_resend_prompt_appears_once() {
// Turn 1 is rewound pre-first-token (the 30s pacing guarantees the
// pristine window); turn 2 is the resend's reply, streamed after the
// pacing is dropped below.
content.set_turns([
"GONE never streams.".to_owned(),
"RESENT_REPLY to the restored prompt.".to_owned(),
]);
let _rewound_turn =
content.expect_agent_turn("rewound turn before first token", "GONE never streams.");
let _resent_turn =
content.expect_agent_turn("resent prompt turn", "RESENT_REPLY to the restored prompt.");
content.set_chunk_delay(Some(Duration::from_secs(30)));
let binary = pager_binary().expect("resolve pager binary");

View file

@ -6,9 +6,9 @@ pub(crate) use serde_json::json;
pub(crate) use std::path::Path;
pub(crate) use std::time::{Duration, Instant};
pub(crate) use xai_grok_pager_pty_harness::{
ContentController, InferenceEndpoint, InferenceRequestMatcher, MockModel, PtyHarness,
ScriptedResponse, SseEvent, keys, oauth_env_for_pager, pager_binary, seed_fake_oauth, sse,
wait_for_labels_absent, wait_for_model_via_new_sessions,
AgentTurnExpectation, ContentController, MockModel, PtyHarness, ScriptedResponse, SseEvent,
keys, oauth_env_for_pager, pager_binary, seed_fake_oauth, sse, wait_for_labels_absent,
wait_for_model_via_new_sessions,
};
/// Default PTY size used by every e2e test. Large enough to render the
@ -22,6 +22,17 @@ pub(crate) const DEFAULT_COLS: u16 = 120;
/// which can take a few seconds on cold build directories.
pub(crate) const WELCOME_TIMEOUT: Duration = Duration::from_secs(20);
/// Wait budget for a `--continue` / resume to replay the prior transcript back
/// into scrollback. Resume is strictly heavier than a cold start: it runs
/// `session/load` (MCP startup, git chores, a full `updates.jsonl` replay, and
/// 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);
/// Substring we wait for on the welcome screen. Matches the menu label `"Quit"`
/// (`render_welcome_done` / gate menus); case-sensitive, so it does **not**
/// match the lowercase `"quit"` hint line during `AuthState::Authenticating`.
@ -582,11 +593,6 @@ pub(crate) fn responses_api_tool_call_events(
events
}
/// Chat Completions SSE stream with a single tool_call (fallback endpoint).
pub(crate) fn chat_completions_tool_call_events(name: &str, arguments: &str) -> Vec<SseEvent> {
chat_completions_tool_call_events_with_id("call_read_hdr", name, arguments)
}
/// [`chat_completions_tool_call_events`] with an explicit `tool_call` id, for
/// tests scripting several calls into ONE conversation (a reused id would
/// alias distinct calls in history and confuse dangling-call bookkeeping).
@ -643,100 +649,6 @@ pub(crate) fn chat_completions_tool_call_events_with_id(
]
}
/// Responses API SSE stream that emits a single assistant text message —
/// the FIFO counterpart of `set_response` for tests scripting DISTINCT text
/// replies per turn (e.g. one per auto-wake).
pub(crate) fn responses_api_message_events(text: &str) -> Vec<SseEvent> {
vec![
SseEvent::data(
json!({
"type": "response.created",
"sequence_number": 0,
"response": {
"id": "resp_text",
"object": "response",
"created_at": 1234567890,
"model": "test-model",
"status": "in_progress",
"output": []
}
})
.to_string(),
),
SseEvent::data(
json!({
"type": "response.output_text.delta",
"sequence_number": 1,
"item_id": "item_text",
"output_index": 0,
"content_index": 0,
"delta": text
})
.to_string(),
),
SseEvent::data(
json!({
"type": "response.completed",
"sequence_number": 2,
"response": {
"id": "resp_text",
"object": "response",
"created_at": 1234567890,
"model": "test-model",
"status": "completed",
"output": [{
"type": "message",
"id": "msg_text",
"role": "assistant",
"status": "completed",
"content": [{
"type": "output_text",
"text": text,
"annotations": []
}]
}],
"usage": {
"input_tokens": 10,
"output_tokens": 10,
"total_tokens": 20,
"input_tokens_details": { "cached_tokens": 0 },
"output_tokens_details": { "reasoning_tokens": 0 }
}
}
})
.to_string(),
),
SseEvent::data("[DONE]".to_string()),
]
}
/// Chat Completions SSE stream with a single assistant text message
/// (fallback endpoint counterpart of [`responses_api_message_events`]).
pub(crate) fn chat_completions_message_events(text: &str) -> Vec<SseEvent> {
vec![
SseEvent::data(
json!({
"id": "chatcmpl-text",
"object": "chat.completion.chunk",
"created": 1234567890,
"model": "test-model",
"choices": [{
"index": 0,
"delta": { "role": "assistant", "content": text },
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 10,
"total_tokens": 20
}
})
.to_string(),
),
SseEvent::data("[DONE]".to_string()),
]
}
/// Poll the raw PTY stream until at least one OSC 52 clipboard payload has
/// been flushed (or `timeout` elapses), then return everything decoded so
/// far. A copy lands asynchronously after the triggering input, so a fixed
@ -820,22 +732,20 @@ pub(crate) fn locate_screen_text(screen: &str, needle: &str) -> Option<(u16, u16
None
}
/// Queue one scripted tool-call turn on both inference endpoints (only the
/// endpoint the agent actually uses drains its FIFO; the other stays parked).
pub(crate) fn enqueue_tool_turn(
/// Register one named scripted tool-call turn on both inference endpoints.
pub(crate) fn expect_tool_turn(
content: &ContentController,
call_id: &str,
name: &str,
args: String,
) {
content.enqueue_response(
"/v1/responses",
) -> AgentTurnExpectation {
content.expect_agent_turn_with_responses(
format!("tool turn {call_id}"),
ScriptedResponse::sse(responses_api_tool_call_events(call_id, name, &args)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events(name, &args)),
);
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
call_id, name, &args,
)),
)
}
/// Responses API SSE stream whose `response.completed` output carries one
@ -974,7 +884,7 @@ pub(crate) fn chat_completions_parallel_tool_call_events(
}
/// Queue one scripted turn with parallel tool calls on both inference
/// endpoints (see [`enqueue_tool_turn`]).
/// endpoints (see [`expect_tool_turn`]).
pub(crate) fn enqueue_parallel_tool_turn(
content: &ContentController,
calls: &[(&str, &str, String)],
@ -991,23 +901,15 @@ pub(crate) fn enqueue_parallel_tool_turn(
/// Seed a target file under the isolated HOME and queue a scripted `read_file`
/// tool call (Responses + Chat Completions) so the pager renders a Read header.
pub(crate) fn seed_read_file_tool_call(content: &ContentController, abs_path: &Path) {
pub(crate) fn seed_read_file_tool_call(
content: &ContentController,
abs_path: &Path,
) -> AgentTurnExpectation {
let args = json!({ "target_file": abs_path.to_string_lossy() }).to_string();
// Prefer Responses API (primary agent path); also queue Chat Completions.
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_read_hdr",
"read_file",
&args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events("read_file", &args)),
);
let turn = expect_tool_turn(content, "call_read_hdr", "read_file", args);
// Follow-up turn after tool result: plain completion so the session settles.
content.set_response(READ_HDR_SENTINEL);
turn
}
// ── Minimal (scrollback-native) mode e2e helpers ────────────────────────

View file

@ -13,7 +13,10 @@ async fn ctrlc_after_activity_no_rewind_prompt_once() {
let content = ContentController::start().await.expect("start content");
content.set_chunk_delay(Some(Duration::from_millis(150)));
content.set_turns([slow_turn_text("CANCELME")]);
let _cancelled_turn = content.expect_agent_turn(
"turn cancelled after visible activity",
slow_turn_text("CANCELME"),
);
let binary = pager_binary().expect("resolve pager binary");
let mut harness =

View file

@ -15,11 +15,12 @@ async fn ctrlc_with_queued_prompt_no_dup() {
let content = ContentController::start().await.expect("start content");
// Gate turn A's terminal event so the queue + Ctrl+C provably land
// mid-turn (the cancel abort beats the held completion).
content.hold_agent_completions();
content.set_turns([
slow_turn_text("ALPHARESP"),
"BRAVORESP promoted after cancel.".to_owned(),
]);
let mut turn_a = content
.expect_agent_turn_blocked("running turn A before cancel", slow_turn_text("ALPHARESP"));
let _turn_b = content.expect_agent_turn(
"queued turn B promoted after cancel",
"BRAVORESP promoted after cancel.",
);
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
@ -35,6 +36,9 @@ async fn ctrlc_with_queued_prompt_no_dup() {
harness
.wait_for_text("ALPHARESP", Duration::from_secs(45))
.expect("A streaming");
tokio::time::timeout(Duration::from_secs(10), turn_a.wait_blocked())
.await
.expect("turn A reached completion barrier");
harness
.inject_keys(format!("{PROMPT_B}\r").as_bytes())
@ -44,7 +48,7 @@ async fn ctrlc_with_queued_prompt_no_dup() {
.expect("B visible as a queued row");
harness.inject_keys(keys::CTRL_C).expect("Ctrl+C cancel A");
content.release_agent_completions();
turn_a.release();
// Standard cancel (queued prompts skip the rewind): A is cancelled and B
// promotes as the next turn. The "Turn cancelled by user" marker and the

View file

@ -27,10 +27,14 @@ async fn drag_select_autoscroll_full_scrollout_copy_pty() {
let content = ContentController::start().await.expect("start content");
// Turn 1: a three-row anchor message (markdown hard breaks keep one row
// per source line). Turn 2: filler tall enough to scroll it fully out.
content.set_turns([
let _anchor_turn = content.expect_agent_turn(
"selection anchor turn",
format!("{ANCHOR_FIRST} anchor first line \nmiddle filler line \n{ANCHOR_LAST} anchor last line"),
);
let _filler_turn = content.expect_agent_turn(
"selection autoscroll filler turn",
marker_response(MOCK_RESPONSE_SENTINEL, FILLER_ROWS),
]);
);
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();

View file

@ -32,7 +32,7 @@ async fn edit_collapsed_oneliner_pty() {
let abs = dunce::canonicalize(&target).unwrap_or(target.clone());
// One deleted line, two inserted lines => a `+2/-1` diffstat.
enqueue_tool_turn(
let _tool_turn = expect_tool_turn(
&content,
"call_collapsed",
"search_replace",

View file

@ -107,7 +107,7 @@ async fn edit_hl_inplace_refresh_pty() {
// Small unique edit on the field line after the closing """ (the spill zone).
let old = " notes: str = Field(..., min_length=1)";
let new = " notes: str = Field(..., min_length=2) # HL upgrade target";
enqueue_tool_turn(
let _tool_turn = expect_tool_turn(
&content,
"call_edit_hl",
"search_replace",

View file

@ -37,11 +37,15 @@ async fn edit_interject_lone_queued_row_keeps_tui_alive() {
}
s
};
content.set_turns([
step_one,
"STEPTWO interjection acknowledged.".to_owned(),
"STEPTHREE liveness prompt handled.".to_owned(),
]);
let _turn_one = content.expect_agent_turn("running turn before edited interjection", step_one);
let _turn_two = content.expect_agent_turn(
"edited interjection continuation",
"STEPTWO interjection acknowledged.",
);
let _turn_three = content.expect_agent_turn(
"post-interjection liveness prompt",
"STEPTHREE liveness prompt handled.",
);
// Image fixture under the isolated HOME; the pasted absolute path becomes
// an `[Image #1]` composer chip (path-paste detection reads + decodes it).

View file

@ -68,34 +68,30 @@ async fn edit_merge_sequential_pty() {
// Three 1:1 replacements at widely separated, increasing lines so every
// merged-hunk gap is computable (edits sit ~11 lines apart, context ±3).
enqueue_tool_turn(
&content,
"call_sr_1",
"search_replace",
edit_args(&abs, "v03", "charlie", EDIT_ONE_MARK),
);
enqueue_tool_turn(
&content,
"call_sr_2",
"search_replace",
edit_args(&abs, "v14", "november", "EDIT_TWO_MARK"),
);
enqueue_tool_turn(
&content,
"call_sr_3",
"search_replace",
edit_args(&abs, "v25", "yankee", EDIT_THREE_MARK),
);
let _edit_turns: [AgentTurnExpectation; 3] = [
expect_tool_turn(
&content,
"call_sr_1",
"search_replace",
edit_args(&abs, "v03", "charlie", EDIT_ONE_MARK),
),
expect_tool_turn(
&content,
"call_sr_2",
"search_replace",
edit_args(&abs, "v14", "november", "EDIT_TWO_MARK"),
),
expect_tool_turn(
&content,
"call_sr_3",
"search_replace",
edit_args(&abs, "v25", "yankee", EDIT_THREE_MARK),
),
];
// The first prompt's turn ends on agent text — the break for the run.
let break_text = format!("{BREAK_TEXT_SENTINEL} first batch settled.");
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_message_events(&break_text)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_message_events(&break_text)),
);
let _break_turn: AgentTurnExpectation =
content.expect_agent_turn("first batch settled", &break_text);
content.set_response(DONE_SENTINEL);
let binary = pager_binary().expect("resolve pager binary");
@ -202,7 +198,7 @@ async fn edit_merge_sequential_pty() {
harness
.wait_for_turn_idle(Duration::from_secs(15))
.expect("turn idle after break-case edit");
enqueue_tool_turn(
let _edit_four = expect_tool_turn(
&content,
"call_sr_4",
"search_replace",

View file

@ -10,17 +10,11 @@ use super::common::*;
#[ignore]
async fn empty_enter_force_sends_top_queued() {
let content = ContentController::start().await.expect("start content");
let mut turn_one = content.expect_response_blocked(
"running turn before send-now",
InferenceRequestMatcher::foreground(InferenceEndpoint::ChatCompletions),
ScriptedResponse::sse(chat_completions_message_events(&slow_turn_text("TURNONE"))),
);
let mut turn_two = content.expect_response(
let mut turn_one = content
.expect_agent_turn_blocked("running turn before send-now", slow_turn_text("TURNONE"));
let mut turn_two = content.expect_agent_turn(
"promoted queued follow-up",
InferenceRequestMatcher::foreground(InferenceEndpoint::ChatCompletions),
ScriptedResponse::sse(chat_completions_message_events(
"TURNTWO reply to the promoted follow-up.",
)),
"TURNTWO reply to the promoted follow-up.",
);
let binary = pager_binary().expect("resolve pager binary");

View file

@ -10,24 +10,15 @@ use super::common::*;
#[ignore]
async fn empty_enter_sends_top_not_last_of_two() {
let content = ContentController::start().await.expect("start content");
let mut turn_one = content.expect_response_blocked(
let mut turn_one = content.expect_agent_turn_blocked(
"running turn before top-row send-now",
InferenceRequestMatcher::foreground(InferenceEndpoint::ChatCompletions),
ScriptedResponse::sse(chat_completions_message_events(&slow_turn_text("TURNONE"))),
slow_turn_text("TURNONE"),
);
let mut turn_two = content.expect_response(
"top queued row",
InferenceRequestMatcher::foreground(InferenceEndpoint::ChatCompletions),
ScriptedResponse::sse(chat_completions_message_events(
"TURNTWO top-row send-now acknowledged.",
)),
);
let mut turn_three = content.expect_response(
let mut turn_two =
content.expect_agent_turn("top queued row", "TURNTWO top-row send-now acknowledged.");
let mut turn_three = content.expect_agent_turn(
"remaining queued row",
InferenceRequestMatcher::foreground(InferenceEndpoint::ChatCompletions),
ScriptedResponse::sse(chat_completions_message_events(
"TURNTHREE remaining queue promoted.",
)),
"TURNTHREE remaining queue promoted.",
);
let binary = pager_binary().expect("resolve pager binary");

View file

@ -1,6 +1,6 @@
//! 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
//! "… 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
@ -33,29 +33,15 @@ async fn endline_park_two_static_markers() {
};
// Tool call 1: a flag-gated background command — the work the watching
// cue counts ("watching · 1 command").
// cue counts ("1 command still running").
let bg_args = json!({
"command": gated_loop(&park_flag),
"description": "flag-gated command",
"is_background": true
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_endline_bg",
"run_terminal_command",
&bg_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_endline_bg",
"run_terminal_command",
&bg_args,
)),
);
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
@ -65,21 +51,11 @@ async fn endline_park_two_static_markers() {
"description": "hold for id extraction"
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_endline_id_hold",
"run_terminal_command",
&id_hold_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_endline_id_hold",
"run_terminal_command",
&id_hold_args,
)),
let _id_hold_turn = expect_tool_turn(
&content,
"call_endline_id_hold",
"run_terminal_command",
id_hold_args,
);
// Fallback for the cancel-and-sent prompt's turn: plain text ends it.
@ -128,21 +104,11 @@ async fn endline_park_two_static_markers() {
"timeout_ms": 600_000
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_endline_wait",
"get_command_or_subagent_output",
&wait_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_endline_wait",
"get_command_or_subagent_output",
&wait_args,
)),
let _wait_turn = expect_tool_turn(
&content,
"call_endline_wait",
"get_command_or_subagent_output",
wait_args,
);
// Everything downstream is scripted — let the id-extraction hold finish.
@ -160,17 +126,22 @@ async fn endline_park_two_static_markers() {
)
});
harness
.wait_for_text("watching · 1 command", Duration::from_secs(30))
.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()
)
});
// 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!(
!harness.contains_text("still running"),
"the parked marker carries no still-running suffix; screen:\n{}",
harness.screen_contents()
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}"
);
// Type mid-park: Enter is cancel-and-send (the wait makes it a sendable
@ -200,7 +171,9 @@ async fn endline_park_two_static_markers() {
// 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) — with NO cancelled marker anywhere
// 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), || {
harness.update(Duration::from_millis(100));
@ -208,7 +181,10 @@ async fn endline_park_two_static_markers() {
// 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.contains("still running")
&& screen
.lines()
.filter(|l| l.contains("Worked for"))
.all(|l| !l.contains("still running"))
&& !screen.contains("Turn cancelled by user")
&& matches!(
(

View file

@ -2,9 +2,9 @@
//! 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.
//! (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).
@ -30,52 +30,33 @@ async fn endline_wakeups_are_markerless() {
.collect();
// The turn backgrounds one flag-gated command per tool call…
for (i, flag) in flags.iter().enumerate() {
let args = json!({
"command": format!(
"while [ ! -e {} ]; do /bin/sleep 0.2; done",
flag.display()
),
"description": format!("flag-gated command {i}"),
"is_background": true
let _background_turns: Vec<_> = flags
.iter()
.enumerate()
.map(|(i, flag)| {
let args = json!({
"command": format!(
"while [ ! -e {} ]; do /bin/sleep 0.2; done",
flag.display()
),
"description": format!("flag-gated command {i}"),
"is_background": true
})
.to_string();
expect_tool_turn(
&content,
&format!("call_endline_status_{i}"),
"run_terminal_command",
args,
)
})
.to_string();
let call_id = format!("call_endline_status_{i}");
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
&call_id,
"run_terminal_command",
&args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
&call_id,
"run_terminal_command",
&args,
)),
);
}
// …then a text response ends it with all three still running, and each
// auto-wake turn consumes one distinct scripted reply (FIFO per path; the
// stage gating below keeps the consumption order deterministic).
for text in [
"STATUS_TURN_SETTLED",
"WAKE_REPLY_ONE",
"WAKE_REPLY_TWO",
"WAKE_REPLY_THREE",
] {
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_message_events(text)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_message_events(text)),
);
}
.collect();
// …then a text response ends it with all three still running, followed by
// one response for each auto-wake.
let _settled_turn = content.expect_agent_turn("initial settled turn", "STATUS_TURN_SETTLED");
let _wake_one = content.expect_agent_turn("first completion wake", "WAKE_REPLY_ONE");
let _wake_two = content.expect_agent_turn("second completion wake", "WAKE_REPLY_TWO");
let _wake_three = content.expect_agent_turn("third completion wake", "WAKE_REPLY_THREE");
content.set_response("STATUS_FALLBACK");
let binary = pager_binary().expect("resolve pager binary");
@ -117,7 +98,7 @@ async fn endline_wakeups_are_markerless() {
)
});
harness
.wait_for_text("watching · 3 commands", Duration::from_secs(30))
.wait_for_text("3 commands still running", Duration::from_secs(30))
.unwrap_or_else(|_| {
panic!(
"the watching cue never showed the running count; screen:\n{}",
@ -134,7 +115,7 @@ async fn endline_wakeups_are_markerless() {
let screen = harness.screen_contents();
screen.contains("WAKE_REPLY_ONE")
&& screen.matches("Worked for").count() == 1
&& screen.contains("watching · 2 commands")
&& screen.contains("2 commands still running")
});
assert!(
wake_one,
@ -149,7 +130,7 @@ async fn endline_wakeups_are_markerless() {
let screen = harness.screen_contents();
screen.contains("WAKE_REPLY_TWO")
&& screen.matches("Worked for").count() == 1
&& screen.contains("watching · 1 command")
&& screen.contains("1 command still running")
});
assert!(
wake_two,
@ -158,14 +139,14 @@ async fn endline_wakeups_are_markerless() {
);
// Release flag 2: zero left — still exactly one marker, and the watching
// cue disappears entirely.
// 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.contains("watching ·")
&& !screen.contains("still running")
});
assert!(
wake_three,
@ -174,7 +155,8 @@ async fn endline_wakeups_are_markerless() {
);
// Full chain, positional: marker < chip < reply < chip < reply < chip <
// reply — one marker total, and ZERO "still running" lines anywhere.
// reply — one marker total, and no marker carries a "still running"
// suffix (that copy belongs to the status row's cue, retired above).
let screen = harness.screen_contents();
let chips: Vec<usize> = screen
.match_indices("Task completed")
@ -203,10 +185,12 @@ async fn endline_wakeups_are_markerless() {
&& chips[2] < w3,
"chain out of order; screen:\n{screen}"
);
assert_eq!(
screen.matches("still running").count(),
0,
"no still-running copy may appear in the transcript; screen:\n{screen}"
assert!(
screen
.lines()
.filter(|l| l.contains("Worked for"))
.all(|l| !l.contains("still running")),
"markers must never carry a still-running suffix; screen:\n{screen}"
);
write_cast_if_requested(&harness, "endline_wakeups_are_markerless.cast");

View file

@ -14,11 +14,14 @@ async fn interjection_reaches_model_ctrl_l_in_vscode_family() {
let content = ContentController::start().await.expect("start content");
// Gate turn 1's terminal event so the typed text + chord provably land
// mid-turn regardless of suite load.
content.hold_agent_completions();
content.set_turns([
let mut turn_one = content.expect_agent_turn_blocked(
"running turn before VS Code send-now",
slow_turn_text("TURNONE"),
"TURNTWO reply to the sent-now message.".to_owned(),
]);
);
let _turn_two = content.expect_agent_turn(
"VS Code sent-now message",
"TURNTWO reply to the sent-now message.",
);
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
@ -36,12 +39,15 @@ async fn interjection_reaches_model_ctrl_l_in_vscode_family() {
harness
.wait_for_text("TURNONE", Duration::from_secs(30))
.expect("turn 1 streaming");
tokio::time::timeout(Duration::from_secs(10), turn_one.wait_blocked())
.await
.expect("turn 1 reached completion barrier");
harness
.inject_keys(b"please also check the logs")
.expect("type message");
harness.inject_keys(CTRL_L).expect("send-now via Ctrl+L");
content.release_agent_completions();
turn_one.release();
harness
.wait_for_text(
"\u{276F} please also check the logs",

View file

@ -15,12 +15,11 @@ async fn interjection_reaches_model_in_same_turn() {
// Gate turn 1's terminal event so the typed text + chord provably land
// mid-turn regardless of suite load. Chunk delay widens the mid-stream
// window under remote CI load (same shape as cancel_discards_*).
content.hold_agent_completions();
let mut turn_one = content
.expect_agent_turn_blocked("running turn before send-now", slow_turn_text("TURNONE"));
content.set_chunk_delay(Some(Duration::from_millis(100)));
content.set_turns([
slow_turn_text("TURNONE"),
"TURNTWO reply to the sent-now message.".to_owned(),
]);
let _turn_two =
content.expect_agent_turn("sent-now message", "TURNTWO reply to the sent-now message.");
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
@ -36,6 +35,9 @@ async fn interjection_reaches_model_in_same_turn() {
harness
.wait_for_text("TURNONE", Duration::from_secs(30))
.expect("turn 1 streaming");
tokio::time::timeout(Duration::from_secs(10), turn_one.wait_blocked())
.await
.expect("turn 1 reached completion barrier");
// Still mid-stream (hold gates completion) — not "Worked for".
assert!(
!harness.contains_text("Worked for"),
@ -50,7 +52,7 @@ async fn interjection_reaches_model_in_same_turn() {
.wait_for_text("please also check the logs", Duration::from_secs(5))
.expect("draft visible in composer");
harness.inject_keys(CTRL_ENTER).expect("send-now chord");
content.release_agent_completions();
turn_one.release();
// Cancel-and-send: message leaves the composer and commits as a scrollback
// user block (not just the draft line that also carries ).

View file

@ -1,6 +1,7 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
use xai_grok_pager_pty_harness::{InferenceEndpoint, InferenceRequestMatcher};
/// Reasoning text streamed by the mock. Must never appear in the answer text
/// so screen assertions can tell the two apart.
@ -18,21 +19,17 @@ async fn minimal_commits_thinking_body_to_scrollback() {
])
.await
.expect("start content");
// The scripted turn streams reasoning deltas before the visible answer.
// Two copies so an auxiliary request can't starve the prompt turn
// (consumed FIFO; unconsumed scripts are dropped with the server).
let reasoning = format!("{REASONING_SENTINEL} pondering syllables quietly");
let answer = format!("{MOCK_RESPONSE_SENTINEL} the answer body.");
for _ in 0..2 {
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(sse::responses_api_reasoning_and_text_events(
&reasoning,
&answer,
"test-model",
)),
);
}
let _thinking_turn = content.expect_response(
"minimal transcript reasoning turn",
InferenceRequestMatcher::foreground(InferenceEndpoint::Responses),
ScriptedResponse::sse(sse::responses_api_reasoning_and_text_events(
&reasoning,
&answer,
"test-model",
)),
);
// Fallback mode for any further auxiliary traffic.
content.set_response(answer.clone());

View file

@ -38,7 +38,7 @@ async fn minimal_continue_reprints_transcript() {
project.path(),
);
resumed
.wait_for_full_text(&turn_sentinel(1), WELCOME_TIMEOUT)
.wait_for_full_text(&turn_sentinel(1), RESUME_TIMEOUT)
.unwrap_or_else(|e| {
panic!(
"history must be reprinted after --continue: {e}\nfull:\n{}",

View file

@ -10,16 +10,14 @@ use crate::common::*;
#[ignore]
async fn minimal_ctrl_o_send_now_queued_apple_terminal() {
let content = ContentController::start().await.expect("start content");
content.set_turns([
let mut turn_one = content.expect_agent_turn_blocked(
"running turn before minimal Ctrl+O send-now",
slow_turn_text("STEPONE"),
"STEPTWO send-now via Ctrl+O acknowledged.".to_owned(),
]);
// Hold turn 1 open deterministically: its content streams, but its
// completion is gated until we release it below. Chunk-delay pacing alone
// left a wall-clock race — under parallel-suite load turn 1 could finish
// before Ctrl+O landed, so the follow-up was promoted FIFO as a plain
// prompt and the send-now chrome never appeared.
content.hold_agent_completions();
);
let _turn_two = content.expect_agent_turn(
"minimal Ctrl+O sent-now prompt",
"STEPTWO send-now via Ctrl+O acknowledged.",
);
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
@ -40,6 +38,9 @@ async fn minimal_ctrl_o_send_now_queued_apple_terminal() {
harness
.wait_for_text("STEPONE", Duration::from_secs(30))
.expect("turn 1 streaming");
tokio::time::timeout(Duration::from_secs(10), turn_one.wait_blocked())
.await
.expect("turn 1 reached completion barrier");
harness
.inject_keys(b"minimal send-now payload\r")
@ -62,7 +63,7 @@ async fn minimal_ctrl_o_send_now_queued_apple_terminal() {
.expect("send-now chrome (not a silent transcript open)");
// Let the mock's gate go so the promoted turn streams its reply.
content.release_agent_completions();
turn_one.release();
harness
.wait_for_text("STEPTWO", Duration::from_secs(40))
.expect("send-now turn reply");

View file

@ -18,11 +18,14 @@ async fn minimal_double_esc_committed_queued_prompt_single_render() {
// Gate turn 1's completion so the queue provably lands mid-turn; turn 2
// (the promoted prompt's) streams nothing before the cancel thanks to
// the pacing set just before the release.
content.hold_agent_completions();
content.set_turns([
"STEPONE first reply.".to_owned(),
"STEPTWO never streams before the cancel.".to_owned(),
]);
let mut turn_one = content.expect_agent_turn_blocked(
"running turn before minimal queue promotion",
"STEPONE first reply.",
);
let _turn_two = content.expect_agent_turn(
"promoted minimal prompt cancelled before first token",
"STEPTWO never streams before the cancel.",
);
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
@ -33,6 +36,9 @@ async fn minimal_double_esc_committed_queued_prompt_single_render() {
harness
.wait_for_text("STEPONE", Duration::from_secs(30))
.expect("turn 1 streamed (completion still gated)");
tokio::time::timeout(Duration::from_secs(10), turn_one.wait_blocked())
.await
.expect("turn 1 reached completion barrier");
harness
.inject_keys(format!("{QUEUED_PROMPT}\r").as_bytes())
@ -45,7 +51,7 @@ async fn minimal_double_esc_committed_queued_prompt_single_render() {
// its turn starts — but its first token is 30s away, the exact window
// where a naive rewind would double-show the committed block.
content.set_chunk_delay(Some(Duration::from_secs(30)));
content.release_agent_completions();
turn_one.release();
harness
.wait_for_full_text(
&format!("\u{276F} {QUEUED_PROMPT}"),

View file

@ -0,0 +1,86 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Minimal `Ctrl+G` hands the draft to a local non-interactive editor script,
/// restores the native-scrollback live region, and leaves the edited text in
/// the composer until the user explicitly submits it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_external_editor_round_trip() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} edited prompt received."));
let dir = tempfile::tempdir().expect("temp editor dir");
let editor = if cfg!(windows) {
let script = dir.path().join("local-editor.cmd");
std::fs::write(
&script,
"@echo off\r\n>\"%~1\" echo|set /p=edited draft from external editor\r\n",
)
.expect("write Windows editor script");
format!("cmd /C '{}'", script.display())
} else {
let script = dir.path().join("local-editor.sh");
std::fs::write(
&script,
"#!/bin/sh\nprintf 'edited draft from external editor' > \"$1\"\n",
)
.expect("write Unix editor script");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700))
.expect("make editor executable");
}
format!("'{}'", script.display())
};
let mut env = content.env_for_pager();
env.push(("VISUAL".to_owned(), editor));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, MINIMAL_ARGS, &env_refs)
.expect("spawn minimal pager");
harness.set_respond_to_queries(true);
wait_minimal_ready(&mut harness);
inject_keys_paced(&mut harness, b"original draft");
harness.inject_keys(b"\x07").expect("Ctrl+G");
harness
.wait_for_text("edited draft from external editor", Duration::from_secs(10))
.expect("edited draft restored to composer");
assert!(
!harness.full_text().contains(MOCK_RESPONSE_SENTINEL),
"editor exit must not submit the draft"
);
harness.inject_keys(b"\r").expect("submit edited draft");
harness
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("edited draft submitted");
let user_messages = all_user_message_blobs(&content);
assert!(
user_messages
.iter()
.any(|message| message.contains("edited draft from external editor")),
"exact edited draft must reach the wire: {user_messages:#?}"
);
assert!(
user_messages
.iter()
.all(|message| !message.contains("original draft")),
"original draft must not reach the wire: {user_messages:#?}"
);
harness
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
.expect("minimal live region restored idle");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -87,21 +87,7 @@ async fn minimal_flush_left_no_hpad() {
"description": "flush-left permission check",
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_flush",
"run_terminal_command",
&args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events(
"run_terminal_command",
&args,
)),
);
let _permission_turn = expect_tool_turn(&content, "call_flush", "run_terminal_command", args);
harness
.inject_keys(b"run the flush check\r")
.expect("submit tool prompt");

View file

@ -17,7 +17,7 @@ async fn minimal_lookup_commits_one_line_summary() {
let fixture = content.home().join("haystack.txt");
std::fs::write(&fixture, format!("{BODY_SENTINEL} body line\n")).expect("write fixture");
enqueue_tool_turn(
let _read_turn = expect_tool_turn(
&content,
"call_read",
"read_file",

View file

@ -16,10 +16,14 @@ async fn minimal_queue_indicator_shows_while_running() {
let content = ContentController::start().await.expect("start content");
// Pace turn 1 so it's still streaming when we queue behind it.
content.set_chunk_delay(Some(Duration::from_millis(150)));
content.set_turns([
let _turn_one = content.expect_agent_turn(
"running turn before minimal queue promotion",
slow_turn_text("STEPONE"),
"STEPTWO queued prompt handled.".to_owned(),
]);
);
let _turn_two = content.expect_agent_turn(
"promoted minimal queued prompt",
"STEPTWO queued prompt handled.",
);
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);

View file

@ -15,6 +15,7 @@ mod minimal_continue_reprints_transcript;
mod minimal_ctrl_c_arms_and_quits;
mod minimal_double_esc_committed_queued_prompt_single_render;
mod minimal_esc_mid_turn_is_swallowed;
mod minimal_external_editor_round_trip;
mod minimal_flush_left_no_hpad;
mod minimal_help_opens_command_palette;
mod minimal_lookup_commits_one_line_summary;

View file

@ -19,8 +19,13 @@ fn tall_first_response() -> String {
}
/// 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());
async fn drive_to_second_send(content: &ContentController) -> (PtyHarness, AgentTurnExpectation) {
let mut first_turn =
content.expect_agent_turn("page-flip tall first turn", tall_first_response());
let mut second_turn = content.expect_agent_turn_blocked(
"page-flip held second turn",
format!("{MOCK_RESPONSE_SENTINEL} second turn."),
);
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
@ -35,24 +40,28 @@ async fn drive_to_second_send(content: &ContentController) -> PtyHarness {
harness
.wait_for_text(TAIL_SENTINEL, Duration::from_secs(30))
.expect("turn 1 tail visible");
tokio::time::timeout(Duration::from_secs(10), first_turn.wait_satisfied())
.await
.expect("first turn completes before second send");
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");
tokio::time::timeout(Duration::from_secs(10), second_turn.wait_blocked())
.await
.expect("second turn reaches completion barrier");
harness.update(Duration::from_millis(600));
harness
(harness, second_turn)
}
#[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;
let (mut harness, second_turn) = drive_to_second_send(&content).await;
assert!(
!harness.contains_text(TAIL_SENTINEL),
@ -69,7 +78,7 @@ async fn send_page_flips_by_default() {
"flipped prompt should be in the top half (row {prompt_row})\nscreen:\n{screen}"
);
content.release_agent_completions();
second_turn.release();
harness.quit().expect("clean quit");
}
@ -78,7 +87,7 @@ async fn send_page_flips_by_default() {
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;
let (mut harness, second_turn) = drive_to_second_send(&content).await;
assert!(
harness.contains_text(TAIL_SENTINEL),
@ -86,6 +95,6 @@ async fn send_keeps_viewport_when_page_flip_disabled() {
harness.screen_contents()
);
content.release_agent_completions();
second_turn.release();
harness.quit().expect("clean quit");
}

View file

@ -15,12 +15,18 @@ async fn queue_and_interjection_lifecycle() {
// Gate turn 1's terminal event so the ENTIRE mid-turn setup — queue P1
// and P2, remove P1, refocus the prompt, type + chord I1 — provably lands
// while turn 1 is still the running turn, even under heavy suite load.
content.hold_agent_completions();
content.set_turns([
let mut turn_one = content.expect_agent_turn_blocked(
"running turn before queue lifecycle send-now",
slow_turn_text("STEPONE"),
"STEPTWO sent-now message acknowledged.".to_owned(),
"STEPTHREE promoted prompt handled.".to_owned(),
]);
);
let _turn_two = content.expect_agent_turn(
"lifecycle sent-now message",
"STEPTWO sent-now message acknowledged.",
);
let _turn_three = content.expect_agent_turn(
"remaining lifecycle queued prompt",
"STEPTHREE promoted prompt handled.",
);
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
@ -36,6 +42,9 @@ async fn queue_and_interjection_lifecycle() {
harness
.wait_for_text("STEPONE", Duration::from_secs(45))
.expect("step 1: turn streaming");
tokio::time::timeout(Duration::from_secs(10), turn_one.wait_blocked())
.await
.expect("turn 1 reached completion barrier");
harness.inject_keys(b"lifecycle p-one\r").expect("queue P1");
harness
@ -70,7 +79,7 @@ async fn queue_and_interjection_lifecycle() {
.inject_keys(b"lifecycle i-one")
.expect("type send-now message");
harness.inject_keys(CTRL_ENTER).expect("send-now chord");
content.release_agent_completions();
turn_one.release();
// Cancel-and-send: turn 1 is cancelled silently; I1 commits as a
// standard " " prompt block and runs as its own turn.
// I1 (send-now) then P2 drain back-to-back. The " lifecycle i-one"

View file

@ -17,7 +17,8 @@ async fn queued_bash_promotion_renders_output_pty() {
}
s
};
content.set_turns([step_one]);
let _turn_one =
content.expect_agent_turn("running turn before queued bash promotion", step_one);
let project = tempfile::tempdir().expect("create project dir");
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");

View file

@ -33,22 +33,8 @@ async fn queued_message_renders_once_not_twice() {
"is_background": true
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_qonce_bg",
"run_terminal_command",
&bg_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_qonce_bg",
"run_terminal_command",
&bg_args,
)),
);
let _background_turn =
expect_tool_turn(&content, "call_qonce_bg", "run_terminal_command", bg_args);
// Tool call 2: the flag-gated foreground hold — the mid-turn window
// where the follow-up is queued.
@ -57,21 +43,11 @@ async fn queued_message_renders_once_not_twice() {
"description": "hold for id extraction"
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_qonce_id_hold",
"run_terminal_command",
&id_hold_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_qonce_id_hold",
"run_terminal_command",
&id_hold_args,
)),
let _id_hold_turn = expect_tool_turn(
&content,
"call_qonce_id_hold",
"run_terminal_command",
id_hold_args,
);
// Fallback for both post-wait turns (the parked turn's wrap-up and the
@ -126,21 +102,11 @@ async fn queued_message_renders_once_not_twice() {
"timeout_ms": 600_000
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_qonce_wait",
"get_command_or_subagent_output",
&wait_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_qonce_wait",
"get_command_or_subagent_output",
&wait_args,
)),
let _wait_turn = expect_tool_turn(
&content,
"call_qonce_wait",
"get_command_or_subagent_output",
wait_args,
);
std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold");

View file

@ -23,7 +23,7 @@ async fn read_tool_header_selection_copies_path_only_pty() {
// Filename alone is what the fish-shortened header may show; always present.
let path_tail = READ_HDR_FILE;
seed_read_file_tool_call(&content, &abs_path);
let _read_turn = seed_read_file_tool_call(&content, &abs_path);
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();

View file

@ -35,11 +35,11 @@ async fn reasoning_efforts_fallback_menu_matches_builtin() {
// descriptions) renders — not just a couple, so this genuinely pins
// "fallback == today".
harness
.wait_for_text("Maximum reasoning", Duration::from_secs(10))
.wait_for_text("Extended reasoning", Duration::from_secs(10))
.expect("built-in xhigh row");
let screen = harness.screen_contents();
for description in [
"Maximum reasoning", // xhigh
"Extended reasoning", // xhigh
"Heavy reasoning", // high
"Balanced reasoning", // medium
"Faster, lighter reasoning", // low

View file

@ -45,7 +45,7 @@ async fn reasoning_efforts_from_config_toml_menu() {
.wait_for_text("ConfigHigh", Duration::from_secs(10))
.expect("config-driven label in /effort dropdown");
assert!(
!harness.contains_text("Maximum reasoning"),
!harness.contains_text("Extended reasoning"),
"config list must replace the built-in rows\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -10,16 +10,14 @@ use super::common::*;
#[ignore]
async fn removed_queued_prompt_never_sent() {
let content = ContentController::start().await.expect("start content");
content.set_turns([
let mut turn_one = content.expect_agent_turn_blocked(
"running turn while queued prompt is removed",
slow_turn_text("TURNONE"),
"TURNTWO promoted prompt response.".to_owned(),
]);
// Hold turn 1 open deterministically: its content streams, but its
// completion is gated until we release it below. This removes the
// turn-end race — the removed row can never be promoted out from under
// the removal, which under load previously let the "removed" prompt run
// as its own turn (consuming TURNTWO) and left the survivor stranded.
content.hold_agent_completions();
);
let _turn_two = content.expect_agent_turn(
"surviving queued prompt",
"TURNTWO promoted prompt response.",
);
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
@ -35,6 +33,9 @@ async fn removed_queued_prompt_never_sent() {
harness
.wait_for_text("TURNONE", Duration::from_secs(30))
.expect("turn 1 streaming");
tokio::time::timeout(Duration::from_secs(10), turn_one.wait_blocked())
.await
.expect("turn 1 reached completion barrier");
harness
.inject_keys(b"queued alpha\r")
@ -76,7 +77,7 @@ async fn removed_queued_prompt_never_sent() {
// Now let turn 1 finish: the sole survivor `queued bravo` promotes FIFO
// into turn 2.
content.release_agent_completions();
turn_one.release();
// Assert promotion on the WIRE, not on scrollback text. The auto-shown
// queue pane overlays the top of the scrollback, so the promoted turn's

View file

@ -1,6 +1,6 @@
//! 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 · …"
//! 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`:
@ -46,22 +46,8 @@ async fn reparked_wait_repushes_buried_marker() {
"is_background": true
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_repark_bg",
"run_terminal_command",
&bg_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_repark_bg",
"run_terminal_command",
&bg_args,
)),
);
let _background_turn =
expect_tool_turn(&content, "call_repark_bg", "run_terminal_command", bg_args);
// Tool call 2: the flag-gated foreground hold for id extraction.
let id_hold_args = json!({
@ -69,21 +55,11 @@ async fn reparked_wait_repushes_buried_marker() {
"description": "hold for id extraction"
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_repark_id_hold",
"run_terminal_command",
&id_hold_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_repark_id_hold",
"run_terminal_command",
&id_hold_args,
)),
let _id_hold_turn = expect_tool_turn(
&content,
"call_repark_id_hold",
"run_terminal_command",
id_hold_args,
);
// Fallback for the post-wait continuation once park #2's wait returns.
@ -130,21 +106,11 @@ async fn reparked_wait_repushes_buried_marker() {
"timeout_ms": 4_000
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_repark_wait1",
"get_command_or_subagent_output",
&short_wait_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_repark_wait1",
"get_command_or_subagent_output",
&short_wait_args,
)),
let _short_wait_turn = expect_tool_turn(
&content,
"call_repark_wait1",
"get_command_or_subagent_output",
short_wait_args,
);
// Tool call 4: foreground work between the parks (`MIDWORK` is the
@ -154,21 +120,11 @@ async fn reparked_wait_repushes_buried_marker() {
"description": MIDWORK
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_repark_midwork",
"run_terminal_command",
&midwork_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_repark_midwork",
"run_terminal_command",
&midwork_args,
)),
let _midwork_turn = expect_tool_turn(
&content,
"call_repark_midwork",
"run_terminal_command",
midwork_args,
);
// Tool call 5 — park #2: the long wait on the same still-running task.
@ -177,21 +133,11 @@ async fn reparked_wait_repushes_buried_marker() {
"timeout_ms": 600_000
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_repark_wait2",
"get_command_or_subagent_output",
&long_wait_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_repark_wait2",
"get_command_or_subagent_output",
&long_wait_args,
)),
let _long_wait_turn = expect_tool_turn(
&content,
"call_repark_wait2",
"get_command_or_subagent_output",
long_wait_args,
);
// Everything downstream is scripted — release the id-extraction hold.
@ -209,7 +155,7 @@ async fn reparked_wait_repushes_buried_marker() {
});
// The parked status row carries the still-running story instead.
harness
.wait_for_text("watching · 1 command", Duration::from_secs(30))
.wait_for_text("1 command still running", Duration::from_secs(30))
.unwrap_or_else(|_| {
panic!(
"parked watching cue never appeared; screen:\n{}",
@ -262,14 +208,19 @@ 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 still-running story lives in the status row, not the transcript.
// 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.contains("still running"),
"no still-running suffix anywhere in the transcript; screen:\n{screen}"
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("watching · 1 command"),
"the parked status row keeps the watching cue during park #2; screen:\n{screen}"
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..];

View file

@ -16,8 +16,8 @@ use xai_grok_pager_pty_harness::PtyHarness;
pub(crate) use xai_grok_pager_pty_harness::{SGR_SCROLL_DOWN, SGR_SCROLL_UP};
use super::common::{
ContentController, DEFAULT_COLS, DEFAULT_ROWS, MOCK_RESPONSE_SENTINEL, PROMPT,
WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT, locate_screen_text, pager_binary, sgr_mouse,
AgentTurnExpectation, ContentController, DEFAULT_COLS, DEFAULT_ROWS, MOCK_RESPONSE_SENTINEL,
PROMPT, WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT, locate_screen_text, pager_binary, sgr_mouse,
};
/// Wheel-report position, 0-based (row,col): inside the scrollback pane at
@ -244,20 +244,11 @@ fn streaming_marker_turn_text(marker_count: usize, tail_words: usize) -> String
/// Spawn the pager onto a turn that is STILL STREAMING and provably cannot
/// complete (the shared preamble of the mid-stream wheel tests): the marker
/// block arrives on the first delta, the tail keeps deltas in flight at
/// `chunk_delay` per SSE event, and the mock's completion gate
/// (`hold_agent_completions`) holds the turn's terminal event — every
/// assertion against the returned harness is mid-turn by construction until
/// the caller releases the gate. `extra_env` is appended to the controller's
/// pager env (e.g. the forced-wheel pricing vars). Ends after the setup
/// guards; the caller owns all wheel activity, the gate release, and the
/// completion wait.
///
/// Returns the harness, the content controller (it owns the mock server AND
/// the held gate — callers must eventually call `release_agent_completions()`
/// for the turn to finish), and the topmost visible marker index as the
/// movement baseline, taken bottom-pinned in follow mode with the first
/// marker off-screen-top and [`STREAM_END_SENTINEL`] not yet on screen
/// (guards panic with the screen contents otherwise).
/// `chunk_delay` per SSE event, and a matched expectation prevents terminal
/// completion until released. `extra_env` is appended to the controller's
/// pager env. Returns the harness, content controller, blocked turn, and the
/// bottom-pinned topmost visible marker index; setup asserts that marker zero
/// is above the viewport and [`STREAM_END_SENTINEL`] has not arrived.
///
/// Destructure the controller into a live binding (`content` / `_content`),
/// never `_` — a `_` binding drops it immediately, killing the mock server
@ -271,13 +262,13 @@ pub(crate) async fn spawn_streaming_marker_turn(
tail_words: usize,
chunk_delay: Duration,
extra_env: &[(&str, &str)],
) -> (PtyHarness, ContentController, usize) {
) -> (PtyHarness, ContentController, AgentTurnExpectation, usize) {
let content = ContentController::start().await.expect("start content");
content.set_chunk_delay(Some(chunk_delay));
content.hold_agent_completions();
// set_turns (not set_response): only agent turns ride the completion
// gate; aux title/classifier requests fall through to echo untouched.
content.set_turns([streaming_marker_turn_text(marker_count, tail_words)]);
let turn = content.expect_agent_turn_blocked(
"streaming marker turn",
streaming_marker_turn_text(marker_count, tail_words),
);
let binary = pager_binary().expect("resolve pager binary");
// spawn_with_content minus the fixed env: content env + the caller's.
@ -326,5 +317,5 @@ pub(crate) async fn spawn_streaming_marker_turn(
)
});
(harness, content, top_before)
(harness, content, turn, top_before)
}

View file

@ -10,7 +10,10 @@ use super::common::*;
async fn send_now_tip_after_mid_turn_queue() {
let content = ContentController::start().await.expect("start content");
content.set_chunk_delay(Some(Duration::from_millis(150)));
content.set_turns([slow_turn_text("TURNONE"), "TURNTWO done.".to_owned()]);
let _turn_one = content.expect_agent_turn(
"running turn while send-now tip appears",
slow_turn_text("TURNONE"),
);
let binary = pager_binary().expect("resolve pager binary");
let env = contextual_hints_env(&content);

View file

@ -279,16 +279,13 @@ async fn show_thinking_blocks_toggle_hides_existing_pty() {
format!("{THINKING_SENTINEL} reason carefully about the user prompt and list every step");
let response_body = format!("{MOCK_RESPONSE_SENTINEL} after thinking.");
content.enqueue_response(
"/v1/responses",
let _thinking_turn = content.expect_agent_turn_with_responses(
"thinking turn before visibility toggle",
ScriptedResponse::sse(responses_api_with_reasoning_stream(
&reasoning,
&response_body,
model,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completion_with_reasoning_stream(
&reasoning,
&response_body,

View file

@ -36,22 +36,8 @@ async fn spinner_reappears_after_wait_resumes() {
"is_background": true
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_spinner_bg",
"run_terminal_command",
&bg_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_spinner_bg",
"run_terminal_command",
&bg_args,
)),
);
let _background_turn =
expect_tool_turn(&content, "call_spinner_bg", "run_terminal_command", bg_args);
// Tool call 2: the flag-gated foreground hold for id extraction.
let id_hold_args = json!({
@ -59,21 +45,11 @@ async fn spinner_reappears_after_wait_resumes() {
"description": "hold for id extraction"
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_spinner_id_hold",
"run_terminal_command",
&id_hold_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_spinner_id_hold",
"run_terminal_command",
&id_hold_args,
)),
let _id_hold_turn = expect_tool_turn(
&content,
"call_spinner_id_hold",
"run_terminal_command",
id_hold_args,
);
// Fallback for the post-wait continuation: a slow stream (~5s at the
@ -120,26 +96,16 @@ async fn spinner_reappears_after_wait_resumes() {
"timeout_ms": 600_000
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_spinner_wait",
"get_command_or_subagent_output",
&wait_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_spinner_wait",
"get_command_or_subagent_output",
&wait_args,
)),
let _wait_turn = expect_tool_turn(
&content,
"call_spinner_wait",
"get_command_or_subagent_output",
wait_args,
);
std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold");
// Parked look: the plain marker renders, the "watching · …" cue takes
// Parked look: the plain marker renders, the "… still running" cue takes
// the status row, and the running chrome (cancel keybar) drops — the
// session reads as stopped.
harness
@ -152,7 +118,7 @@ async fn spinner_reappears_after_wait_resumes() {
)
});
harness
.wait_for_text("watching · 1 command", Duration::from_secs(30))
.wait_for_text("1 command still running", Duration::from_secs(30))
.unwrap_or_else(|_| {
panic!(
"parked watching cue never appeared; screen:\n{}",

View file

@ -101,7 +101,7 @@ async fn stuck_drag_recovers_on_esc_pty() {
// The two post-Esc latched-extend paths: `<32` = left-drag motion (button 0 + motion bit),
// `<35` = bare move with no button held, reported under any-event tracking (DECSET 1003).
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Any-event-tracking
let far_motion = sgr_mouse(32, row, far_col, 'M') + &sgr_mouse(35, row, far_col, 'M');
let far_motion = sgr_mouse(32, row, far_col, 'M') + sgr_mouse(35, row, far_col, 'M').as_str();
harness
.inject_keys(far_motion.as_bytes())
.expect("post-esc motion");

View file

@ -45,34 +45,36 @@ async fn verb_group_fold_expand_collapse_pty() {
let home_str = content.home().to_string_lossy().into_owned();
// read x3 → grep x2 → edit → read x2, then a plain completion to settle.
enqueue_tool_turn(&content, "call_r1", "read_file", read_args(&paths[0]));
enqueue_tool_turn(&content, "call_r2", "read_file", read_args(&paths[1]));
enqueue_tool_turn(&content, "call_r3", "read_file", read_args(&paths[2]));
enqueue_tool_turn(
&content,
"call_g1",
"grep",
json!({ "pattern": "hello", "path": home_str }).to_string(),
);
enqueue_tool_turn(
&content,
"call_g2",
"grep",
json!({ "pattern": "verb", "path": home_str }).to_string(),
);
enqueue_tool_turn(
&content,
"call_e1",
"search_replace",
json!({
"file_path": paths[3].to_string_lossy(),
"old_string": "hello verb group",
"new_string": "hola verb group",
})
.to_string(),
);
enqueue_tool_turn(&content, "call_r4", "read_file", read_args(&paths[4]));
enqueue_tool_turn(&content, "call_r5", "read_file", read_args(&paths[5]));
let _tool_turns = [
expect_tool_turn(&content, "call_r1", "read_file", read_args(&paths[0])),
expect_tool_turn(&content, "call_r2", "read_file", read_args(&paths[1])),
expect_tool_turn(&content, "call_r3", "read_file", read_args(&paths[2])),
expect_tool_turn(
&content,
"call_g1",
"grep",
json!({ "pattern": "hello", "path": home_str }).to_string(),
),
expect_tool_turn(
&content,
"call_g2",
"grep",
json!({ "pattern": "verb", "path": home_str }).to_string(),
),
expect_tool_turn(
&content,
"call_e1",
"search_replace",
json!({
"file_path": paths[3].to_string_lossy(),
"old_string": "hello verb group",
"new_string": "hola verb group",
})
.to_string(),
),
expect_tool_turn(&content, "call_r4", "read_file", read_args(&paths[4])),
expect_tool_turn(&content, "call_r5", "read_file", read_args(&paths[5])),
];
content.set_response(DONE_SENTINEL);
let binary = pager_binary().expect("resolve pager binary");

View file

@ -58,14 +58,18 @@ async fn verb_group_header_drag_copy_pty() {
std::fs::write(&path, "hello drag copy\n").expect("write fixture file");
paths.push(dunce::canonicalize(&path).unwrap_or(path));
}
for (i, p) in paths.iter().enumerate() {
enqueue_tool_turn(
&content,
&format!("call_d{i}"),
"read_file",
json!({ "target_file": p.to_string_lossy() }).to_string(),
);
}
let _tool_turns: Vec<_> = paths
.iter()
.enumerate()
.map(|(i, p)| {
expect_tool_turn(
&content,
&format!("call_d{i}"),
"read_file",
json!({ "target_file": p.to_string_lossy() }).to_string(),
)
})
.collect();
content.set_response(DONE_SENTINEL);
let binary = pager_binary().expect("resolve pager binary");

View file

@ -91,14 +91,18 @@ async fn verb_group_settings_toggle_pty() {
std::fs::write(&path, "hello verb group\n").expect("write fixture file");
paths.push(dunce::canonicalize(&path).unwrap_or(path));
}
for (i, p) in paths.iter().enumerate() {
enqueue_tool_turn(
&content,
&format!("call_t{i}"),
"read_file",
json!({ "target_file": p.to_string_lossy() }).to_string(),
);
}
let _tool_turns: Vec<_> = paths
.iter()
.enumerate()
.map(|(i, p)| {
expect_tool_turn(
&content,
&format!("call_t{i}"),
"read_file",
json!({ "target_file": p.to_string_lossy() }).to_string(),
)
})
.collect();
content.set_response(DONE_SENTINEL);
let binary = pager_binary().expect("resolve pager binary");

View file

@ -25,14 +25,18 @@ async fn verb_group_streaming_fold_pty() {
std::fs::write(&path, "hello verb group\n").expect("write fixture file");
paths.push(dunce::canonicalize(&path).unwrap_or(path));
}
for (i, p) in paths.iter().enumerate() {
enqueue_tool_turn(
&content,
&format!("call_s{i}"),
"read_file",
json!({ "target_file": p.to_string_lossy() }).to_string(),
);
}
let _tool_turns: Vec<_> = paths
.iter()
.enumerate()
.map(|(i, p)| {
expect_tool_turn(
&content,
&format!("call_s{i}"),
"read_file",
json!({ "target_file": p.to_string_lossy() }).to_string(),
)
})
.collect();
content.set_response(DONE_SENTINEL);
// Hold each scripted turn open (4 SSE events x 350ms ≈ 1.4s) so the
// mid-flight window is pollable; cleared after capture so the tail

View file

@ -41,8 +41,8 @@ async fn verb_group_thinking_fold_pty() {
// Turn 1 thinks and calls the first read: the tool call finishes the
// thought, which auto-collapses and folds into the forming run.
let args0 = json!({ "target_file": paths[0].to_string_lossy() }).to_string();
content.enqueue_response(
"/v1/responses",
let _thinking_turn = content.expect_agent_turn_with_responses(
"thinking then first read",
ScriptedResponse::sse(sse::responses_api_reasoning_then_tool_call_events(
&reasoning,
"call_t0",
@ -50,9 +50,6 @@ async fn verb_group_thinking_fold_pty() {
&args0,
"test-model",
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(sse::chat_completions_reasoning_then_tool_call_events(
&reasoning,
"call_t0",
@ -63,18 +60,7 @@ async fn verb_group_thinking_fold_pty() {
);
// Turn 2: a plain second read grows the already-folded run to two tools.
let args1 = json!({ "target_file": paths[1].to_string_lossy() }).to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_t1",
"read_file",
&args1,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events("read_file", &args1)),
);
let _second_read_turn = expect_tool_turn(&content, "call_t1", "read_file", args1);
content.set_response(DONE_SENTINEL);
// Pace the scripted SSE so the streaming-thinking window is pollable;
// cleared after capture so the tail settles fast.

View file

@ -19,11 +19,11 @@ async fn verify_bashq_claim2_force_interject() {
}
s
};
content.set_turns([
step_one,
// Consumed only on a broken binary (a wrong model continuation).
"STEPTWO force-send continuation.".to_owned(),
]);
let _turn_one = content.expect_agent_turn("running turn before queued bash send-now", step_one);
let _unexpected_turn = content.expect_agent_turn(
"unexpected model continuation for bash send-now",
"STEPTWO force-send continuation.",
);
let project = tempfile::tempdir().expect("create project dir");
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");

View file

@ -17,13 +17,12 @@ async fn verify_bashq_claim3_edit_keeps_bash() {
}
s
};
content.set_turns([
step_one,
// Consumed only on an unfixed binary (the demoted-to-prompt drain).
"STEPTHREE edited continuation.".to_owned(),
]);
// Hold turn 1 open so the edit lands while the row is still queued.
content.hold_agent_completions();
let mut turn_one =
content.expect_agent_turn_blocked("running turn while queued bash row is edited", step_one);
let _unexpected_turn = content.expect_agent_turn(
"unexpected model continuation for edited bash row",
"STEPTHREE edited continuation.",
);
let project = tempfile::tempdir().expect("create project dir");
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
@ -49,6 +48,15 @@ async fn verify_bashq_claim3_edit_keeps_bash() {
harness
.wait_for_text("STEPONE", Duration::from_secs(30))
.expect("turn 1 streaming");
tokio::time::timeout(Duration::from_secs(10), turn_one.wait_received())
.await
.unwrap_or_else(|_| {
panic!(
"turn 1 expectation was not claimed: {}\nrequests:\n{}",
turn_one.diagnostic(),
content.server().request_log_summary(),
)
});
harness
.inject_keys(b"!printf 'CLAIMTHREE_%s_OK\\n' ORIG\r")
@ -89,7 +97,16 @@ async fn verify_bashq_claim3_edit_keeps_bash() {
.expect("queued row shows the edited text after the rebroadcast");
harness.update(Duration::from_millis(500));
content.release_agent_completions();
tokio::time::timeout(Duration::from_secs(30), turn_one.wait_blocked())
.await
.unwrap_or_else(|_| {
panic!(
"turn 1 did not reach its terminal barrier after the queued edit: {}\nrequests:\n{}",
turn_one.diagnostic(),
content.server().request_log_summary(),
)
});
turn_one.release();
let deadline = std::time::Instant::now() + Duration::from_secs(90);
while !harness.contains_text("CLAIMTHREE_EDITED_OK") && !harness.contains_text("STEPTHREE") {
assert!(

View file

@ -62,7 +62,7 @@ const CHUNK_DELAY: Duration = Duration::from_millis(30);
async fn wheel_overscroll_at_bottom_reengages_follow_mid_stream() {
// Gated, paced, provably mid-turn transcript with setup guards taken,
// spawned under the forced-wheel pricing env (see the header).
let (mut harness, content, top_start) = spawn_streaming_marker_turn(
let (mut harness, _content, mut turn, top_start) = spawn_streaming_marker_turn(
MARKER_COUNT,
TAIL_WORDS,
CHUNK_DELAY,
@ -146,16 +146,19 @@ async fn wheel_overscroll_at_bottom_reengages_follow_mid_stream() {
});
// Release the gate and let the turn complete: the dance didn't wedge it.
content.release_agent_completions();
turn.release();
let deadline = std::time::Instant::now() + Duration::from_secs(40);
while harness.contains_text("Responding") {
assert!(
std::time::Instant::now() < deadline,
"turn never completed after releasing the gate\nscreen:\n{}",
"turn never completed after releasing its expectation\nscreen:\n{}",
harness.screen_contents()
);
harness.update(Duration::from_millis(200));
}
tokio::time::timeout(Duration::from_secs(10), turn.wait_satisfied())
.await
.expect("streaming marker expectation satisfied");
harness.quit().expect("clean quit");
}

View file

@ -59,7 +59,7 @@ const CHUNK_DELAY: Duration = Duration::from_millis(30);
async fn wheel_scrolls_viewport_during_streaming_turn() {
// Gated, paced, provably mid-turn transcript with setup guards taken —
// see the helper for the construction and the baseline's meaning.
let (mut harness, content, top_before) =
let (mut harness, _content, mut turn, top_before) =
spawn_streaming_marker_turn(MARKER_COUNT, TAIL_WORDS, CHUNK_DELAY, &[]).await;
send_wheel_burst(
@ -119,16 +119,19 @@ async fn wheel_scrolls_viewport_during_streaming_turn() {
// Release the gate and let the tail finish: the turn must complete
// (status label clears) — scrolling mid-stream didn't wedge it.
content.release_agent_completions();
turn.release();
let deadline = std::time::Instant::now() + Duration::from_secs(40);
while harness.contains_text("Responding") {
assert!(
std::time::Instant::now() < deadline,
"turn never completed after releasing the gate\nscreen:\n{}",
"turn never completed after releasing its expectation\nscreen:\n{}",
harness.screen_contents()
);
harness.update(Duration::from_millis(200));
}
tokio::time::timeout(Duration::from_secs(10), turn.wait_satisfied())
.await
.expect("streaming marker expectation satisfied");
harness.quit().expect("clean quit");
}

View file

@ -34,7 +34,7 @@ mod paste_ctrl_v_text_echoes_fast_windows;
use common::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; exercises real copy output and /terminal-setup"]
#[ignore = "PTY e2e; exercises real copy output and /doctor"]
async fn unknown_ssh_clipboard_delivery_is_unverified() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!(
@ -92,9 +92,7 @@ async fn unknown_ssh_clipboard_delivery_is_unverified() {
assert!(!harness.contains_text("Copy failed"));
assert!(!harness.contains_text("Copied!"));
harness
.inject_keys(b"/terminal-setup\r")
.expect("run /terminal-setup");
harness.inject_keys(b"/doctor\r").expect("run /doctor");
harness
.wait_for_text("status unverified", Duration::from_secs(10))
.expect("unverified clipboard status");

View file

@ -64,7 +64,7 @@ fn wait_for_raw_bytes(harness: &mut PtyHarness, needle: &[u8], timeout: Duration
}
/// Unknown brand → probe fires; the harness's scripted reply is surfaced
/// in `/terminal-setup`, never as screen garbage.
/// in `/doctor`, never as screen garbage.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn unknown_brand_probe_round_trip() {
@ -87,13 +87,11 @@ async fn unknown_brand_probe_round_trip() {
.expect("welcome text");
assert_no_probe_garbage_on_screen(&harness);
// Surface check: /terminal-setup shows the probed identity.
harness
.inject_keys(b"/terminal-setup\r")
.expect("run /terminal-setup");
// Surface check: /doctor shows the probed identity.
harness.inject_keys(b"/doctor\r").expect("run /doctor");
harness
.wait_for_text("PtyHarnessTerm 9.9", Duration::from_secs(10))
.expect("XTVERSION identity shown in /terminal-setup");
.expect("XTVERSION identity shown in /doctor");
assert!(!harness.contains_text("panicked"));
harness.quit().expect("clean quit");
@ -123,9 +121,7 @@ async fn allowlisted_brand_probe_fires() {
.expect("welcome text");
assert_no_probe_garbage_on_screen(&harness);
harness
.inject_keys(b"/terminal-setup\r")
.expect("run /terminal-setup");
harness.inject_keys(b"/doctor\r").expect("run /doctor");
harness
.wait_for_text("PtyHarnessTerm 9.9", Duration::from_secs(10))
.expect("XTVERSION identity shown for an allowlisted brand");
@ -195,13 +191,11 @@ async fn unknown_brand_no_reply_starts_cleanly() {
assert!(!harness.contains_text("panicked"));
assert_no_probe_garbage_on_screen(&harness);
// /terminal-setup must omit the xtversion line entirely.
harness
.inject_keys(b"/terminal-setup\r")
.expect("run /terminal-setup");
// /doctor must omit the xtversion line entirely.
harness.inject_keys(b"/doctor\r").expect("run /doctor");
harness
.wait_for_text("Environment", Duration::from_secs(10))
.expect("terminal-setup output");
.expect("doctor output");
assert!(
!harness.contains_text("xtversion"),
"xtversion line should be absent when the terminal never replied"
@ -235,12 +229,10 @@ async fn unknown_brand_malformed_reply_is_discarded() {
assert!(!harness.contains_text("panicked"));
assert_no_probe_garbage_on_screen(&harness);
harness
.inject_keys(b"/terminal-setup\r")
.expect("run /terminal-setup");
harness.inject_keys(b"/doctor\r").expect("run /doctor");
harness
.wait_for_text("Environment", Duration::from_secs(10))
.expect("terminal-setup output");
.expect("doctor output");
assert!(
!harness.contains_text("xtversion"),
"malformed reply must not produce an xtversion line"
@ -276,12 +268,10 @@ async fn unknown_brand_late_reply_swallowed_and_recorded() {
.expect("welcome text");
assert_no_probe_garbage_on_screen(&harness);
harness
.inject_keys(b"/terminal-setup\r")
.expect("run /terminal-setup");
harness.inject_keys(b"/doctor\r").expect("run /doctor");
harness
.wait_for_text("PtyHarnessTerm 9.9", Duration::from_secs(10))
.expect("late XTVERSION identity shown in /terminal-setup");
.expect("late XTVERSION identity shown in /doctor");
assert!(!harness.contains_text("panicked"));
harness.quit().expect("clean quit");
@ -345,12 +335,10 @@ async fn unknown_brand_split_reply_round_trip() {
.expect("welcome text");
assert_no_probe_garbage_on_screen(&harness);
harness
.inject_keys(b"/terminal-setup\r")
.expect("run /terminal-setup");
harness.inject_keys(b"/doctor\r").expect("run /doctor");
harness
.wait_for_text("PtyHarnessTerm 9.9", Duration::from_secs(10))
.expect("split XTVERSION reply shown in /terminal-setup");
.expect("split XTVERSION reply shown in /doctor");
assert!(!harness.contains_text("panicked"));
harness.quit().expect("clean quit");

View file

@ -33,6 +33,7 @@ const ALL_SETTINGS_EXERCISED: &[&str] = &[
"show_timestamps",
"show_timeline",
"page_flip_on_send",
"combine_queued_prompts",
"simple_mode",
"vim_mode",
"remember_tool_approvals",
@ -148,10 +149,7 @@ fn row_idx_for(state: &SettingsModalState, target: &str) -> usize {
state
.rows
.iter()
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == target)
})
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == target))
.unwrap_or_else(|| panic!("setting `{target}` not present in modal rows"))
}
@ -216,6 +214,12 @@ fn assert_set_bool_action(outcome: SettingsKeyOutcome, key: &str, expected: bool
("page_flip_on_send", Action::SetPageFlipOnSend(b)) => {
assert_eq!(b, expected, "SetPageFlipOnSend value differs from expected")
}
("combine_queued_prompts", Action::SetCombineQueuedPrompts(b)) => {
assert_eq!(
b, expected,
"SetCombineQueuedPrompts value differs from expected"
)
}
("simple_mode", Action::SetSimpleMode(b)) => {
assert_eq!(b, expected, "SetSimpleMode value differs from expected")
}
@ -386,6 +390,15 @@ fn space_on_page_flip_on_send_dispatches_typed_setter() {
assert_set_bool_action(outcome, "page_flip_on_send", !default_on);
}
#[test]
fn space_on_combine_queued_prompts_dispatches_typed_setter() {
let mut s = make_state();
navigate_to(&mut s, "combine_queued_prompts");
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
let default_on = UiConfig::default().combine_queued_prompts.unwrap_or(false);
assert_set_bool_action(outcome, "combine_queued_prompts", !default_on);
}
#[test]
fn space_on_simple_mode_dispatches_typed_setter() {
let mut s = make_state();
@ -635,6 +648,21 @@ fn mouse_click_on_page_flip_on_send_indicator_toggles_in_one_click() {
assert_set_bool_action(outcome, "page_flip_on_send", !default_on);
}
#[test]
fn mouse_click_on_combine_queued_prompts_indicator_toggles_in_one_click() {
let mut s = make_state();
synth_rects(&mut s);
let row_y = row_idx_for(&s, "combine_queued_prompts") as u16;
let outcome = handle_settings_mouse(
&mut s,
MouseEventKind::Down(crossterm::event::MouseButton::Left),
72,
row_y,
);
let default_on = UiConfig::default().combine_queued_prompts.unwrap_or(false);
assert_set_bool_action(outcome, "combine_queued_prompts", !default_on);
}
/// Value-column click toggles `remember_tool_approvals` in one click.
#[test]
fn mouse_click_on_remember_tool_approvals_indicator_toggles_in_one_click() {
@ -1762,6 +1790,7 @@ fn registry_kind_membership_through_pr_14() {
"show_timeline",
"show_timestamps",
"page_flip_on_send",
"combine_queued_prompts",
"simple_mode",
"vim_mode",
"remember_tool_approvals",
@ -1894,6 +1923,7 @@ fn defaults_round_trip_through_registry() {
xai_grok_pager::appearance::cache::set_prompt_suggestions(true);
xai_grok_pager::appearance::cache::set_group_tool_verbs(true);
xai_grok_pager::appearance::cache::set_page_flip_on_send(true);
xai_grok_pager::appearance::cache::set_combine_queued_prompts(false);
xai_grok_pager::appearance::cache::set_scroll_mode(
xai_grok_pager::appearance::ScrollMode::Auto,
);
@ -1909,6 +1939,7 @@ fn defaults_round_trip_through_registry() {
"show_timestamps" => SettingValue::Bool(true),
"show_timeline" => SettingValue::Bool(false),
"page_flip_on_send" => SettingValue::Bool(true),
"combine_queued_prompts" => SettingValue::Bool(false),
"simple_mode" => SettingValue::Bool(true),
"vim_mode" => SettingValue::Bool(false),
"remember_tool_approvals" => SettingValue::Bool(false),
@ -2007,6 +2038,7 @@ fn settings_value_payload_matches_kind() {
| SettingsKeyOutcome::Action(Action::SetTimestamps(_))
| SettingsKeyOutcome::Action(Action::SetTimeline(_))
| SettingsKeyOutcome::Action(Action::SetPageFlipOnSend(_))
| SettingsKeyOutcome::Action(Action::SetCombineQueuedPrompts(_))
| SettingsKeyOutcome::Action(Action::SetSimpleMode(_))
| SettingsKeyOutcome::Action(Action::SetMultilineMode(_))
| SettingsKeyOutcome::Action(Action::SetVimMode(_))
@ -2149,10 +2181,10 @@ fn d_key_emits_open_reset_confirm_for_every_setting() {
// without key-release reporting, which tests run without). Skip settings
// with no visible row; their reset path is covered by the dispatch
// round-trip tests.
let has_row = s.rows.iter().any(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == meta.key)
});
let has_row = s
.rows
.iter()
.any(|r| matches!(r, RowEntry::Setting { key, .. } if *key == meta.key));
if !has_row {
continue;
}
@ -3034,8 +3066,7 @@ fn pr6_permission_mode_picker_enter_dispatches_set_permission_mode_commit() {
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
assert!(
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. }
if key == "permission_mode"),
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "permission_mode"),
"Enter on permission_mode row must open the picker, got {:?}",
s.mode(),
);
@ -3316,8 +3347,7 @@ fn pr11_picker_commit_for_default_dispatches_set_permission_mode_default() {
navigate_to(&mut s, "permission_mode");
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
assert!(
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. }
if key == "permission_mode"),
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "permission_mode"),
"Enter on permission_mode row must open the picker, got {:?}",
s.mode(),
);
@ -3362,8 +3392,7 @@ fn pr11_picker_commit_for_ask_dispatches_set_permission_mode_ask() {
navigate_to(&mut s, "permission_mode");
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
assert!(
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. }
if key == "permission_mode"),
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "permission_mode"),
"Enter on permission_mode row must open the picker, got {:?}",
s.mode(),
);
@ -4227,8 +4256,7 @@ fn pr14_default_model_picker_commits_resolved_model_id() {
"Enter on DynamicEnum row must transition to PickingEnum, got {outcome:?}"
);
assert!(
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. }
if key == "default_model"),
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "default_model"),
"Enter must transition to PickingEnum for default_model"
);
@ -4343,8 +4371,7 @@ fn pr14_mouse_click_on_dynamic_enum_row_opens_picker() {
"second click on DynamicEnum row must open picker, got {outcome:?}",
);
assert!(
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. }
if key == "default_model"),
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "default_model"),
"second click on DynamicEnum row must transition to PickingEnum, got {:?}",
s.mode(),
);
@ -4388,8 +4415,7 @@ fn pr8_mouse_click_on_int_row_opens_editor() {
"second click on Int row must be Changed, got {outcome:?}",
);
assert!(
matches!(s.mode(), SettingsModalMode::EditingValue { key, .. }
if key == "max_thoughts_width"),
matches!(s.mode(), SettingsModalMode::EditingValue { key, .. } if key == "max_thoughts_width"),
"second click on Int row must transition to EditingValue, got {:?}",
s.mode(),
);
@ -6889,8 +6915,7 @@ fn scroll_speed_mouse_click_opens_editor() {
"second click on focused Int row must enter the editor, got {outcome:?}"
);
assert!(
matches!(s.mode(), SettingsModalMode::EditingValue { key, .. }
if key == "scroll_speed"),
matches!(s.mode(), SettingsModalMode::EditingValue { key, .. } if key == "scroll_speed"),
"mode must be EditingValue(scroll_speed) after Enter-equivalent click, got {:?}",
s.mode(),
);
@ -7080,8 +7105,7 @@ fn scroll_lines_mouse_click_opens_editor() {
"second click on focused Int row must enter the editor, got {outcome:?}"
);
assert!(
matches!(s.mode(), SettingsModalMode::EditingValue { key, .. }
if key == "scroll_lines"),
matches!(s.mode(), SettingsModalMode::EditingValue { key, .. } if key == "scroll_lines"),
"mode must be EditingValue(scroll_lines), got {:?}",
s.mode(),
);