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/api/agents.rs | |
| parent | 040956531f673c8fb7720037494ed4e61b123c6c (diff) | |
feat: harden named agent cli workflows
refs #1525
Diffstat (limited to 'src/app/api/agents.rs')
| -rw-r--r-- | src/app/api/agents.rs | 185 |
1 files changed, 178 insertions, 7 deletions
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(); |
