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

@ -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");
}