aboutsummaryrefslogtreecommitdiff
path: root/src/app/api/agents.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-17 23:55:08 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-17 23:59:56 +0300
commite0758c32118f2aa006db3d8fa4b41833fe6e7ead (patch)
tree2b29dd61ebf870db8d0ac95cc0b4515178c834c4 /src/app/api/agents.rs
parent040956531f673c8fb7720037494ed4e61b123c6c (diff)
feat: harden named agent cli workflows
refs #1525
Diffstat (limited to 'src/app/api/agents.rs')
-rw-r--r--src/app/api/agents.rs185
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(&params.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, &params.target);
+ };
+ let Some(terminal) = self.state.terminals.get(&terminal_id) else {
+ return agent_not_found(id, &params.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, &params.target);
+ };
+ if terminal.managed_agent_launch_pending() {
+ return agent_not_ready(id, &params.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, &params.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, &params.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, &params.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, &params.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();