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:
parent
a881e6703f
commit
3af4d5d398
556 changed files with 56609 additions and 21892 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ❯).
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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{}",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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..];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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{}",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue