aboutsummaryrefslogtreecommitdiff
path: root/src/api/wait.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/wait.rs')
-rw-r--r--src/api/wait.rs430
1 files changed, 430 insertions, 0 deletions
diff --git a/src/api/wait.rs b/src/api/wait.rs
index d556fa96..2f8da2e3 100644
--- a/src/api/wait.rs
+++ b/src/api/wait.rs
@@ -126,6 +126,404 @@ pub(super) fn wait_for_output(
}
}
+pub(super) fn wait_for_agent(
+ request_id: String,
+ params: crate::api::schema::AgentWaitParams,
+ stream: &mut LocalStream,
+ api_tx: &ApiRequestSender,
+ event_hub: &EventHub,
+ running: &Arc<AtomicBool>,
+) -> std::io::Result<Option<String>> {
+ let last_event_sequence = event_hub.current_sequence();
+ let initial = match agent_get(&request_id, &params.target, api_tx) {
+ Ok(agent) => agent,
+ Err(response) => {
+ return serde_json::to_string(&response)
+ .map(Some)
+ .map_err(std::io::Error::other);
+ }
+ };
+ let until = agent_wait_statuses(params.until);
+ if agent_wait_matches(&initial, &until, None) {
+ return agent_wait_success(request_id, initial).map(Some);
+ }
+
+ match wait_for_resolved_agent(
+ request_id.clone(),
+ ResolvedAgentWait {
+ target: params.target,
+ until,
+ timeout_ms: params.timeout_ms,
+ initial,
+ last_event_sequence,
+ after_state_change_seq: None,
+ accept_transient_status: true,
+ },
+ stream,
+ api_tx,
+ event_hub,
+ running,
+ )? {
+ Some(AgentWaitOutcome::Matched(agent)) => agent_wait_success(request_id, *agent).map(Some),
+ Some(AgentWaitOutcome::Response(response)) => Ok(Some(response)),
+ None => Ok(None),
+ }
+}
+
+pub(super) fn prompt_agent(
+ request_id: String,
+ params: crate::api::schema::AgentPromptParams,
+ stream: &mut LocalStream,
+ api_tx: &ApiRequestSender,
+ event_hub: &EventHub,
+ running: &Arc<AtomicBool>,
+) -> std::io::Result<Option<String>> {
+ let Some(wait) = params.wait.clone() else {
+ return Ok(Some(dispatch_to_app_with_timeout(
+ Request {
+ id: request_id,
+ method: Method::AgentPrompt(params),
+ },
+ api_tx,
+ None,
+ )));
+ };
+
+ let last_event_sequence = event_hub.current_sequence();
+ let before_prompt = match agent_get(&request_id, &params.target, api_tx) {
+ Ok(agent) => agent,
+ Err(response) => {
+ return serde_json::to_string(&response)
+ .map(Some)
+ .map_err(std::io::Error::other);
+ }
+ };
+ let target = params.target.clone();
+ let prompt_response = dispatch_to_app_with_timeout(
+ Request {
+ id: request_id.clone(),
+ method: Method::AgentPrompt(params),
+ },
+ api_tx,
+ None,
+ );
+ let Ok(prompted) = agent_from_response(&request_id, &prompt_response) else {
+ return Ok(Some(prompt_response));
+ };
+ if !agent_wait_identity_matches(
+ &prompted,
+ &before_prompt.terminal_id,
+ before_prompt.name.as_deref().filter(|name| *name == target),
+ before_prompt.agent.as_deref(),
+ ) {
+ return agent_wait_not_running(request_id).map(Some);
+ }
+
+ // The prompt response is produced on the app thread after input is queued.
+ // Requiring a later sequence prevents pre-prompt completion from satisfying this wait.
+ let prompt_state_change_seq = prompted.state_change_seq;
+ let Some(outcome) = wait_for_resolved_agent(
+ request_id.clone(),
+ ResolvedAgentWait {
+ target,
+ until: agent_wait_statuses(wait.until),
+ timeout_ms: wait.timeout_ms,
+ initial: prompted,
+ last_event_sequence,
+ after_state_change_seq: Some(prompt_state_change_seq),
+ accept_transient_status: false,
+ },
+ stream,
+ api_tx,
+ event_hub,
+ running,
+ )?
+ else {
+ return Ok(None);
+ };
+ let agent = match outcome {
+ AgentWaitOutcome::Matched(agent) => *agent,
+ AgentWaitOutcome::Response(response) => return Ok(Some(response)),
+ };
+ serde_json::to_string(&SuccessResponse {
+ id: request_id,
+ result: ResponseResult::AgentPrompted { agent },
+ })
+ .map(Some)
+ .map_err(std::io::Error::other)
+}
+
+struct ResolvedAgentWait {
+ target: String,
+ until: Vec<crate::api::schema::AgentStatus>,
+ timeout_ms: Option<u64>,
+ initial: crate::api::schema::AgentInfo,
+ last_event_sequence: u64,
+ after_state_change_seq: Option<u64>,
+ accept_transient_status: bool,
+}
+
+enum AgentWaitOutcome {
+ Matched(Box<crate::api::schema::AgentInfo>),
+ Response(String),
+}
+
+fn wait_for_resolved_agent(
+ request_id: String,
+ wait: ResolvedAgentWait,
+ stream: &mut LocalStream,
+ api_tx: &ApiRequestSender,
+ event_hub: &EventHub,
+ running: &Arc<AtomicBool>,
+) -> std::io::Result<Option<AgentWaitOutcome>> {
+ let deadline = wait
+ .timeout_ms
+ .map(|ms| std::time::Instant::now() + std::time::Duration::from_millis(ms));
+ let expected_terminal_id = wait.initial.terminal_id.clone();
+ let expected_name = wait
+ .initial
+ .name
+ .as_ref()
+ .filter(|name| name.as_str() == wait.target)
+ .cloned();
+ let expected_agent = wait.initial.agent.clone();
+ let pane_id = wait.initial.pane_id.clone();
+ let mut last_event_sequence = wait.last_event_sequence;
+
+ loop {
+ if should_stop_connection(stream, running)? {
+ return Ok(None);
+ }
+
+ let mut should_probe = false;
+ let mut matched_event_status = None;
+ for (sequence, event) in event_hub.events_after(last_event_sequence) {
+ last_event_sequence = sequence;
+ match event.data {
+ EventData::PaneAgentDetected {
+ pane_id: event_pane,
+ agent,
+ released,
+ final_status,
+ ..
+ } if event_pane == pane_id => {
+ if released {
+ if let Some(status) = final_status
+ .filter(|status| wait.until.contains(status))
+ .or(matched_event_status)
+ {
+ let mut matched = wait.initial.clone();
+ matched.agent_status = status;
+ return Ok(Some(AgentWaitOutcome::Matched(Box::new(matched))));
+ }
+ return agent_wait_not_running(request_id)
+ .map(AgentWaitOutcome::Response)
+ .map(Some);
+ }
+ if agent.is_some() && expected_agent.is_some() && agent != expected_agent {
+ return agent_wait_not_running(request_id)
+ .map(AgentWaitOutcome::Response)
+ .map(Some);
+ }
+ should_probe = true;
+ }
+ EventData::PaneAgentStatusChanged {
+ pane_id: event_pane,
+ agent_status,
+ ..
+ } if event_pane == pane_id => {
+ if wait.accept_transient_status && wait.until.contains(&agent_status) {
+ matched_event_status = Some(agent_status);
+ }
+ should_probe = true;
+ }
+ EventData::PaneUpdated { pane } if pane.pane_id == pane_id => should_probe = true,
+ EventData::PaneMoved {
+ previous_pane_id, ..
+ } if previous_pane_id == pane_id => {
+ return agent_wait_not_running(request_id)
+ .map(AgentWaitOutcome::Response)
+ .map(Some);
+ }
+ EventData::PaneClosed {
+ pane_id: event_pane,
+ ..
+ }
+ | EventData::PaneExited {
+ pane_id: event_pane,
+ ..
+ } if event_pane == pane_id => {
+ return agent_wait_not_running(request_id)
+ .map(AgentWaitOutcome::Response)
+ .map(Some);
+ }
+ _ => {}
+ }
+ }
+
+ if should_probe {
+ let current = match agent_get(&request_id, &wait.target, api_tx) {
+ Ok(agent) => agent,
+ Err(response) => {
+ return agent_wait_probe_error(response)
+ .map(AgentWaitOutcome::Response)
+ .map(Some);
+ }
+ };
+ if !agent_wait_identity_matches(
+ &current,
+ &expected_terminal_id,
+ expected_name.as_deref(),
+ expected_agent.as_deref(),
+ ) {
+ return agent_wait_not_running(request_id)
+ .map(AgentWaitOutcome::Response)
+ .map(Some);
+ }
+ if let Some(status) = matched_event_status {
+ let mut matched = current;
+ matched.agent_status = status;
+ return Ok(Some(AgentWaitOutcome::Matched(Box::new(matched))));
+ }
+ if agent_wait_matches(&current, &wait.until, wait.after_state_change_seq) {
+ return Ok(Some(AgentWaitOutcome::Matched(Box::new(current))));
+ }
+ }
+
+ if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) {
+ return serde_json::to_string(&ErrorResponse {
+ id: request_id,
+ error: ErrorBody {
+ code: "timeout".into(),
+ message: "timed out waiting for agent status".into(),
+ },
+ })
+ .map(AgentWaitOutcome::Response)
+ .map(Some)
+ .map_err(std::io::Error::other);
+ }
+ std::thread::sleep(CONNECTION_POLL_INTERVAL);
+ }
+}
+
+fn agent_wait_statuses(
+ until: Vec<crate::api::schema::AgentStatus>,
+) -> Vec<crate::api::schema::AgentStatus> {
+ if until.is_empty() {
+ vec![
+ crate::api::schema::AgentStatus::Idle,
+ crate::api::schema::AgentStatus::Done,
+ crate::api::schema::AgentStatus::Blocked,
+ ]
+ } else {
+ until
+ }
+}
+
+fn agent_wait_identity_matches(
+ agent: &crate::api::schema::AgentInfo,
+ expected_terminal_id: &str,
+ expected_name: Option<&str>,
+ expected_agent: Option<&str>,
+) -> bool {
+ agent.terminal_id == expected_terminal_id
+ && expected_name.is_none_or(|name| agent.name.as_deref() == Some(name))
+ && match (expected_agent, agent.agent.as_deref()) {
+ (Some(expected), Some(current)) => expected == current,
+ (Some(_), None) => agent.name.is_some(),
+ (None, _) => true,
+ }
+}
+
+fn agent_wait_matches(
+ agent: &crate::api::schema::AgentInfo,
+ until: &[crate::api::schema::AgentStatus],
+ after_state_change_seq: Option<u64>,
+) -> bool {
+ until.contains(&agent.agent_status)
+ && after_state_change_seq.is_none_or(|baseline| agent.state_change_seq > baseline)
+}
+
+fn agent_get(
+ request_id: &str,
+ target: &str,
+ api_tx: &ApiRequestSender,
+) -> Result<crate::api::schema::AgentInfo, ErrorResponse> {
+ let response = dispatch_to_app_with_timeout(
+ Request {
+ id: format!("{request_id}:agent"),
+ method: Method::AgentGet(crate::api::schema::AgentTarget {
+ target: target.to_string(),
+ }),
+ },
+ api_tx,
+ Some(APP_RESPONSE_TIMEOUT),
+ );
+ agent_from_response(request_id, &response)
+}
+
+fn agent_from_response(
+ request_id: &str,
+ response: &str,
+) -> Result<crate::api::schema::AgentInfo, ErrorResponse> {
+ let value: serde_json::Value = serde_json::from_str(response).map_err(|_| ErrorResponse {
+ id: request_id.into(),
+ error: ErrorBody {
+ code: "internal_error".into(),
+ message: "failed to decode agent response".into(),
+ },
+ })?;
+ if value.get("error").is_some() {
+ let error = serde_json::from_value(value["error"].clone()).map_err(|_| ErrorResponse {
+ id: request_id.into(),
+ error: ErrorBody {
+ code: "internal_error".into(),
+ message: "failed to decode agent error".into(),
+ },
+ })?;
+ return Err(ErrorResponse {
+ id: request_id.into(),
+ error,
+ });
+ }
+ serde_json::from_value(value["result"]["agent"].clone()).map_err(|_| ErrorResponse {
+ id: request_id.into(),
+ error: ErrorBody {
+ code: "internal_error".into(),
+ message: "failed to decode agent result".into(),
+ },
+ })
+}
+
+fn agent_wait_success(
+ request_id: String,
+ agent: crate::api::schema::AgentInfo,
+) -> std::io::Result<String> {
+ serde_json::to_string(&SuccessResponse {
+ id: request_id,
+ result: ResponseResult::AgentInfo { agent },
+ })
+ .map_err(std::io::Error::other)
+}
+
+fn agent_wait_not_running(request_id: String) -> std::io::Result<String> {
+ serde_json::to_string(&ErrorResponse {
+ id: request_id,
+ error: ErrorBody {
+ code: "agent_not_running".into(),
+ message: "agent is no longer running in the target pane".into(),
+ },
+ })
+ .map_err(std::io::Error::other)
+}
+
+fn agent_wait_probe_error(response: ErrorResponse) -> std::io::Result<String> {
+ if response.error.code == "agent_not_found" {
+ return agent_wait_not_running(response.id);
+ }
+ serde_json::to_string(&response).map_err(std::io::Error::other)
+}
+
pub(super) fn wait_for_event(
request_id: String,
params: EventsWaitParams,
@@ -246,3 +644,35 @@ fn wait_matched_response(request_id: &str, event: serde_json::Value) -> String {
})
.unwrap()
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn agent_wait_probe_only_translates_agent_disappearance() {
+ let disappeared = agent_wait_probe_error(ErrorResponse {
+ id: "wait".into(),
+ error: ErrorBody {
+ code: "agent_not_found".into(),
+ message: "missing".into(),
+ },
+ })
+ .unwrap();
+ let disappeared: ErrorResponse = serde_json::from_str(&disappeared).unwrap();
+ assert_eq!(disappeared.id, "wait");
+ assert_eq!(disappeared.error.code, "agent_not_running");
+
+ let unavailable = agent_wait_probe_error(ErrorResponse {
+ id: "wait".into(),
+ error: ErrorBody {
+ code: "server_unavailable".into(),
+ message: "timed out waiting for app response".into(),
+ },
+ })
+ .unwrap();
+ let unavailable: ErrorResponse = serde_json::from_str(&unavailable).unwrap();
+ assert_eq!(unavailable.id, "wait");
+ assert_eq!(unavailable.error.code, "server_unavailable");
+ }
+}