diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-17 23:55:08 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-17 23:59:56 +0300 |
| commit | e0758c32118f2aa006db3d8fa4b41833fe6e7ead (patch) | |
| tree | 2b29dd61ebf870db8d0ac95cc0b4515178c834c4 /src/app | |
| parent | 040956531f673c8fb7720037494ed4e61b123c6c (diff) | |
feat: harden named agent cli workflows
refs #1525
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 52 | ||||
| -rw-r--r-- | src/app/agents.rs | 360 | ||||
| -rw-r--r-- | src/app/api.rs | 1 | ||||
| -rw-r--r-- | src/app/api/agents.rs | 185 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 51 | ||||
| -rw-r--r-- | src/app/api_helpers.rs | 29 | ||||
| -rw-r--r-- | src/app/mod.rs | 99 | ||||
| -rw-r--r-- | src/app/runtime.rs | 16 |
8 files changed, 554 insertions, 239 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 3fedeeb3..b4f352b4 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1,6 +1,8 @@ //! Pure state mutations on AppState. //! These don't need channels, async, or PTY runtime. +use std::time::Instant; + use tracing::{info, warn}; use crate::detect::{Agent, AgentState}; @@ -2583,7 +2585,7 @@ impl AppState { observed_at, } => self .update_terminal_state(pane_id, |terminal| { - Some(terminal.set_detected_state_with_screen_signals_at( + let mutation = terminal.set_detected_state_with_screen_signals_at( agent, state, visible_blocker, @@ -2591,7 +2593,11 @@ impl AppState { visible_working, process_exited, observed_at, - )) + ); + if process_exited { + terminal.reconcile_managed_agent_at(observed_at, true); + } + Some(mutation) }) .into_iter() .collect(), @@ -2753,11 +2759,13 @@ impl AppState { .attached_terminal_id .clone(); let previous_seen = self.workspaces[ws_idx].pane_state(pane_id)?.seen; - let mutation = { + let (mutation, managed_changed) = { let terminal = self.terminals.get_mut(&terminal_id)?; - update(terminal)? + let mutation = update(terminal)?; + let managed_changed = terminal.reconcile_managed_agent_at(Instant::now(), false); + (mutation, managed_changed) }; - if mutation.session_ref_changed { + if mutation.session_ref_changed || managed_changed { self.mark_session_dirty(); } let change = mutation.effective_state_change?; @@ -2785,6 +2793,40 @@ impl AppState { Some(update) } + pub(crate) fn next_managed_agent_deadline(&self) -> Option<Instant> { + self.terminals + .values() + .filter_map(crate::terminal::TerminalState::next_managed_agent_deadline) + .min() + } + + pub(crate) fn reconcile_managed_agents_at(&mut self, now: Instant) -> Vec<(usize, PaneId)> { + let mut changed_terminals = std::collections::HashSet::new(); + for (terminal_id, terminal) in &mut self.terminals { + if terminal.reconcile_managed_agent_at(now, false) { + changed_terminals.insert(terminal_id.clone()); + } + } + if changed_terminals.is_empty() { + return Vec::new(); + } + self.mark_session_dirty(); + self.workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, workspace)| { + let changed_terminals = &changed_terminals; + workspace.tabs.iter().flat_map(move |tab| { + tab.panes.iter().filter_map(move |(&pane_id, pane)| { + changed_terminals + .contains(&pane.attached_terminal_id) + .then_some((ws_idx, pane_id)) + }) + }) + }) + .collect() + } + pub(crate) fn publish_pane_process_exit_if_agent( &mut self, pane_id: PaneId, diff --git a/src/app/agents.rs b/src/app/agents.rs index 83f97bd4..f95bf77f 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -1,7 +1,13 @@ -use std::path::PathBuf; +use std::time::{Duration, Instant}; -use super::{terminal_targets::TerminalTargetError, App, Mode}; -use crate::api::schema::{AgentStartParams, SplitDirection}; +use bytes::Bytes; + +use super::{terminal_targets::TerminalTargetError, App}; +use crate::api::schema::AgentStartParams; + +const DEFAULT_AGENT_START_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_AGENT_START_TIMEOUT: Duration = Duration::from_secs(300); +const AGENT_START_SETTLE_DELAY: Duration = Duration::from_secs(3); impl App { pub(super) fn collect_agent_infos(&self) -> Vec<crate::api::schema::AgentInfo> { @@ -20,6 +26,31 @@ impl App { .collect() } + pub(super) fn reconcile_managed_agent_target(&mut self, target: &str) { + let Ok(resolved) = self.resolve_terminal_target(target) else { + return; + }; + let Some(terminal_id) = self + .state + .workspaces + .get(resolved.ws_idx) + .and_then(|workspace| workspace.terminal_id(resolved.pane_id)) + .cloned() + else { + return; + }; + let changed = self + .state + .terminals + .get_mut(&terminal_id) + .is_some_and(|terminal| terminal.reconcile_managed_agent_at(Instant::now(), false)); + if changed { + self.state.mark_session_dirty(); + self.schedule_session_save(); + self.emit_pane_updated(resolved.ws_idx, resolved.pane_id); + } + } + pub(super) fn agent_info_for_target( &self, target: &str, @@ -79,6 +110,9 @@ impl App { target: target.to_string(), })); }; + if terminal.managed_agent_launch_pending() { + return Err(AgentRenameError::PendingLaunch); + } match normalized_name { Some(name) => { terminal.set_agent_name(name.clone()); @@ -98,14 +132,20 @@ impl App { pub(super) fn start_agent( &mut self, params: AgentStartParams, - extra_env: Vec<(String, String)>, ) -> Result<(crate::api::schema::AgentInfo, Vec<String>), AgentStartError> { let name = params.name.trim().to_string(); if name.is_empty() { return Err(AgentStartError::InvalidName); } - if params.argv.is_empty() { - return Err(AgentStartError::EmptyArgv); + let Some(kind) = crate::detect::parse_agent_label(¶ms.kind) else { + return Err(AgentStartError::UnsupportedKind(params.kind)); + }; + if params + .args + .iter() + .any(|arg| arg.chars().any(char::is_control)) + { + return Err(AgentStartError::InvalidArgument); } let conflicts = self.agent_name_conflicts(&name, ""); if !conflicts.is_empty() { @@ -114,94 +154,68 @@ impl App { candidates: conflicts, }); } - - let cwd = params - .cwd - .map(PathBuf::from) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from("/")); - let argv = params.argv; - let focus = params.focus; - let (rows, cols) = self.state.estimate_pane_size(); - - let (ws_idx, tab_idx, pane_id) = if let Some(tab_id) = params.tab_id { - let (ws_idx, tab_idx) = - self.parse_tab_id(&tab_id) - .ok_or_else(|| AgentStartError::TargetNotFound { - target: tab_id.clone(), - })?; - if let Some(workspace_id) = params.workspace_id.as_deref() { - let requested_ws_idx = self.parse_workspace_id(workspace_id).ok_or_else(|| { - AgentStartError::TargetNotFound { - target: workspace_id.to_string(), - } - })?; - if requested_ws_idx != ws_idx { - return Err(AgentStartError::PlacementConflict); - } - } - let target_pane = self.state.workspaces[ws_idx].tabs[tab_idx].layout.focused(); - self.spawn_agent_split( - ws_idx, - target_pane, - params.split.unwrap_or(SplitDirection::Right), - cwd, - &argv, - extra_env, - focus, - )? - } else if let Some(workspace_id) = params.workspace_id { - let ws_idx = self.parse_workspace_id(&workspace_id).ok_or_else(|| { - AgentStartError::TargetNotFound { - target: workspace_id.clone(), - } - })?; - let tab_idx = self.state.workspaces[ws_idx].active_tab; - let target_pane = self.state.workspaces[ws_idx].tabs[tab_idx].layout.focused(); - self.spawn_agent_split( - ws_idx, - target_pane, - params.split.unwrap_or(SplitDirection::Right), - cwd, - &argv, - extra_env, - focus, - )? - } else if self.state.workspaces.is_empty() { - self.spawn_agent_workspace(cwd, rows, cols, &argv, extra_env, focus)? - } else { - let ws_idx = self.state.active.unwrap_or(0); - let tab_idx = self.state.workspaces[ws_idx].active_tab; - let target_pane = self.state.workspaces[ws_idx].tabs[tab_idx].layout.focused(); - self.spawn_agent_split( - ws_idx, - target_pane, - params.split.unwrap_or(SplitDirection::Right), - cwd, - &argv, - extra_env, - focus, - )? + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return Err(AgentStartError::TargetNotFound(params.pane_id)); }; - let terminal_id = self .state .workspaces .get(ws_idx) - .and_then(|ws| ws.terminal_id(pane_id)) + .and_then(|workspace| workspace.terminal_id(pane_id)) .cloned() - .ok_or_else(|| AgentStartError::SpawnFailed("terminal disappeared".into()))?; - let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { - return Err(AgentStartError::SpawnFailed("terminal disappeared".into())); - }; - terminal.set_agent_name(name.clone()); + .ok_or_else(|| AgentStartError::TargetNotFound(params.pane_id.clone()))?; + let terminal = self + .state + .terminals + .get(&terminal_id) + .ok_or_else(|| AgentStartError::TargetNotFound(params.pane_id.clone()))?; + if terminal.is_agent_terminal() || terminal.managed_agent_kind().is_some() { + return Err(AgentStartError::TargetBusy(params.pane_id)); + } + let runtime = self + .terminal_runtimes + .get(&terminal_id) + .ok_or_else(|| AgentStartError::TargetUnavailable(params.pane_id.clone()))?; + let shell_name = available_shell_name(runtime) + .ok_or_else(|| AgentStartError::TargetBusy(params.pane_id.clone()))?; + + let mut argv = vec![crate::detect::interactive_agent_executable(kind).to_string()]; + argv.extend(params.args); + let command = crate::platform::interactive_shell_command(&argv, &shell_name) + .ok_or(AgentStartError::InvalidArgument)?; + let bytes = crate::app::api_helpers::encode_api_submission(runtime, &command); + let timeout = Duration::from_millis( + params + .timeout_ms + .unwrap_or(DEFAULT_AGENT_START_TIMEOUT.as_millis() as u64), + ); + if timeout <= AGENT_START_SETTLE_DELAY || timeout > MAX_AGENT_START_TIMEOUT { + return Err(AgentStartError::InvalidTimeout); + } + + let previous_label = terminal.manual_label.clone(); + let now = Instant::now(); + let terminal = self + .state + .terminals + .get_mut(&terminal_id) + .ok_or_else(|| AgentStartError::TargetUnavailable(params.pane_id.clone()))?; + terminal.begin_managed_agent(name.clone(), kind, now, AGENT_START_SETTLE_DELAY, timeout); terminal.set_manual_label(name); + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + terminal.clear_agent_name(); + match previous_label { + Some(label) => terminal.set_manual_label(label), + None => terminal.clear_manual_label(), + } + return Err(AgentStartError::InputFailed(err.to_string())); + } self.state.mark_session_dirty(); + self.schedule_session_save(); let agent = self .agent_info(ws_idx, pane_id) - .ok_or_else(|| AgentStartError::SpawnFailed("agent disappeared".into()))?; - debug_assert_eq!(agent.tab_id, self.public_tab_id(ws_idx, tab_idx).unwrap()); + .ok_or(AgentStartError::TargetUnavailable(params.pane_id))?; Ok((agent, argv)) } @@ -214,20 +228,33 @@ impl App { code: "invalid_agent_name".into(), message: "agent name must not be empty".into(), }, - AgentStartError::EmptyArgv => crate::api::schema::ErrorBody { - code: "invalid_agent_argv".into(), - message: "agent start argv must not be empty".into(), + AgentStartError::UnsupportedKind(kind) => crate::api::schema::ErrorBody { + code: "unsupported_agent_kind".into(), + message: format!("unsupported interactive agent kind {kind}"), }, - AgentStartError::TargetNotFound { target } => crate::api::schema::ErrorBody { - code: "agent_placement_not_found".into(), - message: format!("agent placement target {target} not found"), + AgentStartError::InvalidArgument => crate::api::schema::ErrorBody { + code: "invalid_agent_argument".into(), + message: "agent arguments cannot be encoded safely for the target shell".into(), }, - AgentStartError::PlacementConflict => crate::api::schema::ErrorBody { - code: "agent_placement_conflict".into(), - message: "--tab must belong to --workspace".into(), + AgentStartError::InvalidTimeout => crate::api::schema::ErrorBody { + code: "invalid_agent_timeout".into(), + message: "agent start timeout must be greater than 3000ms and at most 300000ms" + .into(), }, - AgentStartError::SpawnFailed(message) => crate::api::schema::ErrorBody { - code: "agent_start_failed".into(), + AgentStartError::TargetNotFound(target) => crate::api::schema::ErrorBody { + code: "agent_pane_not_found".into(), + message: format!("agent target pane {target} not found"), + }, + AgentStartError::TargetBusy(target) => crate::api::schema::ErrorBody { + code: "agent_pane_busy".into(), + message: format!("agent target pane {target} is not an available shell"), + }, + AgentStartError::TargetUnavailable(target) => crate::api::schema::ErrorBody { + code: "agent_pane_unavailable".into(), + message: format!("agent target pane {target} has no live terminal"), + }, + AgentStartError::InputFailed(message) => crate::api::schema::ErrorBody { + code: "agent_start_input_failed".into(), message, }, AgentStartError::DuplicateName { name, candidates } => crate::api::schema::ErrorBody { @@ -291,6 +318,10 @@ impl App { ) -> crate::api::schema::ErrorBody { match err { AgentRenameError::Target(err) => self.agent_target_error_body(err), + AgentRenameError::PendingLaunch => crate::api::schema::ErrorBody { + code: "agent_launch_pending".into(), + message: "agent name cannot change while startup is pending".into(), + }, AgentRenameError::DuplicateName { name, candidates } => crate::api::schema::ErrorBody { code: "agent_name_taken".into(), message: format!( @@ -313,99 +344,7 @@ impl App { } } - fn spawn_agent_workspace( - &mut self, - cwd: PathBuf, - rows: u16, - cols: u16, - argv: &[String], - extra_env: Vec<(String, String)>, - focus: bool, - ) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> { - let (ws, terminal, runtime) = crate::workspace::Workspace::new_argv_command_with_extra_env( - cwd, - rows, - cols, - argv, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.event_tx.clone(), - self.render_notify.clone(), - self.render_dirty.clone(), - extra_env, - ) - .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; - self.terminal_runtimes.insert(terminal.id.clone(), runtime); - self.state.terminals.insert(terminal.id.clone(), terminal); - self.state.workspaces.push(ws); - let ws_idx = self.state.workspaces.len() - 1; - self.state - .remove_alias_shadowed_by_new_pane(self.state.workspaces[ws_idx].tabs[0].root_pane); - if focus || self.state.active.is_none() { - self.state.switch_workspace(ws_idx); - self.state.mode = Mode::Terminal; - } - self.schedule_session_save(); - let pane_id = self.state.workspaces[ws_idx].tabs[0].root_pane; - Ok((ws_idx, 0, pane_id)) - } - - fn spawn_agent_split( - &mut self, - ws_idx: usize, - target_pane: crate::layout::PaneId, - split: SplitDirection, - cwd: PathBuf, - argv: &[String], - extra_env: Vec<(String, String)>, - focus: bool, - ) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> { - let (rows, cols) = self.state.estimate_pane_size(); - let previous_focus = self.state.current_pane_focus_target(); - let direction = match split { - SplitDirection::Right => ratatui::layout::Direction::Horizontal, - SplitDirection::Down => ratatui::layout::Direction::Vertical, - }; - let result = self - .state - .workspaces - .get_mut(ws_idx) - .and_then(|ws| { - ws.split_pane_argv_command( - target_pane, - direction, - rows, - cols, - Some(cwd), - argv, - extra_env, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - focus, - ) - }) - .ok_or_else(|| AgentStartError::TargetNotFound { - target: target_pane.raw().to_string(), - })? - .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; - self.terminal_runtimes - .insert(result.1.terminal.id.clone(), result.1.runtime); - self.state - .remove_alias_shadowed_by_new_pane(result.1.pane_id); - self.state - .terminals - .insert(result.1.terminal.id.clone(), result.1.terminal); - if focus { - self.state.switch_workspace_tab(ws_idx, result.0); - self.state - .record_pane_focus_change(previous_focus, ws_idx, result.1.pane_id); - self.state.mode = Mode::Terminal; - } - self.schedule_session_save(); - Ok((ws_idx, result.0, result.1.pane_id)) - } - - fn agent_info( + pub(super) fn agent_info( &self, ws_idx: usize, pane_id: crate::layout::PaneId, @@ -434,6 +373,9 @@ impl App { tab_id: pane.tab_id, pane_id: pane.pane_id, focused: pane.focused, + launch_pending: terminal.managed_agent_launch_pending(), + interactive_ready: terminal.managed_agent_interactive_ready(), + state_change_seq: terminal.last_agent_state_change_seq.unwrap_or(0), cwd: pane.cwd, foreground_cwd: pane.foreground_cwd, revision: pane.revision, @@ -454,14 +396,45 @@ impl App { } } +fn available_shell_name(runtime: &crate::terminal::TerminalRuntime) -> Option<String> { + #[cfg(test)] + if runtime.child_pid().is_none() { + return Some("sh".into()); + } + crate::platform::available_pane_shell(runtime.child_pid()?) +} + +pub(super) fn runtime_hosts_agent( + runtime: &crate::terminal::TerminalRuntime, + expected: crate::detect::Agent, +) -> bool { + #[cfg(test)] + if runtime.child_pid().is_none() { + return true; + } + live_runtime_agent(runtime) == Some(expected) +} + +fn live_runtime_agent(runtime: &crate::terminal::TerminalRuntime) -> Option<crate::detect::Agent> { + let job = crate::detect::foreground_job(runtime.child_pid()?)?; + crate::detect::identify_agent_in_job(&job) + .map(|(agent, _)| agent) + .or_else(|| { + job.processes + .iter() + .find_map(|process| crate::platform::process_agent_hint(process.pid)) + }) +} + pub(super) enum AgentStartError { InvalidName, - EmptyArgv, - TargetNotFound { - target: String, - }, - PlacementConflict, - SpawnFailed(String), + UnsupportedKind(String), + InvalidArgument, + InvalidTimeout, + TargetNotFound(String), + TargetBusy(String), + TargetUnavailable(String), + InputFailed(String), DuplicateName { name: String, candidates: Vec<crate::api::schema::AgentInfo>, @@ -470,6 +443,7 @@ pub(super) enum AgentStartError { pub(super) enum AgentRenameError { Target(TerminalTargetError), + PendingLaunch, DuplicateName { name: String, candidates: Vec<crate::api::schema::AgentInfo>, diff --git a/src/app/api.rs b/src/app/api.rs index 7bc8edf1..79402e47 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -995,6 +995,7 @@ impl App { Method::AgentFocus(target) => return self.handle_agent_focus(request.id, target), Method::AgentRename(params) => return self.handle_agent_rename(request.id, params), Method::AgentStart(params) => return self.handle_agent_start(request.id, params), + Method::AgentPrompt(params) => return self.handle_agent_prompt(request.id, params), Method::AgentRead(params) => return self.handle_agent_read(request.id, params), Method::AgentExplain(target) => return self.handle_agent_explain(request.id, target), Method::AgentSend(params) => return self.handle_agent_send(request.id, params), diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index b66924ec..d1fbf14c 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -1,8 +1,8 @@ use bytes::Bytes; use crate::api::schema::{ - AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget, PaneReadResult, ReadFormat, - ReadSource, ResponseResult, + AgentPromptParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget, + PaneReadResult, ReadFormat, ReadSource, ResponseResult, }; use crate::app::App; @@ -19,6 +19,7 @@ impl App { } pub(super) fn handle_agent_get(&mut self, id: String, target: AgentTarget) -> String { + self.reconcile_managed_agent_target(&target.target); let agent = match self.agent_info_for_target(&target.target) { Ok(agent) => agent, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), @@ -46,11 +47,7 @@ impl App { } pub(super) fn handle_agent_start(&mut self, id: String, params: AgentStartParams) -> String { - let extra_env = match super::env::normalize_launch_env(params.env.clone()) { - Ok(env) => env, - Err((code, message)) => return encode_error(id, &code, message), - }; - let (agent, argv) = match self.start_agent(params, extra_env) { + let (agent, argv) = match self.start_agent(params) { Ok(started) => started, Err(err) => return encode_error_body(id, self.agent_start_error_body(err)), }; @@ -58,6 +55,78 @@ impl App { encode_success(id, ResponseResult::AgentStarted { agent, argv }) } + pub(super) fn handle_agent_prompt(&mut self, id: String, params: AgentPromptParams) -> String { + if params.text.is_empty() { + return encode_error(id, "empty_agent_prompt", "agent prompt must not be empty"); + } + let resolved = match self.resolve_terminal_target(¶ms.target) { + Ok(resolved) => resolved, + Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), + }; + let Some(terminal_id) = self + .state + .workspaces + .get(resolved.ws_idx) + .and_then(|workspace| workspace.terminal_id(resolved.pane_id)) + .cloned() + else { + return agent_not_found(id, ¶ms.target); + }; + let Some(terminal) = self.state.terminals.get(&terminal_id) else { + return agent_not_found(id, ¶ms.target); + }; + let Some(expected_agent) = terminal.effective_known_agent().filter(|_| { + terminal.agent_name.as_deref() == Some(params.target.as_str()) + && terminal.state != crate::detect::AgentState::Unknown + }) else { + return agent_not_ready(id, ¶ms.target); + }; + if terminal.managed_agent_launch_pending() { + return agent_not_ready(id, ¶ms.target); + } + if terminal.state == crate::detect::AgentState::Working { + return encode_error( + id, + "agent_working", + format!( + "agent {} is still working; wait before prompting", + params.target + ), + ); + } + if terminal.managed_agent_kind().is_some() && !terminal.managed_agent_interactive_ready() { + return agent_not_ready(id, ¶ms.target); + } + let baseline_state_change_seq = terminal.last_agent_state_change_seq.unwrap_or(0); + let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else { + return agent_not_found(id, ¶ms.target); + }; + if !super::super::agents::runtime_hosts_agent(runtime, expected_agent) { + return encode_error( + id, + "agent_not_ready", + format!( + "agent {} is no longer the pane foreground process", + params.target + ), + ); + } + let bytes = crate::app::api_helpers::encode_api_submission(runtime, ¶ms.text); + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "agent_prompt_failed", err.to_string()); + } + let Some(agent) = self.agent_info(resolved.ws_idx, resolved.pane_id) else { + return agent_not_found(id, ¶ms.target); + }; + encode_success( + id, + ResponseResult::AgentPrompted { + agent, + baseline_state_change_seq, + }, + ) + } + pub(super) fn handle_agent_read( &mut self, id: String, @@ -195,6 +264,14 @@ impl App { } } +fn agent_not_ready(id: String, target: &str) -> String { + encode_error( + id, + "agent_not_ready", + format!("agent {target} is not an active named agent"), + ) +} + fn agent_not_found(id: String, target: &str) -> String { encode_error( id, @@ -231,6 +308,100 @@ mod tests { app } + #[tokio::test] + async fn agent_prompt_brackets_opencode_text_and_falls_back_to_raw_submission() { + 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] + .attached_terminal_id + .clone(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_agent_name("reviewer".into()); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + let (runtime, mut rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, 24, 0, b"", 1, + ); + runtime.test_process_pty_bytes(b"\x1b[?2004h"); + app.state.insert_test_runtime(pane_id, runtime); + + let response = app.handle_agent_prompt( + "req".into(), + AgentPromptParams { + target: "reviewer".into(), + text: "A != B".into(), + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::AgentPrompted { agent, .. } = success.result else { + panic!("expected prompted response"); + }; + 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") + ); + assert!(rx.try_recv().is_err()); + + app.lookup_runtime_sender(0, pane_id) + .unwrap() + .test_process_pty_bytes(b"\x1b[?2004l"); + let raw = app.handle_agent_prompt( + "req-raw".into(), + AgentPromptParams { + target: "reviewer".into(), + text: "A != B".into(), + }, + ); + 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!(rx.try_recv().is_err()); + + let rejected = app.handle_agent_prompt( + "req-label".into(), + AgentPromptParams { + target: "opencode".into(), + text: "wrong target".into(), + }, + ); + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&rejected).unwrap(); + assert_eq!(error.error.code, "agent_not_ready"); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn agent_prompt_rejects_managed_agent_while_startup_is_pending() { + 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] + .attached_terminal_id + .clone(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + let now = std::time::Instant::now(); + terminal.begin_managed_agent( + "reviewer".into(), + Agent::OpenCode, + now, + std::time::Duration::from_secs(3), + std::time::Duration::from_secs(10), + ); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.state.insert_test_runtime(pane_id, runtime); + + let response = app.handle_agent_prompt( + "req-pending".into(), + AgentPromptParams { + target: "reviewer".into(), + text: "A != B".into(), + }, + ); + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "agent_not_ready"); + assert!(rx.try_recv().is_err()); + } + #[test] fn agent_focus_marks_already_focused_done_agent_seen() { let mut app = app_with_agent(); diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 17569682..6a5b41fe 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -20,9 +20,8 @@ use crate::app::Mode; use crate::layout::{find_in_direction, NavDirection, PaneId}; use super::super::api_helpers::{ - detect_state_from_api, encode_api_keys, encode_api_text, normalize_metadata_source, - normalize_metadata_tokens, normalize_metadata_ttl, normalize_reported_agent_label, - MAX_METADATA_TOKEN_KEYS_PER_RESOURCE, + detect_state_from_api, encode_api_keys, normalize_metadata_source, normalize_metadata_tokens, + normalize_metadata_ttl, normalize_reported_agent_label, MAX_METADATA_TOKEN_KEYS_PER_RESOURCE, }; #[cfg(test)] use super::super::api_helpers::{METADATA_SOURCE_MAX_CHARS, METADATA_TTL_MAX_MS}; @@ -1499,20 +1498,16 @@ impl App { let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; - let encoded_keys = match encode_api_keys(runtime, ¶ms.keys) { - Ok(encoded_keys) => encoded_keys, + let bytes = match super::super::api_helpers::encode_api_input( + runtime, + ¶ms.text, + ¶ms.keys, + ) { + Ok(bytes) => bytes, Err(key) => return encode_error(id, "invalid_key", format!("unsupported key {key}")), }; - if !params.text.is_empty() { - let text_bytes = encode_api_text(runtime, ¶ms.text); - if let Err(err) = runtime.try_send_bytes(Bytes::from(text_bytes)) { - return encode_error(id, "pane_send_failed", err.to_string()); - } - } - for bytes in encoded_keys { - if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { - return encode_error(id, "pane_send_failed", err.to_string()); - } + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "pane_send_failed", err.to_string()); } encode_success(id, ResponseResult::Ok {}) @@ -2072,6 +2067,32 @@ mod tests { } #[tokio::test] + async fn api_pane_send_input_brackets_text_and_enter_atomically() { + let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); + let internal_pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.lookup_runtime_sender(0, internal_pane_id) + .unwrap() + .test_process_pty_bytes(b"\x1b[?2004h"); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::PaneSendInput(PaneSendInputParams { + pane_id, + text: "A != B".into(), + keys: vec!["Enter".into()], + }), + }); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, ResponseResult::Ok {}); + assert_eq!( + rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[200~A != B\x1b[201~\r") + ); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] async fn api_pane_send_input_keys_accept_key_combo_chords() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index c0a98357..c7dc39d2 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -48,6 +48,35 @@ pub(super) fn encode_api_keys( Ok(encoded_keys) } +pub(super) fn encode_api_submission( + runtime: &crate::terminal::TerminalRuntime, + text: &str, +) -> Vec<u8> { + let mut bytes = 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 +} + +pub(super) fn encode_api_input( + runtime: &crate::terminal::TerminalRuntime, + text: &str, + keys: &[String], +) -> Result<Vec<u8>, String> { + let mut bytes = if text.is_empty() { + Vec::new() + } else { + encode_api_text(runtime, text) + }; + for encoded in encode_api_keys(runtime, keys)? { + bytes.extend_from_slice(&encoded); + } + Ok(bytes) +} + pub(super) fn detect_state_from_api( state: crate::api::schema::PaneAgentState, ) -> crate::detect::AgentState { diff --git a/src/app/mod.rs b/src/app/mod.rs index 578ea0cd..b6f2bbb8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4146,42 +4146,103 @@ mod tests { } #[tokio::test] - async fn focused_agent_start_records_previous_pane() { + async fn unavailable_agent_start_does_not_mutate_topology() { let mut app = test_app(); - let workspace = Workspace::test_new("agent-start-focus"); + let workspace = Workspace::test_new("agent-start-target"); let root = workspace.tabs[0].root_pane; app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; + let pane_id = app.pane_info(0, root).unwrap().pane_id; let response = app.handle_api_request(crate::api::schema::Request { - id: "req_agent_start_focus".into(), + id: "req_agent_start_target".into(), method: crate::api::schema::Method::AgentStart(crate::api::schema::AgentStartParams { name: "worker".into(), - cwd: None, - workspace_id: None, - tab_id: None, - split: Some(crate::api::schema::SplitDirection::Right), - focus: true, - argv: vec![exiting_test_command().into()], - env: Default::default(), + kind: "pi".into(), + pane_id, + args: Vec::new(), + timeout_ms: Some(1_000), }), }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); - assert_eq!(response["result"]["type"], "agent_started"); - assert_ne!(app.state.workspaces[0].focused_pane_id(), Some(root)); + assert_eq!(response["error"]["code"], "agent_pane_unavailable"); + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); + } - app.state.last_pane(); + #[tokio::test] + async fn failed_agent_start_input_rolls_back_and_can_retry() { + let mut app = test_app(); + let workspace = Workspace::test_new("agent-start-input-failure"); + let root = workspace.tabs[0].root_pane; + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + let pane_id = app.pane_info(0, root).unwrap().pane_id; + let terminal_id = app.state.workspaces[0].tabs[0].panes[&root] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_manual_label("shell".into()); + let (runtime, mut receiver) = + crate::terminal::TerminalRuntime::test_with_channel_capacity(80, 24, 1); + runtime + .try_send_bytes(bytes::Bytes::from_static(b"occupied")) + .unwrap(); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); + let request = || crate::api::schema::Request { + id: "req_agent_start_input".into(), + method: crate::api::schema::Method::AgentStart(crate::api::schema::AgentStartParams { + name: "worker".into(), + kind: "pi".into(), + pane_id: pane_id.clone(), + args: Vec::new(), + timeout_ms: Some(4_000), + }), + }; + let response = app.handle_api_request(request()); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["error"]["code"], "agent_start_input_failed"); + assert_eq!(app.state.terminals[&terminal_id].agent_name, None); + assert_eq!( + app.state.terminals[&terminal_id].manual_label.as_deref(), + Some("shell") + ); - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); - } + assert_eq!( + receiver.try_recv().unwrap(), + bytes::Bytes::from_static(b"occupied") + ); + let retry = app.handle_api_request(request()); + let retry: serde_json::Value = serde_json::from_str(&retry).unwrap(); + assert_eq!(retry["result"]["type"], "agent_started"); + assert_eq!( + app.state.terminals[&terminal_id].agent_name.as_deref(), + Some("worker") + ); + let rename = app.handle_api_request(crate::api::schema::Request { + id: "req_agent_rename_pending".into(), + method: crate::api::schema::Method::AgentRename( + crate::api::schema::AgentRenameParams { + target: pane_id, + name: Some("replacement".into()), + }, + ), + }); + let rename: serde_json::Value = serde_json::from_str(&rename).unwrap(); + assert_eq!(rename["error"]["code"], "agent_launch_pending"); + assert_eq!( + app.state.terminals[&terminal_id].agent_name.as_deref(), + Some("worker") + ); } #[test] diff --git a/src/app/runtime.rs b/src/app/runtime.rs index cad49372..97edc471 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -261,6 +261,21 @@ impl App { } if self + .state + .next_managed_agent_deadline() + .is_some_and(|deadline| now >= deadline) + { + let panes = self.state.reconcile_managed_agents_at(now); + if !panes.is_empty() { + for (ws_idx, pane_id) in panes { + self.emit_pane_updated(ws_idx, pane_id); + } + self.schedule_session_save(); + changed = true; + } + } + + if self .copy_feedback_deadline .is_some_and(|deadline| now >= deadline) { @@ -593,6 +608,7 @@ impl App { self.config_diagnostic_deadline, self.toast_deadline, self.state.next_pending_agent_notification_deadline(), + self.state.next_managed_agent_deadline(), self.copy_feedback_deadline, self.next_animation_tick, include_git_refresh |
