From bb29eedb7209a0d5e91052458ce76bc7e4259d18 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 27 Jul 2026 00:12:25 +0300 Subject: [PATCH] fix: delay agent prompt submission refs #1878 --- docs/next/CHANGELOG.md | 1 + src/app/api/agents.rs | 34 +++++++++++++++++++++++++++++----- src/app/api_helpers.rs | 18 +++++++++++++----- src/pane.rs | 26 ++++++++++++++++++++++++++ src/terminal/runtime.rs | 4 ++++ tests/cli/agents.rs | 2 +- 6 files changed, 74 insertions(+), 11 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index ff0a910a..21d11b85 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -6,6 +6,7 @@ - Relicensed Herdr from AGPL-3.0-or-later to Apache-2.0. ### Fixed +- Agent prompts now wait briefly after sending text before pressing Enter, preventing prompts from remaining in agent composers without starting a turn. (#1878) - Empty clipboard writes from pane applications no longer erase existing clipboard contents or show a copied confirmation. (#1893) - Plain mouse movement no longer triggers continuous full renders while preserving Herdr menu hover and pane application mouse tracking. (#1865) - `ui.copy_on_select = false` now retains drag and double-click word selections without copying; `Ctrl+C`, or `Cmd+C` when the host terminal forwards it, copies and clears the selection. diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index f1977ce4..8bc29462 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use bytes::Bytes; use crate::api::schema::{ @@ -8,6 +10,8 @@ use crate::app::App; use super::responses::{encode_error, encode_error_body, encode_success}; +const AGENT_PROMPT_SUBMIT_DELAY: Duration = Duration::from_millis(300); + impl App { pub(super) fn handle_agent_list(&mut self, id: String) -> String { encode_success( @@ -94,10 +98,12 @@ impl App { ), ); } - let bytes = crate::app::api_helpers::encode_api_submission(runtime, ¶ms.text); - if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + let (text, enter) = + crate::app::api_helpers::encode_api_submission_parts(runtime, ¶ms.text); + if let Err(err) = runtime.try_send_bytes(Bytes::from(text)) { return encode_error(id, "agent_prompt_failed", err.to_string()); } + runtime.send_bytes_after(Bytes::from(enter), AGENT_PROMPT_SUBMIT_DELAY); let Some(agent) = self.agent_info(resolved.ws_idx, resolved.pane_id) else { return agent_not_found(id, ¶ms.target); }; @@ -307,7 +313,7 @@ mod tests { } #[tokio::test] - async fn agent_prompt_accepts_pane_ids_and_working_agents_atomically() { + async fn agent_prompt_sends_text_then_delays_enter() { let mut app = app_with_agent(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] @@ -324,6 +330,7 @@ mod tests { app.state.insert_test_runtime(pane_id, runtime); let public_pane_id = app.public_pane_id(0, pane_id).unwrap(); + let bracketed_started = std::time::Instant::now(); let response = app.handle_agent_prompt( "req".into(), AgentPromptParams { @@ -339,13 +346,22 @@ mod tests { assert_eq!(agent.name.as_deref(), Some("reviewer")); assert_eq!( rx.try_recv().unwrap(), - Bytes::from_static(b"\x1b[200~A != B\x1b[201~\r") + Bytes::from_static(b"\x1b[200~A != B\x1b[201~") ); assert!(rx.try_recv().is_err()); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .unwrap() + .unwrap(), + Bytes::from_static(b"\r") + ); + assert!(bracketed_started.elapsed() >= AGENT_PROMPT_SUBMIT_DELAY); app.lookup_runtime_sender(0, pane_id) .unwrap() .test_process_pty_bytes(b"\x1b[?2004l"); + let raw_started = std::time::Instant::now(); let raw = app.handle_agent_prompt( "req-raw".into(), AgentPromptParams { @@ -356,8 +372,16 @@ mod tests { ); let raw: SuccessResponse = serde_json::from_str(&raw).unwrap(); assert!(matches!(raw.result, ResponseResult::AgentPrompted { .. })); - assert_eq!(rx.try_recv().unwrap(), Bytes::from_static(b"A != B\r")); + assert_eq!(rx.try_recv().unwrap(), Bytes::from_static(b"A != B")); assert!(rx.try_recv().is_err()); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .unwrap() + .unwrap(), + Bytes::from_static(b"\r") + ); + assert!(raw_started.elapsed() >= AGENT_PROMPT_SUBMIT_DELAY); let rejected = app.handle_agent_prompt( "req-label".into(), diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index e0091b39..a6dad384 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -48,17 +48,25 @@ pub(super) fn encode_api_keys( Ok(encoded_keys) } -pub(super) fn encode_api_submission( +pub(super) fn encode_api_submission_parts( runtime: &crate::terminal::TerminalRuntime, text: &str, -) -> Vec { - let mut bytes = encode_api_text(runtime, text); +) -> (Vec, Vec) { + let text = encode_api_text(runtime, text); let enter = crossterm::event::KeyEvent::new( crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::NONE, ); - bytes.extend_from_slice(&runtime.encode_terminal_key(enter.into())); - bytes + (text, runtime.encode_terminal_key(enter.into())) +} + +pub(super) fn encode_api_submission( + runtime: &crate::terminal::TerminalRuntime, + text: &str, +) -> Vec { + let (mut text, enter) = encode_api_submission_parts(runtime, text); + text.extend_from_slice(&enter); + text } pub(super) fn encode_api_input( diff --git a/src/pane.rs b/src/pane.rs index 45f4f720..c6d8c003 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1107,6 +1107,28 @@ impl PaneRuntimeIo { PaneRuntimeIo::TestChannel { sender, .. } => sender.try_send(bytes), } } + + fn send_bytes_after(&self, bytes: Bytes, delay: std::time::Duration) { + match self { + PaneRuntimeIo::Actor(actor) => { + let actor = actor.clone(); + tokio::spawn(async move { + tokio::time::sleep(delay).await; + if let Err(err) = actor.write_user_input(bytes).await { + warn!(error = %err, "failed to send delayed PTY input"); + } + }); + } + #[cfg(test)] + PaneRuntimeIo::TestChannel { sender, .. } => { + let sender = sender.clone(); + tokio::spawn(async move { + tokio::time::sleep(delay).await; + let _ = sender.send(bytes).await; + }); + } + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -2628,6 +2650,10 @@ impl PaneRuntime { self.io.try_send_bytes(bytes) } + pub fn send_bytes_after(&self, bytes: Bytes, delay: std::time::Duration) { + self.io.send_bytes_after(bytes, delay); + } + pub async fn send_paste(&self, text: String) -> Result<(), mpsc::error::SendError> { self.send_bytes(self.paste_payload(text)).await } diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index cd6f4211..f9858ce0 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -411,6 +411,10 @@ impl TerminalRuntime { self.0.try_send_bytes(bytes) } + pub fn send_bytes_after(&self, bytes: Bytes, delay: std::time::Duration) { + self.0.send_bytes_after(bytes, delay); + } + pub async fn send_paste(&self, text: String) -> Result<(), mpsc::error::SendError> { self.0.send_paste(text).await } diff --git a/tests/cli/agents.rs b/tests/cli/agents.rs index 8a08826f..37c8d557 100644 --- a/tests/cli/agents.rs +++ b/tests/cli/agents.rs @@ -157,7 +157,7 @@ fn agent_start_command_works() { "do not transition", "--wait", "--timeout", - "200", + "500", ], ); assert_eq!(stale_idle.status.code(), Some(1));