| 1 | //! Safe, bounded Agent Details projection (#2889). |
| 2 | //! |
| 3 | //! Since the v0.9.7 "one agent, one destination" inversion, activating an |
| 4 | //! agent row opens the agent's transcript surface directly |
| 5 | //! (`crate::tui::agent_transcript`); this bounded projection is the secondary |
| 6 | //! action, reached from the transcript via the same Alt+V chord that opens |
| 7 | //! the transcript from here. |
| 8 | |
| 9 | use std::path::{Component, Path}; |
| 10 | |
| 11 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; |
| 12 | use ratatui::{buffer::Buffer, layout::Rect}; |
| 13 | |
| 14 | use crate::tools::subagent::{SubAgentResult, SubAgentStatus, localized_whale_display_names}; |
| 15 | use crate::tui::app::{ |
| 16 | AgentCurrentActivityStatus, AgentProgressMeta, App, bound_agent_activity_text, |
| 17 | }; |
| 18 | use crate::tui::pager::PagerView; |
| 19 | use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; |
| 20 | |
| 21 | pub(crate) struct AgentDetailsProjection { |
| 22 | pub(crate) title: String, |
| 23 | pub(crate) body: String, |
| 24 | pub(crate) transcript_available: bool, |
| 25 | } |
| 26 | |
| 27 | /// Pager-backed details view with a distinct close receipt and an explicit |
| 28 | /// exact-transcript action. |
| 29 | pub(crate) struct AgentDetailsView { |
| 30 | pager: PagerView, |
| 31 | agent_id: String, |
| 32 | transcript_available: bool, |
| 33 | } |
| 34 | |
| 35 | impl AgentDetailsView { |
| 36 | fn new(projection: AgentDetailsProjection, agent_id: impl Into<String>, width: u16) -> Self { |
| 37 | let agent_id = agent_id.into(); |
| 38 | let pager = |
| 39 | PagerView::from_text(projection.title, &projection.body, width.saturating_sub(2)) |
| 40 | .with_copy_text(projection.body); |
| 41 | Self { |
| 42 | pager, |
| 43 | agent_id, |
| 44 | transcript_available: projection.transcript_available, |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | #[cfg(test)] |
| 49 | fn body_text(&self) -> String { |
| 50 | self.pager.body_text() |
| 51 | } |
| 52 | |
| 53 | #[cfg(test)] |
| 54 | fn title(&self) -> &str { |
| 55 | self.pager.title() |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | impl ModalView for AgentDetailsView { |
| 60 | fn kind(&self) -> ModalKind { |
| 61 | ModalKind::Pager |
| 62 | } |
| 63 | |
| 64 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 65 | if matches!(key.code, KeyCode::Char('v' | 'V')) |
| 66 | && key.modifiers.contains(KeyModifiers::ALT) |
| 67 | && self.transcript_available |
| 68 | { |
| 69 | return ViewAction::Emit(ViewEvent::OpenAgentTranscript { |
| 70 | agent_id: self.agent_id.clone(), |
| 71 | }); |
| 72 | } |
| 73 | if matches!(key.code, KeyCode::Esc | KeyCode::Left) |
| 74 | || (key.code == KeyCode::Char('q') && key.modifiers.is_empty()) |
| 75 | { |
| 76 | return ViewAction::EmitAndClose(ViewEvent::AgentDetailsClosed { |
| 77 | agent_id: self.agent_id.clone(), |
| 78 | }); |
| 79 | } |
| 80 | self.pager.handle_key(key) |
| 81 | } |
| 82 | |
| 83 | fn handle_paste(&mut self, text: &str) -> bool { |
| 84 | self.pager.handle_paste(text) |
| 85 | } |
| 86 | |
| 87 | fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { |
| 88 | self.pager.handle_mouse(mouse) |
| 89 | } |
| 90 | |
| 91 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 92 | self.pager.render(area, buf); |
| 93 | } |
| 94 | |
| 95 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 96 | self |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | pub(crate) fn open_agent_details(app: &mut App, agent_id: &str) -> bool { |
| 101 | let Some(projection) = project_agent_details(app, agent_id) else { |
| 102 | return false; |
| 103 | }; |
| 104 | let width = app |
| 105 | .viewport |
| 106 | .last_transcript_area |
| 107 | .map(|area| area.width) |
| 108 | .unwrap_or(80); |
| 109 | app.view_stack |
| 110 | .push(AgentDetailsView::new(projection, agent_id, width)); |
| 111 | true |
| 112 | } |
| 113 | |
| 114 | pub(crate) fn safe_agent_display_name(app: &App, agent_id: &str) -> String { |
| 115 | let generated = localized_whale_display_names( |
| 116 | app.subagent_cache |
| 117 | .iter() |
| 118 | .map(|agent| (agent.agent_id.as_str(), agent.nickname.as_deref())), |
| 119 | app.ui_locale.tag(), |
| 120 | ); |
| 121 | app.subagent_cache |
| 122 | .iter() |
| 123 | .find(|agent| agent.agent_id == agent_id) |
| 124 | .and_then(crate::tui::sidebar::dispatched_agent_name) |
| 125 | .map(str::to_string) |
| 126 | .or_else(|| generated.get(agent_id).cloned()) |
| 127 | .or_else(|| app.agent_label_map.get(agent_id).cloned()) |
| 128 | .and_then(|name| safe_child_value(app, &name)) |
| 129 | .unwrap_or_else(|| "Agent".to_string()) |
| 130 | } |
| 131 | |
| 132 | pub(crate) fn project_agent_details(app: &App, agent_id: &str) -> Option<AgentDetailsProjection> { |
| 133 | // The Agents sidebar is the primary worker surface. Consume its exact row |
| 134 | // projection so status precedence, model, elapsed time, and steps cannot |
| 135 | // diverge between the row and the popup opened from it. |
| 136 | let surface_row = crate::tui::sidebar::sidebar_agent_rows(app) |
| 137 | .into_iter() |
| 138 | .find(|row| row.id == agent_id)?; |
| 139 | let agent = app |
| 140 | .subagent_cache |
| 141 | .iter() |
| 142 | .find(|agent| agent.agent_id == agent_id); |
| 143 | let meta = app.agent_progress_meta.get(agent_id); |
| 144 | |
| 145 | let display_name = safe_child_value(app, &surface_row.name).unwrap_or_else(|| "Agent".into()); |
| 146 | let mut lines = Vec::new(); |
| 147 | |
| 148 | if let Some(agent) = agent { |
| 149 | push_safe_line(app, &mut lines, "Assignment", &agent.assignment.objective); |
| 150 | |
| 151 | // The child's own route receipt is the authoritative resolved truth |
| 152 | // for a fleet-dispatched child; `assignment.role` is only the caller's |
| 153 | // advisory token and may be absent on a `type`-only dispatch. |
| 154 | let route = agent.child_route.as_ref(); |
| 155 | let role = route |
| 156 | .map(|route| route.canonical_role.trim()) |
| 157 | .filter(|role| !role.is_empty()) |
| 158 | .or(agent.assignment.role.as_deref()) |
| 159 | .map(str::trim) |
| 160 | .filter(|role| !role.is_empty()); |
| 161 | if let Some(role) = role { |
| 162 | push_safe_line(app, &mut lines, "Role", role); |
| 163 | } |
| 164 | // Profile is the member that actually resolved. A caller alias/model |
| 165 | // label is useful evidence but must not be presented as who ran. |
| 166 | let profile = route |
| 167 | .and_then(|route| route.resolved_profile_id.as_deref()) |
| 168 | .map(str::trim) |
| 169 | .filter(|profile| !profile.is_empty()) |
| 170 | .or_else(|| { |
| 171 | route |
| 172 | .and_then(|route| route.requested_profile.as_deref()) |
| 173 | .map(str::trim) |
| 174 | .filter(|profile| !profile.is_empty()) |
| 175 | }) |
| 176 | .unwrap_or_else(|| agent.agent_type.as_str()); |
| 177 | push_safe_line(app, &mut lines, "Profile", profile); |
| 178 | if let Some(requested) = route |
| 179 | .and_then(|route| route.requested_profile.as_deref()) |
| 180 | .map(str::trim) |
| 181 | .filter(|requested| !requested.is_empty() && !requested.eq_ignore_ascii_case(profile)) |
| 182 | { |
| 183 | push_safe_line(app, &mut lines, "Requested as", requested); |
| 184 | } |
| 185 | push_safe_line(app, &mut lines, "Type", agent.agent_type.as_str()); |
| 186 | lines.push(format!("Parent: {}", safe_parent_name(app, agent))); |
| 187 | } else { |
| 188 | lines.push(format!("Parent: {}", safe_parent_from_meta(app, meta))); |
| 189 | } |
| 190 | |
| 191 | let mut state = vec![surface_row.status.clone()]; |
| 192 | if let Some(duration_ms) = surface_row.duration_ms { |
| 193 | state.push(format!( |
| 194 | "elapsed {}", |
| 195 | crate::elapsed::format_elapsed_ms(duration_ms) |
| 196 | )); |
| 197 | } |
| 198 | state.push(format!( |
| 199 | "{} {}", |
| 200 | surface_row.steps_taken, |
| 201 | if surface_row.steps_taken == 1 { |
| 202 | "step" |
| 203 | } else { |
| 204 | "steps" |
| 205 | } |
| 206 | )); |
| 207 | lines.push(format!("State: {}", state.join(" · "))); |
| 208 | |
| 209 | if let Some(meta) = meta |
| 210 | && let Some(provider) = meta.resolved_provider.as_deref() |
| 211 | { |
| 212 | push_safe_line(app, &mut lines, "Provider", provider); |
| 213 | } else if let Some(route) = agent.and_then(|agent| agent.child_route.as_ref()) |
| 214 | && !route.provider_id.trim().is_empty() |
| 215 | { |
| 216 | push_safe_line(app, &mut lines, "Provider", &route.provider_id); |
| 217 | } |
| 218 | // Model truth: the resolved route model wins, then the Work-row receipt |
| 219 | // (which itself carries `agent.model` or usage-envelope route evidence), |
| 220 | // then the child's spawn model. |
| 221 | let model = agent |
| 222 | .and_then(|agent| agent.child_route.as_ref()) |
| 223 | .map(|route| route.model_id.clone()) |
| 224 | .filter(|model| !model.trim().is_empty()) |
| 225 | .or_else(|| surface_row.model.clone()) |
| 226 | .or_else(|| { |
| 227 | agent |
| 228 | .map(|agent| agent.model.clone()) |
| 229 | .filter(|model| !model.trim().is_empty()) |
| 230 | }) |
| 231 | .or_else(|| meta.and_then(|meta| meta.resolved_model.clone())); |
| 232 | if let Some(model) = model { |
| 233 | push_safe_line(app, &mut lines, "Model", &model); |
| 234 | } |
| 235 | |
| 236 | if let Some(agent) = agent { |
| 237 | if let Some(workspace) = agent.workspace.as_deref() |
| 238 | && let Some(workspace) = safe_workspace(app, workspace) |
| 239 | { |
| 240 | lines.push(format!("Workspace: {workspace}")); |
| 241 | } |
| 242 | if let Some(branch) = agent.git_branch.as_deref() { |
| 243 | push_safe_line(app, &mut lines, "Branch", branch); |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | if let Some(activity) = meta.and_then(|meta| meta.current_activity.as_ref()) |
| 248 | && !matches!( |
| 249 | activity.status, |
| 250 | AgentCurrentActivityStatus::Done |
| 251 | | AgentCurrentActivityStatus::Failed |
| 252 | | AgentCurrentActivityStatus::Canceled |
| 253 | | AgentCurrentActivityStatus::Interrupted |
| 254 | | AgentCurrentActivityStatus::Waiting |
| 255 | ) |
| 256 | { |
| 257 | let mut current = vec![activity_status_label(activity.status, app.ui_locale).into_owned()]; |
| 258 | if let Some(tool) = activity |
| 259 | .current_tool |
| 260 | .as_deref() |
| 261 | .and_then(|tool| safe_child_value(app, tool)) |
| 262 | { |
| 263 | current.push(tool); |
| 264 | } |
| 265 | if let Some(step) = activity.step { |
| 266 | current.push(format!("step {step}")); |
| 267 | } |
| 268 | if let Some(detail) = activity |
| 269 | .detail |
| 270 | .as_deref() |
| 271 | .and_then(|detail| safe_child_value(app, detail)) |
| 272 | && !detail.starts_with("started ") |
| 273 | && !current.iter().any(|part| part == &detail) |
| 274 | { |
| 275 | current.push(detail); |
| 276 | } |
| 277 | lines.push(format!("Current: {}", current.join(" · "))); |
| 278 | } |
| 279 | |
| 280 | if let Some(meta) = meta { |
| 281 | for action in meta.recent_actions.iter().rev().take(3).rev() { |
| 282 | if let Some(tool) = safe_child_value(app, &action.tool) { |
| 283 | lines.push(format!( |
| 284 | "Recent: {} {tool} · step {}", |
| 285 | if action.ok { "✓" } else { "!" }, |
| 286 | action.step |
| 287 | )); |
| 288 | } |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | if let Some(question) = pending_question(agent, meta) |
| 293 | && let Some(question) = safe_child_value(app, question) |
| 294 | { |
| 295 | lines.push(format!("Pending question: {question}")); |
| 296 | } |
| 297 | if let Some(blocker) = blocker(agent, meta) |
| 298 | && let Some(blocker) = safe_child_value(app, blocker) |
| 299 | { |
| 300 | lines.push(format!("Blocker: {blocker}")); |
| 301 | } |
| 302 | if let Some(summary) = terminal_summary(agent, meta) |
| 303 | && let Some(summary) = safe_child_value(app, summary) |
| 304 | { |
| 305 | lines.push(format!("Summary: {summary}")); |
| 306 | } |
| 307 | |
| 308 | let transcript_available = |
| 309 | crate::tui::mouse_ui::agent_transcript_evidence_available(app, agent_id); |
| 310 | if transcript_available { |
| 311 | // Platform glyph via display_chord (⌥V on macOS, Alt+V elsewhere) — |
| 312 | // never the dual "Alt/⌥V" spelling, and cap:verb not a sentence. |
| 313 | let chord = crate::tui::shell_key_routing::tool_details_chord(); |
| 314 | lines.push(format!("Exact evidence: available · {chord}:transcript")); |
| 315 | } else { |
| 316 | lines.push("Exact evidence: unavailable".to_string()); |
| 317 | } |
| 318 | |
| 319 | Some(AgentDetailsProjection { |
| 320 | title: format!("Agent Details — {display_name}"), |
| 321 | body: lines.join("\n"), |
| 322 | transcript_available, |
| 323 | }) |
| 324 | } |
| 325 | |
| 326 | fn push_safe_line(app: &App, lines: &mut Vec<String>, label: &str, value: &str) { |
| 327 | if let Some(value) = safe_child_value(app, value) { |
| 328 | lines.push(format!("{label}: {value}")); |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | fn safe_child_value(app: &App, value: &str) -> Option<String> { |
| 333 | let bounded = bound_agent_activity_text(value); |
| 334 | let scrubbed = scrub_raw_agent_ids(app, &bounded); |
| 335 | let trimmed = scrubbed.trim(); |
| 336 | if trimmed.is_empty() |
| 337 | || matches!( |
| 338 | trimmed.to_ascii_lowercase().as_str(), |
| 339 | "none" | "(none)" | "n/a" | "unknown" | "not set" | "not available" | "-" |
| 340 | ) |
| 341 | { |
| 342 | None |
| 343 | } else { |
| 344 | Some(trimmed.to_string()) |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | fn scrub_raw_agent_ids(app: &App, value: &str) -> String { |
| 349 | let mut scrubbed = value.to_string(); |
| 350 | let mut ids: Vec<&str> = app |
| 351 | .subagent_cache |
| 352 | .iter() |
| 353 | .map(|agent| agent.agent_id.as_str()) |
| 354 | .chain( |
| 355 | app.subagent_cache |
| 356 | .iter() |
| 357 | .filter_map(|agent| agent.parent_run_id.as_deref()), |
| 358 | ) |
| 359 | .chain(app.agent_progress_meta.keys().map(String::as_str)) |
| 360 | .chain( |
| 361 | app.agent_progress_meta |
| 362 | .values() |
| 363 | .filter_map(|meta| meta.parent_run_id.as_deref()), |
| 364 | ) |
| 365 | .chain(app.agent_progress.keys().map(String::as_str)) |
| 366 | .chain(app.agent_label_map.keys().map(String::as_str)) |
| 367 | .collect(); |
| 368 | ids.sort_unstable_by_key(|id| std::cmp::Reverse(id.len())); |
| 369 | ids.dedup(); |
| 370 | for id in ids { |
| 371 | if !id.is_empty() { |
| 372 | scrubbed = scrubbed.replace(id, "agent"); |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | let mut output = String::with_capacity(scrubbed.len()); |
| 377 | let mut token = String::new(); |
| 378 | let flush = |token: &mut String, output: &mut String| { |
| 379 | if token.starts_with("agent_") |
| 380 | || token.starts_with("agent-") |
| 381 | || token.starts_with("worker:agent_") |
| 382 | || token.starts_with("worker:agent-") |
| 383 | { |
| 384 | output.push_str("agent"); |
| 385 | } else { |
| 386 | output.push_str(token); |
| 387 | } |
| 388 | token.clear(); |
| 389 | }; |
| 390 | for ch in scrubbed.chars() { |
| 391 | if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':') { |
| 392 | token.push(ch); |
| 393 | } else { |
| 394 | flush(&mut token, &mut output); |
| 395 | output.push(ch); |
| 396 | } |
| 397 | } |
| 398 | flush(&mut token, &mut output); |
| 399 | output |
| 400 | } |
| 401 | |
| 402 | fn safe_parent_name(app: &App, agent: &SubAgentResult) -> String { |
| 403 | match agent.parent_run_id.as_deref() { |
| 404 | Some(parent_id) |
| 405 | if app |
| 406 | .subagent_cache |
| 407 | .iter() |
| 408 | .any(|candidate| candidate.agent_id == parent_id) => |
| 409 | { |
| 410 | safe_agent_display_name(app, parent_id) |
| 411 | } |
| 412 | Some(_) if agent.spawn_depth > 1 => "parent agent".to_string(), |
| 413 | _ => "primary session".to_string(), |
| 414 | } |
| 415 | } |
| 416 | |
| 417 | fn safe_parent_from_meta(app: &App, meta: Option<&AgentProgressMeta>) -> String { |
| 418 | match meta.and_then(|meta| meta.parent_run_id.as_deref()) { |
| 419 | Some(parent_id) |
| 420 | if app |
| 421 | .subagent_cache |
| 422 | .iter() |
| 423 | .any(|candidate| candidate.agent_id == parent_id) => |
| 424 | { |
| 425 | safe_agent_display_name(app, parent_id) |
| 426 | } |
| 427 | Some(_) => "parent agent".to_string(), |
| 428 | None => "primary session".to_string(), |
| 429 | } |
| 430 | } |
| 431 | |
| 432 | fn activity_status_label( |
| 433 | status: AgentCurrentActivityStatus, |
| 434 | locale: codewhale_localization::Locale, |
| 435 | ) -> std::borrow::Cow<'static, str> { |
| 436 | if status == AgentCurrentActivityStatus::Parked { |
| 437 | return codewhale_localization::tr( |
| 438 | locale, |
| 439 | codewhale_localization::MessageId::AgentStatusParked, |
| 440 | ); |
| 441 | } |
| 442 | std::borrow::Cow::Borrowed(match status { |
| 443 | AgentCurrentActivityStatus::Queued => "queued", |
| 444 | AgentCurrentActivityStatus::Starting => "starting", |
| 445 | AgentCurrentActivityStatus::Running => "running", |
| 446 | AgentCurrentActivityStatus::ModelWait => "waiting for model", |
| 447 | AgentCurrentActivityStatus::RunningTool => "running tool", |
| 448 | AgentCurrentActivityStatus::Waiting => "waiting for input", |
| 449 | AgentCurrentActivityStatus::Done => "completed", |
| 450 | AgentCurrentActivityStatus::Failed => "failed", |
| 451 | AgentCurrentActivityStatus::Canceled => "canceled", |
| 452 | AgentCurrentActivityStatus::Interrupted => "interrupted", |
| 453 | AgentCurrentActivityStatus::Parked => unreachable!("handled above"), |
| 454 | }) |
| 455 | } |
| 456 | |
| 457 | fn pending_question<'a>( |
| 458 | agent: Option<&'a SubAgentResult>, |
| 459 | meta: Option<&'a AgentProgressMeta>, |
| 460 | ) -> Option<&'a str> { |
| 461 | // A parked child carries a `needs_input` note that is phrased as a |
| 462 | // question but asks the *operator* nothing — it is the resume recipe. |
| 463 | // Reporting it as a pending question is the #5906 complaint verbatim, so |
| 464 | // this surface reports the parked state and its recovery instead. |
| 465 | if agent.is_some_and(crate::tui::subagent_routing::subagent_is_parked) { |
| 466 | return None; |
| 467 | } |
| 468 | agent |
| 469 | .and_then(|agent| agent.needs_input.as_ref()) |
| 470 | .map(|needs_input| needs_input.question.as_str()) |
| 471 | .or_else(|| { |
| 472 | meta.and_then(|meta| meta.current_activity.as_ref()) |
| 473 | .filter(|activity| activity.status == AgentCurrentActivityStatus::Waiting) |
| 474 | .and_then(|activity| activity.detail.as_deref()) |
| 475 | }) |
| 476 | .or_else(|| match agent.map(|agent| &agent.status) { |
| 477 | Some(SubAgentStatus::Interrupted(reason)) => Some(reason.as_str()), |
| 478 | _ => None, |
| 479 | }) |
| 480 | } |
| 481 | |
| 482 | fn blocker<'a>( |
| 483 | agent: Option<&'a SubAgentResult>, |
| 484 | meta: Option<&'a AgentProgressMeta>, |
| 485 | ) -> Option<&'a str> { |
| 486 | match agent.map(|agent| &agent.status) { |
| 487 | Some(SubAgentStatus::Failed(error) | SubAgentStatus::Interrupted(error)) => { |
| 488 | Some(error.as_str()) |
| 489 | } |
| 490 | Some(SubAgentStatus::BudgetExhausted) => Some("worker budget exhausted"), |
| 491 | _ => meta |
| 492 | .and_then(|meta| meta.current_activity.as_ref()) |
| 493 | .filter(|activity| activity.status == AgentCurrentActivityStatus::Failed) |
| 494 | .and_then(|activity| activity.detail.as_deref()), |
| 495 | } |
| 496 | } |
| 497 | |
| 498 | fn terminal_summary<'a>( |
| 499 | agent: Option<&'a SubAgentResult>, |
| 500 | meta: Option<&'a AgentProgressMeta>, |
| 501 | ) -> Option<&'a str> { |
| 502 | let agent = agent?; |
| 503 | if !matches!( |
| 504 | agent.status, |
| 505 | SubAgentStatus::Completed | SubAgentStatus::Cancelled |
| 506 | ) { |
| 507 | return None; |
| 508 | } |
| 509 | agent.result.as_deref().or_else(|| { |
| 510 | meta.and_then(|meta| meta.current_activity.as_ref()) |
| 511 | .and_then(|activity| activity.detail.as_deref()) |
| 512 | }) |
| 513 | } |
| 514 | |
| 515 | fn safe_workspace(app: &App, workspace: &Path) -> Option<String> { |
| 516 | if workspace.as_os_str().is_empty() { |
| 517 | return None; |
| 518 | } |
| 519 | if workspace == app.workspace { |
| 520 | return Some(".".to_string()); |
| 521 | } |
| 522 | if let Ok(relative) = workspace.strip_prefix(&app.workspace) { |
| 523 | let parts: Vec<String> = relative |
| 524 | .components() |
| 525 | .filter_map(|component| match component { |
| 526 | Component::Normal(part) => part.to_str().map(ToString::to_string), |
| 527 | _ => None, |
| 528 | }) |
| 529 | .collect(); |
| 530 | if !parts.is_empty() { |
| 531 | return safe_child_value(app, &parts.join("/")); |
| 532 | } |
| 533 | } |
| 534 | workspace |
| 535 | .file_name() |
| 536 | .and_then(|name| name.to_str()) |
| 537 | .and_then(|name| safe_child_value(app, name)) |
| 538 | } |
| 539 | |
| 540 | #[cfg(test)] |
| 541 | mod tests { |
| 542 | use super::*; |
| 543 | use std::path::PathBuf; |
| 544 | |
| 545 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
| 546 | use ratatui::{buffer::Buffer, layout::Rect}; |
| 547 | use serde_json::json; |
| 548 | use tempfile::tempdir; |
| 549 | |
| 550 | use crate::config::Config; |
| 551 | use crate::tools::subagent::{ |
| 552 | AgentWorkerStatus, ChildRouteReceipt, FleetRole, SubAgentAssignment, SubAgentNeedsInput, |
| 553 | }; |
| 554 | use crate::tui::app::{ |
| 555 | AgentCurrentActivity, AgentRecentAction, MAX_AGENT_RECENT_ACTIONS, TuiOptions, |
| 556 | }; |
| 557 | |
| 558 | fn test_app(workspace: PathBuf) -> App { |
| 559 | App::new( |
| 560 | TuiOptions { |
| 561 | model: "test-model".to_string(), |
| 562 | use_mouse_capture: true, |
| 563 | max_subagents: 4, |
| 564 | ..crate::test_support::test_tui_options(workspace) |
| 565 | }, |
| 566 | &Config::default(), |
| 567 | ) |
| 568 | } |
| 569 | |
| 570 | fn agent(agent_id: &str, status: SubAgentStatus) -> SubAgentResult { |
| 571 | SubAgentResult { |
| 572 | usage: None, |
| 573 | name: agent_id.to_string(), |
| 574 | agent_id: agent_id.to_string(), |
| 575 | context_mode: "isolated".to_string(), |
| 576 | fork_context: false, |
| 577 | workspace: None, |
| 578 | git_branch: None, |
| 579 | agent_type: FleetRole::Builder, |
| 580 | assignment: SubAgentAssignment { |
| 581 | objective: "Implement the bounded details route".to_string(), |
| 582 | role: Some("worker".to_string()), |
| 583 | }, |
| 584 | model: "deepseek-v4-pro".to_string(), |
| 585 | nickname: Some("Blue Whale".to_string()), |
| 586 | status, |
| 587 | worker_status: None, |
| 588 | runtime_permissions: None, |
| 589 | parent_run_id: None, |
| 590 | spawn_depth: 1, |
| 591 | child_route: None, |
| 592 | result: None, |
| 593 | steps_taken: 2, |
| 594 | checkpoint: None, |
| 595 | needs_input: None, |
| 596 | duration_ms: 2_500, |
| 597 | started_at: None, |
| 598 | from_prior_session: false, |
| 599 | } |
| 600 | } |
| 601 | |
| 602 | fn body_for( |
| 603 | status: SubAgentStatus, |
| 604 | worker_status: AgentWorkerStatus, |
| 605 | detail: Option<&str>, |
| 606 | ) -> String { |
| 607 | let tmp = tempdir().expect("tempdir"); |
| 608 | let mut app = test_app(tmp.path().to_path_buf()); |
| 609 | let agent_id = "agent_matrix_subject"; |
| 610 | let mut child = agent(agent_id, status); |
| 611 | child.worker_status = Some(worker_status); |
| 612 | match worker_status { |
| 613 | AgentWorkerStatus::WaitingForUser => { |
| 614 | child.needs_input = Some(SubAgentNeedsInput { |
| 615 | question: detail.unwrap_or("Which path should I use?").to_string(), |
| 616 | }); |
| 617 | } |
| 618 | AgentWorkerStatus::Completed => child.result = detail.map(str::to_string), |
| 619 | _ => {} |
| 620 | } |
| 621 | app.subagent_cache.push(child); |
| 622 | app.agent_progress_meta.insert( |
| 623 | agent_id.to_string(), |
| 624 | AgentProgressMeta { |
| 625 | current_activity: Some(AgentCurrentActivity::bounded( |
| 626 | worker_status.into(), |
| 627 | detail.map(str::to_string), |
| 628 | (worker_status == AgentWorkerStatus::RunningTool) |
| 629 | .then(|| "read_file".to_string()), |
| 630 | Some(2), |
| 631 | )), |
| 632 | ..AgentProgressMeta::default() |
| 633 | }, |
| 634 | ); |
| 635 | project_agent_details(&app, agent_id) |
| 636 | .expect("projection") |
| 637 | .body |
| 638 | } |
| 639 | |
| 640 | #[test] |
| 641 | fn provider_free_status_matrix_is_typed_and_bounded() { |
| 642 | let running = body_for( |
| 643 | SubAgentStatus::Running, |
| 644 | AgentWorkerStatus::RunningTool, |
| 645 | None, |
| 646 | ); |
| 647 | assert!(running.contains("State: tool · elapsed 2s · 2 steps")); |
| 648 | assert!(running.contains("Current: running tool · read_file · step 2")); |
| 649 | assert!(!running.contains("Provider:")); |
| 650 | |
| 651 | let waiting = body_for( |
| 652 | SubAgentStatus::Running, |
| 653 | AgentWorkerStatus::WaitingForUser, |
| 654 | Some("Which path should I use?"), |
| 655 | ); |
| 656 | assert!(waiting.contains("State: waiting")); |
| 657 | assert!(waiting.contains("Pending question: Which path should I use?")); |
| 658 | |
| 659 | let failed = body_for( |
| 660 | SubAgentStatus::Failed("verification failed".to_string()), |
| 661 | AgentWorkerStatus::Failed, |
| 662 | Some("verification failed"), |
| 663 | ); |
| 664 | assert!(failed.contains("State: failed")); |
| 665 | assert!(failed.contains("Blocker: verification failed")); |
| 666 | |
| 667 | let completed = body_for( |
| 668 | SubAgentStatus::Completed, |
| 669 | AgentWorkerStatus::Completed, |
| 670 | Some("all checks passed"), |
| 671 | ); |
| 672 | assert!(completed.contains("State: done")); |
| 673 | assert!(completed.contains("Summary: all checks passed")); |
| 674 | } |
| 675 | |
| 676 | #[test] |
| 677 | fn details_projection_matches_primary_agents_row() { |
| 678 | let tmp = tempdir().expect("tempdir"); |
| 679 | let mut app = test_app(tmp.path().to_path_buf()); |
| 680 | let agent_id = "agent_row_agreement"; |
| 681 | let mut child = agent( |
| 682 | agent_id, |
| 683 | SubAgentStatus::Failed("stale failure".to_string()), |
| 684 | ); |
| 685 | child.model = "kimi-k3".to_string(); |
| 686 | child.steps_taken = 7; |
| 687 | child.duration_ms = 61_000; |
| 688 | app.subagent_cache.push(child); |
| 689 | app.agent_progress_meta.insert( |
| 690 | agent_id.to_string(), |
| 691 | AgentProgressMeta { |
| 692 | current_activity: Some(AgentCurrentActivity::bounded( |
| 693 | AgentCurrentActivityStatus::RunningTool, |
| 694 | Some("checking tests".to_string()), |
| 695 | Some("cargo test".to_string()), |
| 696 | Some(7), |
| 697 | )), |
| 698 | resolved_model: Some("stale-meta-model".to_string()), |
| 699 | ..AgentProgressMeta::default() |
| 700 | }, |
| 701 | ); |
| 702 | |
| 703 | let row = crate::tui::sidebar::sidebar_agent_rows(&app) |
| 704 | .into_iter() |
| 705 | .find(|row| row.id == agent_id) |
| 706 | .expect("primary agents row"); |
| 707 | let details = project_agent_details(&app, agent_id).expect("details projection"); |
| 708 | |
| 709 | assert_eq!(row.status, "tool"); |
| 710 | assert_eq!(row.steps_taken, 7); |
| 711 | assert_eq!(row.model.as_deref(), Some("kimi-k3")); |
| 712 | assert_eq!(row.duration_ms, Some(61_000)); |
| 713 | assert!(details.body.contains(&format!( |
| 714 | "State: {} · elapsed {} · {} steps", |
| 715 | row.status, |
| 716 | crate::elapsed::format_elapsed_ms(row.duration_ms.expect("duration")), |
| 717 | row.steps_taken |
| 718 | ))); |
| 719 | assert!(details.body.contains("Model: kimi-k3")); |
| 720 | assert!(!details.body.contains("stale-meta-model")); |
| 721 | } |
| 722 | |
| 723 | #[test] |
| 724 | fn details_projection_shows_resolved_route_truth_for_fleet_child() { |
| 725 | let tmp = tempdir().expect("tempdir"); |
| 726 | let mut app = test_app(tmp.path().to_path_buf()); |
| 727 | let agent_id = "agent_fleet_child"; |
| 728 | let mut child = agent(agent_id, SubAgentStatus::Running); |
| 729 | // A `type`-only fleet dispatch leaves the advisory role empty and the |
| 730 | // spawn model is a requested placeholder; the child's own route receipt |
| 731 | // is the authoritative resolved truth. |
| 732 | child.assignment.role = None; |
| 733 | child.model = "stale-requested-model".to_string(); |
| 734 | child.child_route = Some(ChildRouteReceipt { |
| 735 | requested_type: "custom".to_string(), |
| 736 | requested_profile: Some("release-lead".to_string()), |
| 737 | resolved_profile_id: Some("roster-release-lead".to_string()), |
| 738 | profile_origin: Some("roster".to_string()), |
| 739 | canonical_role: "release-lead".to_string(), |
| 740 | provider_id: "deepseek".to_string(), |
| 741 | model_id: "deepseek-v4-pro".to_string(), |
| 742 | route_source: "roster".to_string(), |
| 743 | fallback_note: None, |
| 744 | requested_reasoning: "inherit".to_string(), |
| 745 | effective_reasoning: Some("high".to_string()), |
| 746 | runtime_version: "test".to_string(), |
| 747 | runtime_build_sha: "unknown".to_string(), |
| 748 | }); |
| 749 | app.subagent_cache.push(child); |
| 750 | |
| 751 | let body = project_agent_details(&app, agent_id) |
| 752 | .expect("projection") |
| 753 | .body; |
| 754 | assert!(body.contains("Role: release-lead"), "{body}"); |
| 755 | assert!(body.contains("Profile: roster-release-lead"), "{body}"); |
| 756 | assert!(body.contains("Requested as: release-lead"), "{body}"); |
| 757 | assert!(body.contains("Type: implement"), "{body}"); |
| 758 | assert!(body.contains("Model: deepseek-v4-pro"), "{body}"); |
| 759 | assert!(body.contains("Provider: deepseek"), "{body}"); |
| 760 | assert!(!body.contains("stale-requested-model"), "{body}"); |
| 761 | } |
| 762 | |
| 763 | #[test] |
| 764 | fn display_name_prefers_the_dispatch_name_over_the_whale() { |
| 765 | // #5287: the lane was dispatched as `branch-triage`; that is the |
| 766 | // identity the operator glances for. An unnamed sibling keeps its |
| 767 | // generated whale rather than showing a bare id. |
| 768 | let tmp = tempdir().expect("tempdir"); |
| 769 | let mut app = test_app(tmp.path().to_path_buf()); |
| 770 | let mut named = agent("agent_named_lane", SubAgentStatus::Running); |
| 771 | named.name = "branch-triage".to_string(); |
| 772 | app.subagent_cache.push(named); |
| 773 | let mut unnamed = agent("agent_plain_lane", SubAgentStatus::Running); |
| 774 | unnamed.nickname = None; |
| 775 | app.subagent_cache.push(unnamed); |
| 776 | |
| 777 | assert_eq!( |
| 778 | safe_agent_display_name(&app, "agent_named_lane"), |
| 779 | "branch-triage" |
| 780 | ); |
| 781 | assert_eq!( |
| 782 | safe_agent_display_name(&app, "agent_plain_lane"), |
| 783 | crate::tools::subagent::whale_name_for_id_in_locale( |
| 784 | "agent_plain_lane", |
| 785 | app.ui_locale.tag() |
| 786 | ) |
| 787 | ); |
| 788 | } |
| 789 | |
| 790 | #[test] |
| 791 | fn projection_redacts_child_strings_and_never_exposes_raw_ids_or_none() { |
| 792 | let tmp = tempdir().expect("tempdir"); |
| 793 | let mut app = test_app(tmp.path().to_path_buf()); |
| 794 | let agent_id = "agent_secret_child"; |
| 795 | let parent_id = "agent_raw_parent"; |
| 796 | let mut parent = agent(parent_id, SubAgentStatus::Running); |
| 797 | parent.nickname = Some("Parent Whale".to_string()); |
| 798 | let mut child = agent(agent_id, SubAgentStatus::Running); |
| 799 | child.parent_run_id = Some(parent_id.to_string()); |
| 800 | child.spawn_depth = 2; |
| 801 | child.assignment.objective = format!( |
| 802 | "\u{1b}[31minspect {agent_id}\u{1b}[0m with api_key=sk-agent-details-secret-1234567890" |
| 803 | ); |
| 804 | child.git_branch = Some(format!("work/{parent_id}")); |
| 805 | child.model.clear(); |
| 806 | child.nickname = Some(format!("\u{1b}[35m{agent_id}\u{1b}[0m")); |
| 807 | app.subagent_cache.extend([parent, child]); |
| 808 | |
| 809 | let projection = project_agent_details(&app, agent_id).expect("projection"); |
| 810 | let all = format!("{}\n{}", projection.title, projection.body); |
| 811 | assert!(!all.contains(agent_id), "{all}"); |
| 812 | assert!(!all.contains(parent_id), "{all}"); |
| 813 | assert!(!all.contains("sk-agent-details-secret"), "{all}"); |
| 814 | assert!(!all.contains('\u{1b}'), "{all:?}"); |
| 815 | assert!(!all.contains("None"), "{all}"); |
| 816 | assert!(all.contains("[redacted]"), "{all}"); |
| 817 | } |
| 818 | |
| 819 | #[test] |
| 820 | fn external_workspace_is_basename_safe_and_branch_is_bounded() { |
| 821 | let mut app = test_app(PathBuf::from("/repo/main")); |
| 822 | let agent_id = "agent_external_workspace"; |
| 823 | let mut child = agent(agent_id, SubAgentStatus::Running); |
| 824 | child.workspace = Some(PathBuf::from("/private/customer/secret/repo-child")); |
| 825 | child.git_branch = Some("codex/details".to_string()); |
| 826 | app.subagent_cache.push(child); |
| 827 | |
| 828 | let body = project_agent_details(&app, agent_id) |
| 829 | .expect("projection") |
| 830 | .body; |
| 831 | assert!(body.contains("Workspace: repo-child"), "{body}"); |
| 832 | assert!(!body.contains("/private/customer/secret"), "{body}"); |
| 833 | assert!(body.contains("Branch: codex/details"), "{body}"); |
| 834 | } |
| 835 | |
| 836 | #[test] |
| 837 | fn recent_actions_are_bounded_and_render_only_structured_outcomes() { |
| 838 | let tmp = tempdir().expect("tempdir"); |
| 839 | let mut app = test_app(tmp.path().to_path_buf()); |
| 840 | let agent_id = "agent_recent_actions"; |
| 841 | app.subagent_cache |
| 842 | .push(agent(agent_id, SubAgentStatus::Running)); |
| 843 | let mut meta = AgentProgressMeta::default(); |
| 844 | for step in 1..=MAX_AGENT_RECENT_ACTIONS as u32 { |
| 845 | meta.recent_actions.push_back(AgentRecentAction::bounded( |
| 846 | if step == 2 { |
| 847 | "apply_patch" |
| 848 | } else { |
| 849 | "read_file" |
| 850 | }, |
| 851 | step, |
| 852 | step != 2, |
| 853 | )); |
| 854 | } |
| 855 | app.agent_progress_meta.insert(agent_id.to_string(), meta); |
| 856 | |
| 857 | let body = project_agent_details(&app, agent_id) |
| 858 | .expect("projection") |
| 859 | .body; |
| 860 | assert_eq!(body.matches("Recent:").count(), 3, "{body}"); |
| 861 | assert!(body.contains("Recent: ! apply_patch · step 2"), "{body}"); |
| 862 | } |
| 863 | |
| 864 | #[test] |
| 865 | fn alt_v_is_truthful_for_present_and_absent_evidence() { |
| 866 | let tmp = tempdir().expect("tempdir"); |
| 867 | let agent_id = "agent_evidence"; |
| 868 | let mut app = test_app(tmp.path().to_path_buf()); |
| 869 | app.subagent_cache |
| 870 | .push(agent(agent_id, SubAgentStatus::Running)); |
| 871 | let absent = project_agent_details(&app, agent_id).expect("projection"); |
| 872 | assert!(!absent.transcript_available); |
| 873 | let details_chord = crate::tui::shell_key_routing::tool_details_chord(); |
| 874 | let transcript_hint = format!("{details_chord}:transcript"); |
| 875 | assert!(!absent.body.contains(&transcript_hint)); |
| 876 | let mut absent_view = AgentDetailsView::new(absent, agent_id, 80); |
| 877 | assert!(matches!( |
| 878 | absent_view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)), |
| 879 | ViewAction::None |
| 880 | )); |
| 881 | |
| 882 | { |
| 883 | let mut store = app |
| 884 | .runtime_services |
| 885 | .handle_store |
| 886 | .try_lock() |
| 887 | .expect("handle store"); |
| 888 | let _ = store.insert_json( |
| 889 | format!("agent:{agent_id}"), |
| 890 | "full_transcript", |
| 891 | json!({ |
| 892 | "message_count": 1, |
| 893 | "messages": [{ |
| 894 | "role": "assistant", |
| 895 | "content": [{ |
| 896 | "type": "text", |
| 897 | "text": "exact evidence", |
| 898 | "cache_control": null |
| 899 | }] |
| 900 | }] |
| 901 | }), |
| 902 | ); |
| 903 | } |
| 904 | let present = project_agent_details(&app, agent_id).expect("projection"); |
| 905 | assert!(present.transcript_available); |
| 906 | assert!( |
| 907 | present.body.contains(&transcript_hint), |
| 908 | "expected {transcript_hint:?} in {}", |
| 909 | present.body |
| 910 | ); |
| 911 | assert!( |
| 912 | !present.body.contains("Alt/⌥V"), |
| 913 | "dual Alt/⌥ spelling must not appear: {}", |
| 914 | present.body |
| 915 | ); |
| 916 | let mut present_view = AgentDetailsView::new(present, agent_id, 80); |
| 917 | assert!(matches!( |
| 918 | present_view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)), |
| 919 | ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id: ref id }) if id == agent_id |
| 920 | )); |
| 921 | |
| 922 | assert!(open_agent_details(&mut app, agent_id)); |
| 923 | let events = app |
| 924 | .view_stack |
| 925 | .handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)); |
| 926 | assert!(matches!( |
| 927 | events.as_slice(), |
| 928 | [ViewEvent::OpenAgentTranscript { agent_id: id }] if id == agent_id |
| 929 | )); |
| 930 | // The transcript destination is the in-place focus; the details view |
| 931 | // survives underneath and its chord still points at the transcript. |
| 932 | crate::tui::agent_focus::focus_agent(&mut app, agent_id); |
| 933 | assert!( |
| 934 | app.agent_focus |
| 935 | .as_ref() |
| 936 | .is_some_and(|focus| focus.is(agent_id)) |
| 937 | ); |
| 938 | assert!(matches!( |
| 939 | app.view_stack |
| 940 | .handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)) |
| 941 | .as_slice(), |
| 942 | [ViewEvent::OpenAgentTranscript { agent_id: id }] if id == agent_id |
| 943 | )); |
| 944 | } |
| 945 | |
| 946 | #[test] |
| 947 | fn close_keys_emit_receipt_and_80x24_render_stays_safe() { |
| 948 | let tmp = tempdir().expect("tempdir"); |
| 949 | let agent_id = "agent_render_80x24"; |
| 950 | let mut app = test_app(tmp.path().to_path_buf()); |
| 951 | app.subagent_cache |
| 952 | .push(agent(agent_id, SubAgentStatus::Running)); |
| 953 | let projection = project_agent_details(&app, agent_id).expect("projection"); |
| 954 | let mut view = AgentDetailsView::new(projection, agent_id, 80); |
| 955 | assert!(view.title().starts_with("Agent Details — ")); |
| 956 | assert!(view.body_text().contains("Exact evidence: unavailable")); |
| 957 | |
| 958 | let area = Rect::new(0, 0, 80, 24); |
| 959 | let mut buffer = Buffer::empty(area); |
| 960 | view.render(area, &mut buffer); |
| 961 | let rendered = (0..area.height) |
| 962 | .flat_map(|y| (0..area.width).map(move |x| (x, y))) |
| 963 | .map(|point| buffer[point].symbol()) |
| 964 | .collect::<String>(); |
| 965 | assert!(rendered.contains("Agent Details"), "{rendered}"); |
| 966 | assert!(rendered.contains("Assignment"), "{rendered}"); |
| 967 | assert!(!rendered.contains(agent_id), "{rendered}"); |
| 968 | |
| 969 | for code in [KeyCode::Esc, KeyCode::Left] { |
| 970 | assert!(matches!( |
| 971 | view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)), |
| 972 | ViewAction::EmitAndClose(ViewEvent::AgentDetailsClosed { agent_id: ref id }) |
| 973 | if id == agent_id |
| 974 | )); |
| 975 | } |
| 976 | } |
| 977 | // === #5906: a parked husk is not a pending question ================== |
| 978 | |
| 979 | /// The runtime hands a parked child a `needs_input` note that reads like a |
| 980 | /// question, so Agent Details used to print `Pending question: Resume this |
| 981 | /// parked child with ...` — a question no operator ever asked and none can |
| 982 | /// answer. Build one exactly as the runtime does and assert the view names |
| 983 | /// the state and both ways out instead. |
| 984 | fn parked_child(agent_id: &str) -> SubAgentResult { |
| 985 | let mut child = agent( |
| 986 | agent_id, |
| 987 | SubAgentStatus::Interrupted( |
| 988 | "Parent turn ended before this turn-owned child settled.".to_string(), |
| 989 | ), |
| 990 | ); |
| 991 | child.worker_status = Some(AgentWorkerStatus::WaitingForUser); |
| 992 | child.needs_input = Some(SubAgentNeedsInput { |
| 993 | question: format!( |
| 994 | "Resume this parked child with agent(action=\"start\", resume_from=\"{agent_id}\")." |
| 995 | ), |
| 996 | }); |
| 997 | child.checkpoint = Some(crate::tools::subagent::SubAgentCheckpoint { |
| 998 | checkpoint_id: format!("{agent_id}:step:2"), |
| 999 | agent_id: agent_id.to_string(), |
| 1000 | continuation_handle: format!("agent:{agent_id}:checkpoint"), |
| 1001 | reason: "Parent turn ended before this turn-owned child settled.".to_string(), |
| 1002 | continuable: true, |
| 1003 | steps_taken: 2, |
| 1004 | message_count: 4, |
| 1005 | created_at_ms: 1_000, |
| 1006 | messages: Vec::new(), |
| 1007 | omitted_messages: 0, |
| 1008 | parked_at_turn_end: true, |
| 1009 | }); |
| 1010 | child |
| 1011 | } |
| 1012 | |
| 1013 | #[test] |
| 1014 | fn parked_details_name_the_state_and_the_recovery_not_a_pending_question() { |
| 1015 | let tmp = tempdir().expect("tempdir"); |
| 1016 | let mut app = test_app(tmp.path().to_path_buf()); |
| 1017 | let agent_id = "agent_parked_details"; |
| 1018 | app.subagent_cache.push(parked_child(agent_id)); |
| 1019 | crate::tui::subagent_routing::reconcile_subagent_activity_state(&mut app); |
| 1020 | |
| 1021 | let body = project_agent_details(&app, agent_id) |
| 1022 | .expect("projection") |
| 1023 | .body; |
| 1024 | |
| 1025 | assert!(body.contains("State: parked"), "{body}"); |
| 1026 | assert!( |
| 1027 | !body.contains("waiting for input") && !body.contains("State: waiting"), |
| 1028 | "a parked husk must not wear the answerable label: {body}" |
| 1029 | ); |
| 1030 | assert!( |
| 1031 | !body.contains("Pending question"), |
| 1032 | "nothing asked the operator anything: {body}" |
| 1033 | ); |
| 1034 | // The recovery is quoted with the verbs the runtime actually exposes. |
| 1035 | assert!(body.contains("resume_from"), "{body}"); |
| 1036 | assert!(body.contains("cancel"), "{body}"); |
| 1037 | } |
| 1038 | |
| 1039 | #[test] |
| 1040 | fn a_child_that_really_asked_still_reports_waiting_for_input() { |
| 1041 | let tmp = tempdir().expect("tempdir"); |
| 1042 | let mut app = test_app(tmp.path().to_path_buf()); |
| 1043 | let agent_id = "agent_really_asked"; |
| 1044 | let mut child = agent(agent_id, SubAgentStatus::Running); |
| 1045 | child.worker_status = Some(AgentWorkerStatus::WaitingForUser); |
| 1046 | child.needs_input = Some(SubAgentNeedsInput { |
| 1047 | question: "Which path should I use?".to_string(), |
| 1048 | }); |
| 1049 | app.subagent_cache.push(child); |
| 1050 | crate::tui::subagent_routing::reconcile_subagent_activity_state(&mut app); |
| 1051 | |
| 1052 | let body = project_agent_details(&app, agent_id) |
| 1053 | .expect("projection") |
| 1054 | .body; |
| 1055 | assert!(body.contains("State: waiting"), "{body}"); |
| 1056 | assert!( |
| 1057 | body.contains("Pending question: Which path should I use?"), |
| 1058 | "{body}" |
| 1059 | ); |
| 1060 | assert!(!body.contains("parked"), "{body}"); |
| 1061 | } |
| 1062 | } |
| 1063 |