diff options
Diffstat (limited to 'src/app/api')
| -rw-r--r-- | src/app/api/agents.rs | 177 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 33 |
2 files changed, 140 insertions, 70 deletions
diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index d1fbf14c..ffb9cb63 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -1,8 +1,8 @@ use bytes::Bytes; use crate::api::schema::{ - AgentPromptParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget, - PaneReadResult, ReadFormat, ReadSource, ResponseResult, + AgentPromptParams, AgentRenameParams, AgentSendKeysParams, AgentStartParams, AgentTarget, + PaneReadResult, ResponseResult, }; use crate::app::App; @@ -59,7 +59,7 @@ impl App { 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) { + let resolved = match self.resolve_agent_target(¶ms.target) { Ok(resolved) => resolved, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), }; @@ -75,29 +75,12 @@ impl App { 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 { + let Some(expected_agent) = terminal.effective_known_agent() 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); }; @@ -118,13 +101,7 @@ impl App { 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, - }, - ) + encode_success(id, ResponseResult::AgentPrompted { agent }) } pub(super) fn handle_agent_read( @@ -132,7 +109,7 @@ impl App { id: String, params: crate::api::schema::AgentReadParams, ) -> String { - let resolved = match self.resolve_terminal_target(¶ms.target) { + let resolved = match self.resolve_agent_target(¶ms.target) { Ok(resolved) => resolved, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), }; @@ -140,21 +117,12 @@ impl App { else { return agent_not_found(id, ¶ms.target); }; - let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; - let text = match params.format { - ReadFormat::Text => match params.source { - ReadSource::Visible => pane.visible_text(), - ReadSource::Recent => pane.recent_text(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - ReadFormat::Ansi => match params.source { - ReadSource::Visible => pane.visible_ansi(), - ReadSource::Recent => pane.recent_ansi(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - }; + let text = crate::app::api_helpers::read_terminal_snapshot( + pane, + params.source, + params.format, + params.lines, + ); encode_success( id, @@ -178,7 +146,7 @@ impl App { } pub(super) fn handle_agent_explain(&mut self, id: String, target: AgentTarget) -> String { - let resolved = match self.resolve_terminal_target(&target.target) { + let resolved = match self.resolve_agent_target(&target.target) { Ok(resolved) => resolved, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), }; @@ -248,16 +216,46 @@ impl App { encode_success(id, ResponseResult::AgentExplain { explain: value }) } - pub(super) fn handle_agent_send(&mut self, id: String, params: AgentSendParams) -> String { - let resolved = match self.resolve_terminal_target(¶ms.target) { + pub(super) fn handle_agent_send_keys( + &mut self, + id: String, + params: AgentSendKeysParams, + ) -> String { + let resolved = match self.resolve_agent_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)) + else { + return agent_not_found(id, ¶ms.target); + }; + let Some(expected_agent) = self + .state + .terminals + .get(terminal_id) + .and_then(|terminal| terminal.effective_known_agent()) + else { + return agent_not_ready(id, ¶ms.target); + }; let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else { return agent_not_found(id, ¶ms.target); }; - if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) { - return encode_error(id, "agent_send_failed", err.to_string()); + if !super::super::agents::runtime_hosts_agent(runtime, expected_agent) { + return agent_not_ready(id, ¶ms.target); + } + let encoded = match super::super::api_helpers::encode_api_keys(runtime, ¶ms.keys) { + Ok(encoded) => encoded, + Err(key) => { + return encode_error(id, "invalid_key", format!("unsupported key {key}")); + } + }; + let bytes: Vec<u8> = encoded.into_iter().flatten().collect(); + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "agent_send_keys_failed", err.to_string()); } encode_success(id, ResponseResult::Ok {}) @@ -309,7 +307,7 @@ mod tests { } #[tokio::test] - async fn agent_prompt_brackets_opencode_text_and_falls_back_to_raw_submission() { + async fn agent_prompt_accepts_pane_ids_and_working_agents_atomically() { 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] @@ -317,7 +315,7 @@ mod tests { .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); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working); let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( 80, 24, 0, b"", 1, @@ -325,11 +323,13 @@ mod tests { runtime.test_process_pty_bytes(b"\x1b[?2004h"); app.state.insert_test_runtime(pane_id, runtime); + let public_pane_id = app.public_pane_id(0, pane_id).unwrap(); let response = app.handle_agent_prompt( "req".into(), AgentPromptParams { - target: "reviewer".into(), + target: public_pane_id, text: "A != B".into(), + wait: None, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); @@ -351,6 +351,7 @@ mod tests { AgentPromptParams { target: "reviewer".into(), text: "A != B".into(), + wait: None, }, ); let raw: SuccessResponse = serde_json::from_str(&raw).unwrap(); @@ -363,10 +364,48 @@ mod tests { AgentPromptParams { target: "opencode".into(), text: "wrong target".into(), + wait: None, }, ); let error: crate::api::schema::ErrorResponse = serde_json::from_str(&rejected).unwrap(); - assert_eq!(error.error.code, "agent_not_ready"); + assert_eq!(error.error.code, "agent_not_found"); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn agent_send_keys_validates_every_key_before_writing() { + 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::Pi), AgentState::Idle); + let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.state.insert_test_runtime(pane_id, runtime); + + let rejected = app.handle_agent_send_keys( + "req-invalid".into(), + AgentSendKeysParams { + target: "reviewer".into(), + keys: vec!["enter".into(), "not-a-key".into()], + }, + ); + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&rejected).unwrap(); + assert_eq!(error.error.code, "invalid_key"); + assert!(rx.try_recv().is_err()); + + let sent = app.handle_agent_send_keys( + "req-valid".into(), + AgentSendKeysParams { + target: "reviewer".into(), + keys: vec!["up".into(), "enter".into()], + }, + ); + let success: SuccessResponse = serde_json::from_str(&sent).unwrap(); + assert!(matches!(success.result, ResponseResult::Ok {})); + assert_eq!(rx.try_recv().unwrap(), Bytes::from_static(b"\x1b[A\r")); assert!(rx.try_recv().is_err()); } @@ -395,6 +434,7 @@ mod tests { AgentPromptParams { target: "reviewer".into(), text: "A != B".into(), + wait: None, }, ); let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); @@ -426,7 +466,7 @@ mod tests { let response = app.handle_agent_focus( "req".into(), AgentTarget { - target: "pi".into(), + target: app.public_pane_id(0, pane_id).unwrap(), }, ); @@ -436,4 +476,33 @@ mod tests { }; assert_eq!(agent.agent_status, AgentStatus::Idle); } + + #[test] + fn agent_rename_does_not_replace_the_pane_label() { + 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_manual_label("shell-pane".into()); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + let target = app.public_pane_id(0, pane_id).unwrap(); + + for name in [Some("reviewer".to_string()), None] { + let response = app.handle_agent_rename( + "req".into(), + AgentRenameParams { + target: target.clone(), + name, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert!(matches!(success.result, ResponseResult::AgentInfo { .. })); + assert_eq!( + app.state.terminals[&terminal_id].manual_label.as_deref(), + Some("shell-pane") + ); + } + } } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 6a5b41fe..483d73a5 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -11,7 +11,7 @@ use crate::api::schema::{ PaneReportMetadataParams, PaneResizeParams, PaneResizeReason, PaneResizeResult, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams, PaneZoomReason, - PaneZoomResult, ReadFormat, ReadSource, ResponseResult, + PaneZoomResult, ResponseResult, }; use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason}; use crate::app::App; @@ -1179,21 +1179,12 @@ impl App { else { return pane_not_found(id, ¶ms.pane_id); }; - let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; - let text = match params.format { - ReadFormat::Text => match params.source { - ReadSource::Visible => pane.visible_text(), - ReadSource::Recent => pane.recent_text(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - ReadFormat::Ansi => match params.source { - ReadSource::Visible => pane.visible_ansi(), - ReadSource::Recent => pane.recent_ansi(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - }; + let text = crate::app::api_helpers::read_terminal_snapshot( + pane, + params.source, + params.format, + params.lines, + ); encode_success( id, @@ -2591,6 +2582,11 @@ mod tests { .clone(); let target = app.state.workspaces[1].tabs[0].root_pane; seed_terminal_states(&mut app); + app.state + .terminals + .get_mut(&source_terminal) + .unwrap() + .set_detected_state(Some(Agent::Pi), AgentState::Idle); let previous_pane_id = app.public_pane_id(0, source).unwrap(); let previous_workspace_id = app.public_workspace_id(0); let target_workspace_id = app.public_workspace_id(1); @@ -2633,6 +2629,11 @@ mod tests { Some(&source_terminal) ); assert_eq!(app.parse_pane_id(&previous_pane_id), Some((0, source))); + assert!(matches!( + app.resolve_agent_target(&previous_pane_id), + Err(crate::app::terminal_targets::TerminalTargetError::NotFound { .. }) + )); + assert!(app.resolve_agent_target(&move_result.pane.pane_id).is_ok()); } #[test] |
