diff options
Diffstat (limited to 'src/app/api')
| -rw-r--r-- | src/app/api/agents.rs | 185 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 51 |
2 files changed, 214 insertions, 22 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(); 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); |
