| 1 | //! Child-agent requests waiting on the person (approvals C1). |
| 2 | //! |
| 3 | //! A child's approval card can be hidden (Esc), buried under another view, or |
| 4 | //! arrive while the parent is idle. This store is the one source for what is |
| 5 | //! still waiting: the footer row, `/agents` re-opening a card, and retiring a |
| 6 | //! card that was answered elsewhere all read it. It is keyed by approval id, |
| 7 | //! so parallel child calls each keep their own entry. |
| 8 | |
| 9 | use std::time::Instant; |
| 10 | |
| 11 | use crate::core::events::Event as EngineEvent; |
| 12 | use crate::tools::subagent::{AgentWorkerStatus, SubAgentManager}; |
| 13 | use crate::tui::app::App; |
| 14 | use crate::tui::approval::ApprovalOwner; |
| 15 | use codewhale_localization::MessageId; |
| 16 | |
| 17 | /// Everything needed to re-raise a hidden child card. |
| 18 | #[derive(Debug, Clone)] |
| 19 | pub(crate) struct PendingChildRequest { |
| 20 | pub agent_id: String, |
| 21 | pub tool_name: String, |
| 22 | pub description: String, |
| 23 | pub input: serde_json::Value, |
| 24 | pub approval_key: String, |
| 25 | pub approval_grouping_key: String, |
| 26 | pub intent_summary: Option<String>, |
| 27 | pub requested_at: Instant, |
| 28 | } |
| 29 | |
| 30 | /// The owning agent id of a child approval id |
| 31 | /// (`agent:{agent_id}:approval:{boot}:{n}`), or `None` for a parent id. |
| 32 | #[must_use] |
| 33 | pub(crate) fn child_agent_id(approval_id: &str) -> Option<&str> { |
| 34 | if !SubAgentManager::is_child_approval_id(approval_id) { |
| 35 | return None; |
| 36 | } |
| 37 | approval_id |
| 38 | .strip_prefix("agent:")? |
| 39 | .split_once(":approval:") |
| 40 | .map(|(agent_id, _)| agent_id) |
| 41 | .filter(|agent_id| !agent_id.is_empty()) |
| 42 | } |
| 43 | |
| 44 | /// Owner label for a child's card: stable agent label plus Fleet role. |
| 45 | pub(crate) fn owner_for(app: &mut App, agent_id: &str) -> ApprovalOwner { |
| 46 | let label = app.ensure_agent_label(agent_id); |
| 47 | let role = app |
| 48 | .subagent_cache |
| 49 | .iter() |
| 50 | .find(|agent| agent.agent_id == agent_id) |
| 51 | .map(|agent| agent.agent_type.as_str().to_string()); |
| 52 | ApprovalOwner { |
| 53 | agent_id: agent_id.to_string(), |
| 54 | label, |
| 55 | role, |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | /// Remember a child request that is now shown to the person. |
| 60 | pub(crate) fn record(app: &mut App, approval_id: &str, request: PendingChildRequest) { |
| 61 | app.pending_child_requests |
| 62 | .insert(approval_id.to_string(), request); |
| 63 | app.needs_redraw = true; |
| 64 | } |
| 65 | |
| 66 | /// The request was answered (here, on the web, or by the engine ending the |
| 67 | /// wait): forget it and retire its card wherever it sits in the stack. |
| 68 | pub(crate) fn resolve(app: &mut App, approval_id: &str) -> bool { |
| 69 | let known = app.pending_child_requests.remove(approval_id).is_some(); |
| 70 | let removed_card = app.view_stack.remove_approval_by_id(approval_id); |
| 71 | if known || removed_card { |
| 72 | app.needs_redraw = true; |
| 73 | } |
| 74 | known || removed_card |
| 75 | } |
| 76 | |
| 77 | /// The engine ended the wait (the agent's work ended, or the decision was |
| 78 | /// made elsewhere): retire the card and entry, and withdraw the web |
| 79 | /// mirror's copy so no surface keeps offering a decision nobody is waiting |
| 80 | /// on. A local decision uses [`resolve`] instead — the mirror is told the |
| 81 | /// decision itself. |
| 82 | pub(crate) fn retire(app: &mut App, approval_id: &str) -> bool { |
| 83 | let retired = resolve(app, approval_id); |
| 84 | let withdrawn = app.remote_control.withdraw_pending_approval(approval_id); |
| 85 | retired || withdrawn |
| 86 | } |
| 87 | |
| 88 | /// Approval retirement and child bookkeeping, run before any session |
| 89 | /// or idle filter (approvals M1, minor 4): |
| 90 | /// |
| 91 | /// - agent lifecycle events record which conversation owns each agent, so a |
| 92 | /// request from another conversation's child is never shown here; |
| 93 | /// - a progress event that names a child approval id and is no longer |
| 94 | /// waiting ends that wait by identity — answered anywhere, cancelled, |
| 95 | /// stopped — whatever conversation is active. The engine sends it without |
| 96 | /// back-pressure drops. |
| 97 | /// - a typed withdrawal retires any approval card and its web mirror by id. |
| 98 | /// |
| 99 | /// Returns `true` when the event only retires a request: nothing else should |
| 100 | /// process it. |
| 101 | pub(crate) fn observe_engine_event(app: &mut App, event: &EngineEvent) -> bool { |
| 102 | match event { |
| 103 | EngineEvent::ApprovalWithdrawn { id } => { |
| 104 | retire(app, id); |
| 105 | true |
| 106 | } |
| 107 | EngineEvent::AgentSpawned { |
| 108 | owner_session_id, |
| 109 | id, |
| 110 | .. |
| 111 | } |
| 112 | | EngineEvent::AgentComplete { |
| 113 | owner_session_id, |
| 114 | id, |
| 115 | .. |
| 116 | } => { |
| 117 | note_agent_session(app, id, owner_session_id); |
| 118 | false |
| 119 | } |
| 120 | EngineEvent::AgentProgress { |
| 121 | owner_session_id, |
| 122 | id, |
| 123 | activity, |
| 124 | .. |
| 125 | } => { |
| 126 | note_agent_session(app, id, owner_session_id); |
| 127 | match activity.approval_id.as_deref() { |
| 128 | Some(approval_id) |
| 129 | if activity.worker_status != AgentWorkerStatus::WaitingForUser => |
| 130 | { |
| 131 | retire(app, approval_id); |
| 132 | activity.worker_status.is_terminal() |
| 133 | } |
| 134 | _ => false, |
| 135 | } |
| 136 | } |
| 137 | _ => false, |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | fn note_agent_session(app: &mut App, agent_id: &str, owner_session_id: &str) { |
| 142 | if !owner_session_id.is_empty() { |
| 143 | app.child_agent_sessions |
| 144 | .insert(agent_id.to_string(), owner_session_id.to_string()); |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | /// Whether `approval_id` is a child request from an agent known to belong to |
| 149 | /// a conversation other than the active one. An agent this host has not seen |
| 150 | /// yet is not foreign: its request is shown rather than lost. |
| 151 | #[must_use] |
| 152 | pub(crate) fn is_foreign_child_request(app: &App, approval_id: &str) -> bool { |
| 153 | child_agent_id(approval_id) |
| 154 | .and_then(|agent_id| app.child_agent_sessions.get(agent_id)) |
| 155 | .is_some_and(|owner| app.current_session_id.as_deref() != Some(owner.as_str())) |
| 156 | } |
| 157 | |
| 158 | /// The agent finished or stopped: nothing it asked for is pending any more. |
| 159 | pub(crate) fn clear_for_agent(app: &mut App, agent_id: &str) { |
| 160 | let ids: Vec<String> = app |
| 161 | .pending_child_requests |
| 162 | .iter() |
| 163 | .filter(|(_, request)| request.agent_id == agent_id) |
| 164 | .map(|(id, _)| id.clone()) |
| 165 | .collect(); |
| 166 | for id in ids { |
| 167 | retire(app, &id); |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | /// A different conversation owns none of this one's pending requests |
| 172 | /// (session switch, `/new`, `/clear`). The engine side needs no answer from |
| 173 | /// here: every conversation boundary finalizes the old conversation's running |
| 174 | /// children (`finalize_session_close_for_session`), which withdraws their |
| 175 | /// pending requests and records each as `cancelled` — never as a denial. |
| 176 | /// A late request from such a child is answered `unavailable` on arrival |
| 177 | /// (`is_foreign_child_request`). |
| 178 | pub(crate) fn clear_all(app: &mut App) { |
| 179 | let ids: Vec<String> = app.pending_child_requests.keys().cloned().collect(); |
| 180 | for id in ids { |
| 181 | retire(app, &id); |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | /// One footer row per agent that is waiting on the person and whose card is |
| 186 | /// not the view on top: "Approval needed in {agent} — /agents". |
| 187 | pub(crate) fn footer_rows(app: &App) -> Vec<String> { |
| 188 | let top = app.view_stack.top_approval_id(); |
| 189 | let mut agents: Vec<&str> = Vec::new(); |
| 190 | for (id, request) in &app.pending_child_requests { |
| 191 | if top == Some(id.as_str()) || agents.contains(&request.agent_id.as_str()) { |
| 192 | continue; |
| 193 | } |
| 194 | agents.push(request.agent_id.as_str()); |
| 195 | } |
| 196 | agents |
| 197 | .into_iter() |
| 198 | .map(|agent_id| { |
| 199 | let label = app |
| 200 | .agent_label_map |
| 201 | .get(agent_id) |
| 202 | .cloned() |
| 203 | .unwrap_or_else(|| agent_id.to_string()); |
| 204 | app.tr(MessageId::PendingApprovalInAgent) |
| 205 | .replace("{agent}", &label) |
| 206 | }) |
| 207 | .collect() |
| 208 | } |
| 209 | |
| 210 | /// Re-raise the agent's hidden cards (oldest first) so the person can answer |
| 211 | /// them after `/agents` or "Go to agent". Cards already in the stack stay put. |
| 212 | pub(crate) fn repush_for_agent( |
| 213 | app: &mut App, |
| 214 | agent_id: &str, |
| 215 | default_selection: crate::config::ApprovalDefaultSelection, |
| 216 | timeout: Option<std::time::Duration>, |
| 217 | ) -> bool { |
| 218 | let mut waiting: Vec<(String, PendingChildRequest)> = app |
| 219 | .pending_child_requests |
| 220 | .iter() |
| 221 | .filter(|(id, request)| { |
| 222 | request.agent_id == agent_id && !app.view_stack.contains_approval_id(id) |
| 223 | }) |
| 224 | .map(|(id, request)| (id.clone(), request.clone())) |
| 225 | .collect(); |
| 226 | waiting.sort_by_key(|(_, request)| request.requested_at); |
| 227 | let pushed = !waiting.is_empty(); |
| 228 | for (id, request) in waiting.into_iter().rev() { |
| 229 | crate::tui::ui::push_approval_request_view( |
| 230 | app, |
| 231 | &id, |
| 232 | &request.tool_name, |
| 233 | &request.description, |
| 234 | &request.input, |
| 235 | &request.approval_key, |
| 236 | &request.approval_grouping_key, |
| 237 | request.intent_summary.as_deref(), |
| 238 | default_selection, |
| 239 | timeout, |
| 240 | ); |
| 241 | } |
| 242 | pushed |
| 243 | } |
| 244 |