diff options
| author | JJ Liebig <jonathan.liebig@gmail.com> | 2026-09-05 02:38:14 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-05 02:38:14 +0200 |
| commit | 7916be1656d8ee9c5882b52753cd47f5e58e3e7e (patch) | |
| tree | d54015e01ecff6566bad26034ada4e8cca3d3edf /src | |
| parent | 987651a760cdfe4cd9493388e8c16b8f99643a05 (diff) | |
fix: make Windows Codex prompt delay size-aware (#3552)
refs #3187
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/schema/agents.rs | 3 | ||||
| -rw-r--r-- | src/api/schema/tests.rs | 1 | ||||
| -rw-r--r-- | src/api/server.rs | 48 | ||||
| -rw-r--r-- | src/api/wait.rs | 64 | ||||
| -rw-r--r-- | src/app/api/agents.rs | 39 | ||||
| -rw-r--r-- | src/cli/agent.rs | 6 | ||||
| -rw-r--r-- | src/pane.rs | 16 | ||||
| -rw-r--r-- | src/pty/actor.rs | 141 | ||||
| -rw-r--r-- | src/terminal/runtime.rs | 4 |
9 files changed, 282 insertions, 40 deletions
diff --git a/src/api/schema/agents.rs b/src/api/schema/agents.rs index 9e1e7b15..ee496d5d 100644 --- a/src/api/schema/agents.rs +++ b/src/api/schema/agents.rs @@ -37,6 +37,9 @@ pub struct AgentPromptWaitOptions { pub until: Vec<AgentStatus>, #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout_ms: Option<u64>, + #[serde(skip)] + #[schemars(skip)] + pub(crate) submission_deadline: Option<std::time::Instant>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 06529f5e..693f0e85 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -133,6 +133,7 @@ fn agent_start_and_prompt_requests_round_trip() { wait: Some(AgentPromptWaitOptions { until: vec![AgentStatus::Idle, AgentStatus::Done], timeout_ms: Some(120_000), + submission_deadline: None, }), }), }; diff --git a/src/api/server.rs b/src/api/server.rs index 64beec33..655d89c5 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -369,7 +369,7 @@ fn handle_request( ); } - dispatch_to_app(request, api_tx, None, response_write_complete, None) + dispatch_to_app(request, api_tx, None, response_write_complete, None, None) } pub(crate) fn api_method_name(method: &Method) -> &'static str { @@ -798,7 +798,22 @@ pub(super) fn dispatch_to_app_with_timeout( api_tx: &ApiRequestSender, timeout: Option<Duration>, ) -> String { - dispatch_to_app(request, api_tx, timeout, None, None) + dispatch_to_app(request, api_tx, timeout, None, None, None) +} + +pub(super) fn dispatch_to_app_with_caller_timeout( + request: Request, + api_tx: &ApiRequestSender, + timeout: Option<Duration>, +) -> String { + dispatch_to_app( + request, + api_tx, + timeout, + None, + None, + Some(("timeout", "timed out waiting for agent status")), + ) } pub(super) fn dispatch_stream_open( @@ -807,7 +822,7 @@ pub(super) fn dispatch_stream_open( timeout: Duration, active: Arc<AtomicBool>, ) -> String { - dispatch_to_app(request, api_tx, Some(timeout), None, Some(active)) + dispatch_to_app(request, api_tx, Some(timeout), None, Some(active), None) } pub(super) fn dispatch_stream_frame( @@ -821,6 +836,7 @@ pub(super) fn dispatch_stream_frame( Some(crate::app::pane_graphics::DIRECT_OUTER_TIMEOUT), None, Some(active), + None, ) } @@ -830,6 +846,7 @@ fn dispatch_to_app( timeout: Option<Duration>, response_write_complete: Option<std::sync::mpsc::Receiver<()>>, stream_active: Option<Arc<AtomicBool>>, + timeout_response: Option<(&str, &str)>, ) -> String { let request_id = request.id.clone(); let request_active = stream_active.clone(); @@ -875,6 +892,11 @@ fn dispatch_to_app( if let Some(active) = request_active { active.store(false, Ordering::Release); } + if err.kind() == std::io::ErrorKind::TimedOut { + if let Some((code, message)) = timeout_response { + return error_response_json(request_id, code, message.into()); + } + } error_response_json( request_id, "server_unavailable", @@ -884,6 +906,26 @@ fn dispatch_to_app( } } +#[cfg(test)] +#[test] +fn caller_timeout_dispatch_uses_timeout_error() { + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let response = dispatch_to_app_with_caller_timeout( + Request { + id: "prompt-timeout".into(), + method: Method::AgentPrompt(crate::api::schema::AgentPromptParams { + target: "reviewer".into(), + text: "review this".into(), + wait: None, + }), + }, + &tx, + Some(Duration::ZERO), + ); + let error: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "timeout"); +} + fn error_response_json(id: String, code: &str, message: String) -> String { serde_json::to_string(&ErrorResponse { id, diff --git a/src/api/wait.rs b/src/api/wait.rs index 9403c4f2..541779f8 100644 --- a/src/api/wait.rs +++ b/src/api/wait.rs @@ -9,8 +9,8 @@ use crate::api::schema::{ SubscriptionEventEnvelope, SuccessResponse, }; use crate::api::server::{ - dispatch_to_app_with_timeout, should_stop_connection, APP_RESPONSE_TIMEOUT, - CONNECTION_POLL_INTERVAL, + dispatch_to_app_with_caller_timeout, dispatch_to_app_with_timeout, should_stop_connection, + APP_RESPONSE_TIMEOUT, CONNECTION_POLL_INTERVAL, }; use crate::api::subscriptions::ActiveSubscription; use crate::api::subscriptions::{match_output, output_match_read_source}; @@ -176,7 +176,7 @@ pub(super) fn wait_for_agent( pub(super) fn prompt_agent( request_id: String, - params: crate::api::schema::AgentPromptParams, + mut params: crate::api::schema::AgentPromptParams, stream: &mut LocalStream, api_tx: &ApiRequestSender, event_hub: &EventHub, @@ -193,7 +193,14 @@ pub(super) fn prompt_agent( ))); }; - let before_prompt = match agent_get(&request_id, ¶ms.target, api_tx) { + let wait_started = std::time::Instant::now(); + let before_prompt = match agent_get_for_prompt( + &request_id, + ¶ms.target, + api_tx, + wait.timeout_ms, + wait_started, + ) { Ok(agent) => agent, Err(response) => { return serde_json::to_string(&response) @@ -204,16 +211,24 @@ pub(super) fn prompt_agent( let prompt_started_working = before_prompt.agent_status == crate::api::schema::AgentStatus::Working; let target = params.target.clone(); - let wait_started = std::time::Instant::now(); + if let Some(prompt_wait) = params.wait.as_mut() { + prompt_wait.submission_deadline = wait + .timeout_ms + .map(|timeout_ms| wait_started + std::time::Duration::from_millis(timeout_ms)); + } let last_event_sequence = event_hub.current_sequence(); - let prompt_response = dispatch_to_app_with_timeout( - Request { - id: request_id.clone(), - method: Method::AgentPrompt(params), - }, + let prompt_request = Request { + id: request_id.clone(), + method: Method::AgentPrompt(params), + }; + #[cfg(windows)] + let prompt_response = dispatch_to_app_with_caller_timeout( + prompt_request, api_tx, - None, + remaining_timeout_ms(wait.timeout_ms, wait_started).map(std::time::Duration::from_millis), ); + #[cfg(not(windows))] + let prompt_response = dispatch_to_app_with_timeout(prompt_request, api_tx, None); let Ok(prompted) = agent_from_response(&request_id, &prompt_response) else { return Ok(Some(prompt_response)); }; @@ -561,6 +576,33 @@ fn agent_get( agent_from_response(request_id, &response) } +fn agent_get_for_prompt( + request_id: &str, + target: &str, + api_tx: &ApiRequestSender, + total_timeout_ms: Option<u64>, + started: std::time::Instant, +) -> Result<crate::api::schema::AgentInfo, ErrorResponse> { + let request = Request { + id: format!("{request_id}:agent"), + method: Method::AgentGet(crate::api::schema::AgentTarget { + target: target.to_string(), + }), + }; + let remaining_ms = remaining_timeout_ms(total_timeout_ms, started); + let response = match remaining_ms { + Some(timeout_ms) if timeout_ms <= APP_RESPONSE_TIMEOUT.as_millis() as u64 => { + dispatch_to_app_with_caller_timeout( + request, + api_tx, + Some(std::time::Duration::from_millis(timeout_ms)), + ) + } + _ => dispatch_to_app_with_timeout(request, api_tx, Some(APP_RESPONSE_TIMEOUT)), + }; + agent_from_response(request_id, &response) +} + fn agent_from_response( request_id: &str, response: &str, diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index 3e4e83a8..cf3e5fa6 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -12,6 +12,18 @@ use super::responses::{encode_error, encode_error_body, encode_success}; const AGENT_PROMPT_SUBMIT_DELAY: Duration = Duration::from_millis(300); +fn agent_prompt_submit_delay(agent: crate::detect::Agent, prompt_bytes: usize) -> Duration { + #[cfg(windows)] + if agent == crate::detect::Agent::Codex { + // Codex consumes Windows paste bursts at about 4 bytes/ms, then suppresses Enter briefly. + // ponytail: best-effort ConPTY timing; remove when Codex exposes a paste-complete boundary. + return Duration::from_millis(600 + prompt_bytes as u64 / 4); + } + #[cfg(not(windows))] + let _ = (agent, prompt_bytes); + AGENT_PROMPT_SUBMIT_DELAY +} + impl App { pub(super) fn handle_agent_list(&mut self, id: String) -> String { encode_success( @@ -72,6 +84,9 @@ impl App { std::thread::spawn(move || { let response = match completion.recv() { Ok(Ok(())) => encode_success(id, ResponseResult::AgentPrompted { agent }), + Ok(Err(err)) if err.kind() == std::io::ErrorKind::TimedOut => { + encode_error(id, "timeout", err.to_string()) + } Ok(Err(err)) => encode_error(id, "agent_prompt_failed", err.to_string()), Err(_) => encode_error(id, "agent_prompt_failed", "pty actor closed"), }; @@ -149,6 +164,14 @@ impl App { ), )); } + let submit_delay = agent_prompt_submit_delay(expected_agent, params.text.len()); + #[cfg(windows)] + let submit_deadline = params + .wait + .as_ref() + .and_then(|wait| wait.submission_deadline); + #[cfg(not(windows))] + let submit_deadline = None; if expected_agent == crate::detect::Agent::GithubCopilot { // Copilot ignores synthetic Enter after focus loss until it receives focus gained. let focus = match crate::ghostty::encode_focus(crate::ghostty::FocusEvent::Gained) { @@ -170,7 +193,8 @@ impl App { .queue_user_input_submission( Bytes::from(text), Bytes::from(enter), - AGENT_PROMPT_SUBMIT_DELAY, + submit_delay, + submit_deadline, ) .map_err(|err| encode_error(id.clone(), "agent_prompt_failed", err.to_string()))?; Ok((id, agent, completion)) @@ -400,6 +424,19 @@ mod tests { .expect("agent prompt responds after submission") } + #[test] + fn prompt_delay_only_scales_for_windows_codex() { + let codex_delay = agent_prompt_submit_delay(Agent::Codex, 4_096); + #[cfg(windows)] + assert_eq!(codex_delay, Duration::from_millis(1_624)); + #[cfg(not(windows))] + assert_eq!(codex_delay, AGENT_PROMPT_SUBMIT_DELAY); + assert_eq!( + agent_prompt_submit_delay(Agent::OpenCode, 4_096), + AGENT_PROMPT_SUBMIT_DELAY + ); + } + #[tokio::test] async fn agent_prompt_sends_text_then_delays_enter() { let mut app = app_with_agent(); diff --git a/src/cli/agent.rs b/src/cli/agent.rs index 42bc5e1c..1af3e0d6 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -834,7 +834,11 @@ fn agent_prompt(args: &[String]) -> std::io::Result<i32> { method: Method::AgentPrompt(AgentPromptParams { target: target.clone(), text: text.clone(), - wait: wait.then_some(AgentPromptWaitOptions { until, timeout_ms }), + wait: wait.then_some(AgentPromptWaitOptions { + until, + timeout_ms, + submission_deadline: None, + }), }), })?; super::print_response(&response) diff --git a/src/pane.rs b/src/pane.rs index b7bd30bc..4f1ea0ad 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1392,11 +1392,21 @@ impl PaneRuntimeIo { text: Bytes, enter: Bytes, delay: std::time::Duration, + deadline: Option<std::time::Instant>, ) -> std::io::Result<std::sync::mpsc::Receiver<std::io::Result<()>>> { match self { - PaneRuntimeIo::Actor(actor) => actor.queue_user_input_submission(text, enter, delay), + PaneRuntimeIo::Actor(actor) => { + #[cfg(windows)] + return actor.queue_user_input_submission(text, enter, delay, deadline); + #[cfg(unix)] + { + let _ = deadline; + actor.queue_user_input_submission(text, enter, delay) + } + } #[cfg(test)] PaneRuntimeIo::TestChannel { sender, .. } => { + let _ = deadline; let sender = sender.clone(); let (reply_tx, reply_rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { @@ -3112,8 +3122,10 @@ impl PaneRuntime { text: Bytes, enter: Bytes, delay: std::time::Duration, + deadline: Option<std::time::Instant>, ) -> std::io::Result<std::sync::mpsc::Receiver<std::io::Result<()>>> { - self.io.queue_user_input_submission(text, enter, delay) + self.io + .queue_user_input_submission(text, enter, delay, deadline) } pub fn try_send_paste(&self, text: String) -> Result<(), mpsc::error::TrySendError<Bytes>> { diff --git a/src/pty/actor.rs b/src/pty/actor.rs index 39b1d29f..76e793ef 100644 --- a/src/pty/actor.rs +++ b/src/pty/actor.rs @@ -8,7 +8,7 @@ pub(crate) use unix::*; mod windows { use std::io::{Read, Write}; use std::sync::{mpsc as std_mpsc, Arc, Mutex}; - use std::time::Duration; + use std::time::{Duration, Instant}; use bytes::Bytes; use portable_pty::{MasterPty, PtySize}; @@ -49,6 +49,7 @@ mod windows { text: Bytes, enter: Bytes, delay: Duration, + deadline: Option<Instant>, reply: std_mpsc::Sender<std::io::Result<()>>, }, } @@ -57,6 +58,7 @@ mod windows { Write(Bytes), SubmissionPart { bytes: Bytes, + deadline: Option<Instant>, reply: std_mpsc::Sender<std::io::Result<()>>, }, } @@ -110,6 +112,7 @@ mod windows { text: Bytes, enter: Bytes, delay: Duration, + deadline: Option<Instant>, ) -> std::io::Result<std_mpsc::Receiver<std::io::Result<()>>> { let accepting = self .accepting @@ -127,6 +130,7 @@ mod windows { text, enter, delay, + deadline, reply: reply_tx, }) .map_err(|err| match err { @@ -292,9 +296,19 @@ mod windows { for command in write_rx { let result = match command { PtyIoWriteCommand::Write(bytes) => write_and_flush(writer, &bytes), - PtyIoWriteCommand::SubmissionPart { bytes, reply } => { - let result = write_and_flush(writer, &bytes); - let failed = result.is_err(); + PtyIoWriteCommand::SubmissionPart { + bytes, + deadline, + reply, + } => { + let result = if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + Err(input_submission_timed_out()) + } else { + write_and_flush(writer, &bytes) + }; + let failed = result + .as_ref() + .is_err_and(|err| err.kind() != std::io::ErrorKind::TimedOut); let _ = reply.send(result); if failed { break; @@ -324,19 +338,32 @@ mod windows { text, enter, delay, + deadline, reply, } => { - let result = write_submission_part(&write_tx, text).and_then(|()| { - std::thread::sleep(delay); - let accepting = accepting - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if !*accepting { - return Err(pty_actor_closed()); - } - write_submission_part(&write_tx, enter) - }); - let failed = result.is_err(); + let result = if deadline.is_some_and(|deadline| { + deadline.saturating_duration_since(Instant::now()) <= delay + }) { + Err(input_submission_timed_out()) + } else { + let text_deadline = + deadline.and_then(|deadline| deadline.checked_sub(delay)); + write_submission_part(&write_tx, text, text_deadline).and_then(|()| { + // A started text write is committed. Finish Enter even if the caller + // stops waiting so a timeout cannot leave a partial prompt. + std::thread::sleep(delay); + let accepting = accepting + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !*accepting { + return Err(pty_actor_closed()); + } + write_submission_part(&write_tx, enter, None) + }) + }; + let failed = result + .as_ref() + .is_err_and(|err| err.kind() != std::io::ErrorKind::TimedOut); let _ = reply.send(result); if failed { break; @@ -349,10 +376,15 @@ mod windows { fn write_submission_part( write_tx: &std_mpsc::Sender<PtyIoWriteCommand>, bytes: Bytes, + deadline: Option<Instant>, ) -> std::io::Result<()> { let (reply, completion) = std_mpsc::channel(); write_tx - .send(PtyIoWriteCommand::SubmissionPart { bytes, reply }) + .send(PtyIoWriteCommand::SubmissionPart { + bytes, + deadline, + reply, + }) .map_err(|_| pty_actor_closed())?; completion .recv() @@ -363,6 +395,13 @@ mod windows { std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed") } + fn input_submission_timed_out() -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "agent prompt timed out before input submission", + ) + } + fn write_and_flush(writer: &mut impl Write, bytes: &[u8]) -> std::io::Result<()> { writer.write_all(bytes)?; writer.flush() @@ -371,8 +410,6 @@ mod windows { #[cfg(test)] mod tests { use super::*; - use std::time::Instant; - struct RecordingWriter { writes: Vec<(Vec<u8>, Instant)>, flushes: Vec<Instant>, @@ -402,6 +439,7 @@ mod windows { fn run_recorded_submission( fail_after: Option<usize>, delay: Duration, + deadline: Option<Instant>, during_delay: impl FnOnce(&std_mpsc::Sender<PtyIoWriteCommand>, &Arc<Mutex<bool>>), ) -> (RecordingWriter, std::io::Result<()>) { let (flushed_tx, flushed_rx) = std_mpsc::channel(); @@ -420,6 +458,7 @@ mod windows { text: Bytes::from_static(b"prompt"), enter: Bytes::from_static(b"\r"), delay, + deadline, reply: reply_tx, }) .unwrap(); @@ -449,7 +488,7 @@ mod windows { #[test] fn submission_sequences_user_input_but_allows_terminal_responses() { let delay = Duration::from_millis(30); - let (writer, result) = run_recorded_submission(None, delay, |write_tx, _| { + let (writer, result) = run_recorded_submission(None, delay, None, |write_tx, _| { write_tx .send(PtyIoWriteCommand::Write(Bytes::from_static(b"response"))) .unwrap(); @@ -465,7 +504,8 @@ mod windows { #[test] fn submission_returns_enter_write_failure() { - let (_writer, result) = run_recorded_submission(Some(1), Duration::ZERO, |_, _| {}); + let (_writer, result) = + run_recorded_submission(Some(1), Duration::ZERO, None, |_, _| {}); let err = result.expect_err("enter failure reaches caller"); assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe); @@ -474,7 +514,7 @@ mod windows { #[test] fn shutdown_during_submission_delay_cancels_enter() { let (writer, result) = - run_recorded_submission(None, Duration::from_millis(30), |_, accepting| { + run_recorded_submission(None, Duration::from_millis(30), None, |_, accepting| { *accepting.lock().unwrap() = false; }); let err = result.expect_err("shutdown cancels enter"); @@ -488,6 +528,65 @@ mod windows { vec![b"prompt"] ); } + + #[test] + fn expired_queued_submission_is_not_written() { + let (flushed_tx, _flushed_rx) = std_mpsc::channel(); + let mut writer = RecordingWriter { + writes: Vec::new(), + flushes: Vec::new(), + fail_after: None, + flushed: flushed_tx, + }; + let (data_tx, mut data_rx) = mpsc::channel(2); + let (write_tx, write_rx) = std_mpsc::channel(); + let (first_reply_tx, first_reply_rx) = std_mpsc::channel(); + let (expired_reply_tx, expired_reply_rx) = std_mpsc::channel(); + let accepting = Arc::new(Mutex::new(true)); + data_tx + .try_send(PtyIoDataCommand::SubmitUserInput { + text: Bytes::from_static(b"first"), + enter: Bytes::from_static(b"\r"), + delay: Duration::from_millis(30), + deadline: None, + reply: first_reply_tx, + }) + .unwrap(); + data_tx + .try_send(PtyIoDataCommand::SubmitUserInput { + text: Bytes::from_static(b"expired"), + enter: Bytes::from_static(b"\r"), + delay: Duration::ZERO, + deadline: Some(Instant::now() + Duration::from_millis(10)), + reply: expired_reply_tx, + }) + .unwrap(); + + let writer_thread = std::thread::spawn(move || { + run_writer(&mut writer, write_rx); + writer + }); + let input_write_tx = write_tx.clone(); + let input_thread = std::thread::spawn(move || { + run_input_forwarder(&mut data_rx, input_write_tx, accepting) + }); + first_reply_rx.recv().unwrap().unwrap(); + let err = expired_reply_rx.recv().unwrap().unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); + + drop(data_tx); + input_thread.join().unwrap(); + drop(write_tx); + let writer = writer_thread.join().unwrap(); + assert_eq!( + writer + .writes + .iter() + .map(|write| write.0.as_slice()) + .collect::<Vec<_>>(), + vec![b"first".as_slice(), b"\r".as_slice()] + ); + } } } diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 66484ea9..4fa80326 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -454,8 +454,10 @@ impl TerminalRuntime { text: Bytes, enter: Bytes, delay: std::time::Duration, + deadline: Option<std::time::Instant>, ) -> std::io::Result<std::sync::mpsc::Receiver<std::io::Result<()>>> { - self.0.queue_user_input_submission(text, enter, delay) + self.0 + .queue_user_input_submission(text, enter, delay, deadline) } pub fn try_send_paste(&self, text: String) -> Result<(), mpsc::error::TrySendError<Bytes>> { |
