返回 CodeWhale
pending_requests.rs
根目录 / crates / tui / src / tui / pending_requests.rs
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
244 lines RUST