| 1 | //! Safe, bounded Agent Details projection (#2889). |
| 2 | //! |
| 3 | //! The default route intentionally does not expose the child transcript. Exact |
| 4 | //! evidence remains behind an explicit artifact-first action. |
| 5 | |
| 6 | use std::path::{Component, Path}; |
| 7 | |
| 8 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; |
| 9 | use ratatui::{buffer::Buffer, layout::Rect}; |
| 10 | |
| 11 | use crate::tools::subagent::{SubAgentResult, SubAgentStatus, localized_whale_display_names}; |
| 12 | use crate::tui::app::{ |
| 13 | AgentCurrentActivityStatus, AgentProgressMeta, App, bound_agent_activity_text, |
| 14 | }; |
| 15 | use crate::tui::pager::PagerView; |
| 16 | use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; |
| 17 | |
| 18 | pub(crate) struct AgentDetailsProjection { |
| 19 | pub(crate) title: String, |
| 20 | pub(crate) body: String, |
| 21 | pub(crate) transcript_available: bool, |
| 22 | } |
| 23 | |
| 24 | /// Pager-backed details view with a distinct close receipt and an explicit |
| 25 | /// exact-transcript action. |
| 26 | pub(crate) struct AgentDetailsView { |
| 27 | pager: PagerView, |
| 28 | agent_id: String, |
| 29 | transcript_available: bool, |
| 30 | } |
| 31 | |
| 32 | impl AgentDetailsView { |
| 33 | fn new(projection: AgentDetailsProjection, agent_id: impl Into<String>, width: u16) -> Self { |
| 34 | let agent_id = agent_id.into(); |
| 35 | let pager = |
| 36 | PagerView::from_text(projection.title, &projection.body, width.saturating_sub(2)) |
| 37 | .with_copy_text(projection.body); |
| 38 | Self { |
| 39 | pager, |
| 40 | agent_id, |
| 41 | transcript_available: projection.transcript_available, |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | #[cfg(test)] |
| 46 | fn body_text(&self) -> String { |
| 47 | self.pager.body_text() |
| 48 | } |
| 49 | |
| 50 | #[cfg(test)] |
| 51 | fn title(&self) -> &str { |
| 52 | self.pager.title() |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | impl ModalView for AgentDetailsView { |
| 57 | fn kind(&self) -> ModalKind { |
| 58 | ModalKind::Pager |
| 59 | } |
| 60 | |
| 61 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 62 | if matches!(key.code, KeyCode::Char('v' | 'V')) |
| 63 | && key.modifiers.contains(KeyModifiers::ALT) |
| 64 | && self.transcript_available |
| 65 | { |
| 66 | return ViewAction::Emit(ViewEvent::OpenAgentTranscript { |
| 67 | agent_id: self.agent_id.clone(), |
| 68 | }); |
| 69 | } |
| 70 | if matches!(key.code, KeyCode::Esc | KeyCode::Left) |
| 71 | || (key.code == KeyCode::Char('q') && key.modifiers.is_empty()) |
| 72 | { |
| 73 | return ViewAction::EmitAndClose(ViewEvent::AgentDetailsClosed { |
| 74 | agent_id: self.agent_id.clone(), |
| 75 | }); |
| 76 | } |
| 77 | self.pager.handle_key(key) |
| 78 | } |
| 79 | |
| 80 | fn handle_paste(&mut self, text: &str) -> bool { |
| 81 | self.pager.handle_paste(text) |
| 82 | } |
| 83 | |
| 84 | fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { |
| 85 | self.pager.handle_mouse(mouse) |
| 86 | } |
| 87 | |
| 88 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 89 | self.pager.render(area, buf); |
| 90 | } |
| 91 | |
| 92 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 93 | self |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | pub(crate) fn open_agent_details(app: &mut App, agent_id: &str) -> bool { |
| 98 | let Some(projection) = project_agent_details(app, agent_id) else { |
| 99 | return false; |
| 100 | }; |
| 101 | let width = app |
| 102 | .viewport |
| 103 | .last_transcript_area |
| 104 | .map(|area| area.width) |
| 105 | .unwrap_or(80); |
| 106 | app.view_stack |
| 107 | .push(AgentDetailsView::new(projection, agent_id, width)); |
| 108 | true |
| 109 | } |
| 110 | |
| 111 | pub(crate) fn safe_agent_display_name(app: &App, agent_id: &str) -> String { |
| 112 | let generated = localized_whale_display_names( |
| 113 | app.subagent_cache |
| 114 | .iter() |
| 115 | .map(|agent| (agent.agent_id.as_str(), agent.nickname.as_deref())), |
| 116 | app.ui_locale.tag(), |
| 117 | ); |
| 118 | generated |
| 119 | .get(agent_id) |
| 120 | .cloned() |
| 121 | .or_else(|| app.agent_label_map.get(agent_id).cloned()) |
| 122 | .and_then(|name| safe_child_value(app, &name)) |
| 123 | .unwrap_or_else(|| "Agent".to_string()) |
| 124 | } |
| 125 | |
| 126 | pub(crate) fn project_agent_details(app: &App, agent_id: &str) -> Option<AgentDetailsProjection> { |
| 127 | // The Agents sidebar is the primary worker surface. Consume its exact row |
| 128 | // projection so status precedence, model, elapsed time, and steps cannot |
| 129 | // diverge between the row and the popup opened from it. |
| 130 | let surface_row = crate::tui::sidebar::sidebar_agent_rows(app) |
| 131 | .into_iter() |
| 132 | .find(|row| row.id == agent_id)?; |
| 133 | let agent = app |
| 134 | .subagent_cache |
| 135 | .iter() |
| 136 | .find(|agent| agent.agent_id == agent_id); |
| 137 | let meta = app.agent_progress_meta.get(agent_id); |
| 138 | |
| 139 | let display_name = safe_child_value(app, &surface_row.name).unwrap_or_else(|| "Agent".into()); |
| 140 | let mut lines = Vec::new(); |
| 141 | |
| 142 | if let Some(agent) = agent { |
| 143 | push_safe_line(app, &mut lines, "Assignment", &agent.assignment.objective); |
| 144 | |
| 145 | if let Some(role) = agent.assignment.role.as_deref() { |
| 146 | push_safe_line(app, &mut lines, "Role", role); |
| 147 | } |
| 148 | push_safe_line(app, &mut lines, "Profile", agent.agent_type.as_str()); |
| 149 | lines.push(format!("Parent: {}", safe_parent_name(app, agent))); |
| 150 | } else { |
| 151 | lines.push(format!("Parent: {}", safe_parent_from_meta(app, meta))); |
| 152 | } |
| 153 | |
| 154 | let mut state = vec![surface_row.status.clone()]; |
| 155 | if let Some(duration_ms) = surface_row.duration_ms { |
| 156 | state.push(format!( |
| 157 | "elapsed {}", |
| 158 | crate::elapsed::format_elapsed_ms(duration_ms) |
| 159 | )); |
| 160 | } |
| 161 | state.push(format!( |
| 162 | "{} {}", |
| 163 | surface_row.steps_taken, |
| 164 | if surface_row.steps_taken == 1 { |
| 165 | "step" |
| 166 | } else { |
| 167 | "steps" |
| 168 | } |
| 169 | )); |
| 170 | lines.push(format!("State: {}", state.join(" · "))); |
| 171 | |
| 172 | if let Some(meta) = meta |
| 173 | && let Some(provider) = meta.resolved_provider.as_deref() |
| 174 | { |
| 175 | push_safe_line(app, &mut lines, "Provider", provider); |
| 176 | } |
| 177 | if let Some(model) = surface_row.model.as_deref() { |
| 178 | push_safe_line(app, &mut lines, "Model", model); |
| 179 | } |
| 180 | |
| 181 | if let Some(agent) = agent { |
| 182 | if let Some(workspace) = agent.workspace.as_deref() |
| 183 | && let Some(workspace) = safe_workspace(app, workspace) |
| 184 | { |
| 185 | lines.push(format!("Workspace: {workspace}")); |
| 186 | } |
| 187 | if let Some(branch) = agent.git_branch.as_deref() { |
| 188 | push_safe_line(app, &mut lines, "Branch", branch); |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | if let Some(activity) = meta.and_then(|meta| meta.current_activity.as_ref()) |
| 193 | && !matches!( |
| 194 | activity.status, |
| 195 | AgentCurrentActivityStatus::Done |
| 196 | | AgentCurrentActivityStatus::Failed |
| 197 | | AgentCurrentActivityStatus::Canceled |
| 198 | | AgentCurrentActivityStatus::Interrupted |
| 199 | | AgentCurrentActivityStatus::Waiting |
| 200 | ) |
| 201 | { |
| 202 | let mut current = vec![activity_status_label(activity.status).to_string()]; |
| 203 | if let Some(tool) = activity |
| 204 | .current_tool |
| 205 | .as_deref() |
| 206 | .and_then(|tool| safe_child_value(app, tool)) |
| 207 | { |
| 208 | current.push(tool); |
| 209 | } |
| 210 | if let Some(step) = activity.step { |
| 211 | current.push(format!("step {step}")); |
| 212 | } |
| 213 | if let Some(detail) = activity |
| 214 | .detail |
| 215 | .as_deref() |
| 216 | .and_then(|detail| safe_child_value(app, detail)) |
| 217 | && !detail.starts_with("started ") |
| 218 | && !current.iter().any(|part| part == &detail) |
| 219 | { |
| 220 | current.push(detail); |
| 221 | } |
| 222 | lines.push(format!("Current: {}", current.join(" · "))); |
| 223 | } |
| 224 | |
| 225 | if let Some(meta) = meta { |
| 226 | for action in meta.recent_actions.iter().rev().take(3).rev() { |
| 227 | if let Some(tool) = safe_child_value(app, &action.tool) { |
| 228 | lines.push(format!( |
| 229 | "Recent: {} {tool} · step {}", |
| 230 | if action.ok { "✓" } else { "!" }, |
| 231 | action.step |
| 232 | )); |
| 233 | } |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | if let Some(question) = pending_question(agent, meta) |
| 238 | && let Some(question) = safe_child_value(app, question) |
| 239 | { |
| 240 | lines.push(format!("Pending question: {question}")); |
| 241 | } |
| 242 | if let Some(blocker) = blocker(agent, meta) |
| 243 | && let Some(blocker) = safe_child_value(app, blocker) |
| 244 | { |
| 245 | lines.push(format!("Blocker: {blocker}")); |
| 246 | } |
| 247 | if let Some(summary) = terminal_summary(agent, meta) |
| 248 | && let Some(summary) = safe_child_value(app, summary) |
| 249 | { |
| 250 | lines.push(format!("Summary: {summary}")); |
| 251 | } |
| 252 | |
| 253 | let transcript_available = |
| 254 | crate::tui::mouse_ui::agent_transcript_evidence_available(app, agent_id); |
| 255 | if transcript_available { |
| 256 | // Platform glyph via display_chord (⌥V on macOS, Alt+V elsewhere) — |
| 257 | // never the dual "Alt/⌥V" spelling, and cap:verb not a sentence. |
| 258 | let chord = crate::tui::shell_key_routing::tool_details_chord(); |
| 259 | lines.push(format!("Exact evidence: available · {chord}:transcript")); |
| 260 | } else { |
| 261 | lines.push("Exact evidence: unavailable".to_string()); |
| 262 | } |
| 263 | |
| 264 | Some(AgentDetailsProjection { |
| 265 | title: format!("Agent Details — {display_name}"), |
| 266 | body: lines.join("\n"), |
| 267 | transcript_available, |
| 268 | }) |
| 269 | } |
| 270 | |
| 271 | fn push_safe_line(app: &App, lines: &mut Vec<String>, label: &str, value: &str) { |
| 272 | if let Some(value) = safe_child_value(app, value) { |
| 273 | lines.push(format!("{label}: {value}")); |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | fn safe_child_value(app: &App, value: &str) -> Option<String> { |
| 278 | let bounded = bound_agent_activity_text(value); |
| 279 | let scrubbed = scrub_raw_agent_ids(app, &bounded); |
| 280 | let trimmed = scrubbed.trim(); |
| 281 | if trimmed.is_empty() |
| 282 | || matches!( |
| 283 | trimmed.to_ascii_lowercase().as_str(), |
| 284 | "none" | "(none)" | "n/a" | "unknown" | "not set" | "not available" | "-" |
| 285 | ) |
| 286 | { |
| 287 | None |
| 288 | } else { |
| 289 | Some(trimmed.to_string()) |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | fn scrub_raw_agent_ids(app: &App, value: &str) -> String { |
| 294 | let mut scrubbed = value.to_string(); |
| 295 | let mut ids: Vec<&str> = app |
| 296 | .subagent_cache |
| 297 | .iter() |
| 298 | .map(|agent| agent.agent_id.as_str()) |
| 299 | .chain( |
| 300 | app.subagent_cache |
| 301 | .iter() |
| 302 | .filter_map(|agent| agent.parent_run_id.as_deref()), |
| 303 | ) |
| 304 | .chain(app.agent_progress_meta.keys().map(String::as_str)) |
| 305 | .chain( |
| 306 | app.agent_progress_meta |
| 307 | .values() |
| 308 | .filter_map(|meta| meta.parent_run_id.as_deref()), |
| 309 | ) |
| 310 | .chain(app.agent_progress.keys().map(String::as_str)) |
| 311 | .chain(app.agent_label_map.keys().map(String::as_str)) |
| 312 | .collect(); |
| 313 | ids.sort_unstable_by_key(|id| std::cmp::Reverse(id.len())); |
| 314 | ids.dedup(); |
| 315 | for id in ids { |
| 316 | if !id.is_empty() { |
| 317 | scrubbed = scrubbed.replace(id, "agent"); |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | let mut output = String::with_capacity(scrubbed.len()); |
| 322 | let mut token = String::new(); |
| 323 | let flush = |token: &mut String, output: &mut String| { |
| 324 | if token.starts_with("agent_") |
| 325 | || token.starts_with("agent-") |
| 326 | || token.starts_with("worker:agent_") |
| 327 | || token.starts_with("worker:agent-") |
| 328 | { |
| 329 | output.push_str("agent"); |
| 330 | } else { |
| 331 | output.push_str(token); |
| 332 | } |
| 333 | token.clear(); |
| 334 | }; |
| 335 | for ch in scrubbed.chars() { |
| 336 | if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':') { |
| 337 | token.push(ch); |
| 338 | } else { |
| 339 | flush(&mut token, &mut output); |
| 340 | output.push(ch); |
| 341 | } |
| 342 | } |
| 343 | flush(&mut token, &mut output); |
| 344 | output |
| 345 | } |
| 346 | |
| 347 | fn safe_parent_name(app: &App, agent: &SubAgentResult) -> String { |
| 348 | match agent.parent_run_id.as_deref() { |
| 349 | Some(parent_id) |
| 350 | if app |
| 351 | .subagent_cache |
| 352 | .iter() |
| 353 | .any(|candidate| candidate.agent_id == parent_id) => |
| 354 | { |
| 355 | safe_agent_display_name(app, parent_id) |
| 356 | } |
| 357 | Some(_) if agent.spawn_depth > 1 => "parent agent".to_string(), |
| 358 | _ => "primary session".to_string(), |
| 359 | } |
| 360 | } |
| 361 | |
| 362 | fn safe_parent_from_meta(app: &App, meta: Option<&AgentProgressMeta>) -> String { |
| 363 | match meta.and_then(|meta| meta.parent_run_id.as_deref()) { |
| 364 | Some(parent_id) |
| 365 | if app |
| 366 | .subagent_cache |
| 367 | .iter() |
| 368 | .any(|candidate| candidate.agent_id == parent_id) => |
| 369 | { |
| 370 | safe_agent_display_name(app, parent_id) |
| 371 | } |
| 372 | Some(_) => "parent agent".to_string(), |
| 373 | None => "primary session".to_string(), |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | fn activity_status_label(status: AgentCurrentActivityStatus) -> &'static str { |
| 378 | match status { |
| 379 | AgentCurrentActivityStatus::Queued => "queued", |
| 380 | AgentCurrentActivityStatus::Starting => "starting", |
| 381 | AgentCurrentActivityStatus::Running => "running", |
| 382 | AgentCurrentActivityStatus::ModelWait => "waiting for model", |
| 383 | AgentCurrentActivityStatus::RunningTool => "running tool", |
| 384 | AgentCurrentActivityStatus::Waiting => "waiting for input", |
| 385 | AgentCurrentActivityStatus::Done => "completed", |
| 386 | AgentCurrentActivityStatus::Failed => "failed", |
| 387 | AgentCurrentActivityStatus::Canceled => "canceled", |
| 388 | AgentCurrentActivityStatus::Interrupted => "interrupted", |
| 389 | } |
| 390 | } |
| 391 | |
| 392 | fn pending_question<'a>( |
| 393 | agent: Option<&'a SubAgentResult>, |
| 394 | meta: Option<&'a AgentProgressMeta>, |
| 395 | ) -> Option<&'a str> { |
| 396 | agent |
| 397 | .and_then(|agent| agent.needs_input.as_ref()) |
| 398 | .map(|needs_input| needs_input.question.as_str()) |
| 399 | .or_else(|| { |
| 400 | meta.and_then(|meta| meta.current_activity.as_ref()) |
| 401 | .filter(|activity| activity.status == AgentCurrentActivityStatus::Waiting) |
| 402 | .and_then(|activity| activity.detail.as_deref()) |
| 403 | }) |
| 404 | .or_else(|| match agent.map(|agent| &agent.status) { |
| 405 | Some(SubAgentStatus::Interrupted(reason)) => Some(reason.as_str()), |
| 406 | _ => None, |
| 407 | }) |
| 408 | } |
| 409 | |
| 410 | fn blocker<'a>( |
| 411 | agent: Option<&'a SubAgentResult>, |
| 412 | meta: Option<&'a AgentProgressMeta>, |
| 413 | ) -> Option<&'a str> { |
| 414 | match agent.map(|agent| &agent.status) { |
| 415 | Some(SubAgentStatus::Failed(error) | SubAgentStatus::Interrupted(error)) => { |
| 416 | Some(error.as_str()) |
| 417 | } |
| 418 | Some(SubAgentStatus::BudgetExhausted) => Some("worker budget exhausted"), |
| 419 | _ => meta |
| 420 | .and_then(|meta| meta.current_activity.as_ref()) |
| 421 | .filter(|activity| activity.status == AgentCurrentActivityStatus::Failed) |
| 422 | .and_then(|activity| activity.detail.as_deref()), |
| 423 | } |
| 424 | } |
| 425 | |
| 426 | fn terminal_summary<'a>( |
| 427 | agent: Option<&'a SubAgentResult>, |
| 428 | meta: Option<&'a AgentProgressMeta>, |
| 429 | ) -> Option<&'a str> { |
| 430 | let agent = agent?; |
| 431 | if !matches!( |
| 432 | agent.status, |
| 433 | SubAgentStatus::Completed | SubAgentStatus::Cancelled |
| 434 | ) { |
| 435 | return None; |
| 436 | } |
| 437 | agent.result.as_deref().or_else(|| { |
| 438 | meta.and_then(|meta| meta.current_activity.as_ref()) |
| 439 | .and_then(|activity| activity.detail.as_deref()) |
| 440 | }) |
| 441 | } |
| 442 | |
| 443 | fn safe_workspace(app: &App, workspace: &Path) -> Option<String> { |
| 444 | if workspace.as_os_str().is_empty() { |
| 445 | return None; |
| 446 | } |
| 447 | if workspace == app.workspace { |
| 448 | return Some(".".to_string()); |
| 449 | } |
| 450 | if let Ok(relative) = workspace.strip_prefix(&app.workspace) { |
| 451 | let parts: Vec<String> = relative |
| 452 | .components() |
| 453 | .filter_map(|component| match component { |
| 454 | Component::Normal(part) => part.to_str().map(ToString::to_string), |
| 455 | _ => None, |
| 456 | }) |
| 457 | .collect(); |
| 458 | if !parts.is_empty() { |
| 459 | return safe_child_value(app, &parts.join("/")); |
| 460 | } |
| 461 | } |
| 462 | workspace |
| 463 | .file_name() |
| 464 | .and_then(|name| name.to_str()) |
| 465 | .and_then(|name| safe_child_value(app, name)) |
| 466 | } |
| 467 | |
| 468 | #[cfg(test)] |
| 469 | mod tests { |
| 470 | use super::*; |
| 471 | use std::path::PathBuf; |
| 472 | |
| 473 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
| 474 | use ratatui::{buffer::Buffer, layout::Rect}; |
| 475 | use serde_json::json; |
| 476 | use tempfile::tempdir; |
| 477 | |
| 478 | use crate::config::Config; |
| 479 | use crate::tools::subagent::{ |
| 480 | AgentWorkerStatus, FleetRole, SubAgentAssignment, SubAgentNeedsInput, |
| 481 | }; |
| 482 | use crate::tui::app::{ |
| 483 | AgentCurrentActivity, AgentRecentAction, MAX_AGENT_RECENT_ACTIONS, TuiOptions, |
| 484 | }; |
| 485 | |
| 486 | fn test_app(workspace: PathBuf) -> App { |
| 487 | App::new( |
| 488 | TuiOptions { |
| 489 | model: "test-model".to_string(), |
| 490 | use_mouse_capture: true, |
| 491 | max_subagents: 4, |
| 492 | ..crate::test_support::test_tui_options(workspace) |
| 493 | }, |
| 494 | &Config::default(), |
| 495 | ) |
| 496 | } |
| 497 | |
| 498 | fn agent(agent_id: &str, status: SubAgentStatus) -> SubAgentResult { |
| 499 | SubAgentResult { |
| 500 | name: agent_id.to_string(), |
| 501 | agent_id: agent_id.to_string(), |
| 502 | context_mode: "isolated".to_string(), |
| 503 | fork_context: false, |
| 504 | workspace: None, |
| 505 | git_branch: None, |
| 506 | agent_type: FleetRole::Builder, |
| 507 | assignment: SubAgentAssignment { |
| 508 | objective: "Implement the bounded details route".to_string(), |
| 509 | role: Some("worker".to_string()), |
| 510 | }, |
| 511 | model: "deepseek-v4-pro".to_string(), |
| 512 | nickname: Some("Blue Whale".to_string()), |
| 513 | status, |
| 514 | worker_status: None, |
| 515 | runtime_permissions: None, |
| 516 | parent_run_id: None, |
| 517 | spawn_depth: 1, |
| 518 | result: None, |
| 519 | steps_taken: 2, |
| 520 | checkpoint: None, |
| 521 | needs_input: None, |
| 522 | duration_ms: 2_500, |
| 523 | from_prior_session: false, |
| 524 | } |
| 525 | } |
| 526 | |
| 527 | fn body_for( |
| 528 | status: SubAgentStatus, |
| 529 | worker_status: AgentWorkerStatus, |
| 530 | detail: Option<&str>, |
| 531 | ) -> String { |
| 532 | let tmp = tempdir().expect("tempdir"); |
| 533 | let mut app = test_app(tmp.path().to_path_buf()); |
| 534 | let agent_id = "agent_matrix_subject"; |
| 535 | let mut child = agent(agent_id, status); |
| 536 | child.worker_status = Some(worker_status); |
| 537 | match worker_status { |
| 538 | AgentWorkerStatus::WaitingForUser => { |
| 539 | child.needs_input = Some(SubAgentNeedsInput { |
| 540 | question: detail.unwrap_or("Which path should I use?").to_string(), |
| 541 | }); |
| 542 | } |
| 543 | AgentWorkerStatus::Completed => child.result = detail.map(str::to_string), |
| 544 | _ => {} |
| 545 | } |
| 546 | app.subagent_cache.push(child); |
| 547 | app.agent_progress_meta.insert( |
| 548 | agent_id.to_string(), |
| 549 | AgentProgressMeta { |
| 550 | current_activity: Some(AgentCurrentActivity::bounded( |
| 551 | worker_status.into(), |
| 552 | detail.map(str::to_string), |
| 553 | (worker_status == AgentWorkerStatus::RunningTool) |
| 554 | .then(|| "read_file".to_string()), |
| 555 | Some(2), |
| 556 | )), |
| 557 | ..AgentProgressMeta::default() |
| 558 | }, |
| 559 | ); |
| 560 | project_agent_details(&app, agent_id) |
| 561 | .expect("projection") |
| 562 | .body |
| 563 | } |
| 564 | |
| 565 | #[test] |
| 566 | fn provider_free_status_matrix_is_typed_and_bounded() { |
| 567 | let running = body_for( |
| 568 | SubAgentStatus::Running, |
| 569 | AgentWorkerStatus::RunningTool, |
| 570 | None, |
| 571 | ); |
| 572 | assert!(running.contains("State: tool · elapsed 2s · 2 steps")); |
| 573 | assert!(running.contains("Current: running tool · read_file · step 2")); |
| 574 | assert!(!running.contains("Provider:")); |
| 575 | |
| 576 | let waiting = body_for( |
| 577 | SubAgentStatus::Running, |
| 578 | AgentWorkerStatus::WaitingForUser, |
| 579 | Some("Which path should I use?"), |
| 580 | ); |
| 581 | assert!(waiting.contains("State: waiting")); |
| 582 | assert!(waiting.contains("Pending question: Which path should I use?")); |
| 583 | |
| 584 | let failed = body_for( |
| 585 | SubAgentStatus::Failed("verification failed".to_string()), |
| 586 | AgentWorkerStatus::Failed, |
| 587 | Some("verification failed"), |
| 588 | ); |
| 589 | assert!(failed.contains("State: failed")); |
| 590 | assert!(failed.contains("Blocker: verification failed")); |
| 591 | |
| 592 | let completed = body_for( |
| 593 | SubAgentStatus::Completed, |
| 594 | AgentWorkerStatus::Completed, |
| 595 | Some("all checks passed"), |
| 596 | ); |
| 597 | assert!(completed.contains("State: done")); |
| 598 | assert!(completed.contains("Summary: all checks passed")); |
| 599 | } |
| 600 | |
| 601 | #[test] |
| 602 | fn details_projection_matches_primary_agents_row() { |
| 603 | let tmp = tempdir().expect("tempdir"); |
| 604 | let mut app = test_app(tmp.path().to_path_buf()); |
| 605 | let agent_id = "agent_row_agreement"; |
| 606 | let mut child = agent( |
| 607 | agent_id, |
| 608 | SubAgentStatus::Failed("stale failure".to_string()), |
| 609 | ); |
| 610 | child.model = "kimi-k3".to_string(); |
| 611 | child.steps_taken = 7; |
| 612 | child.duration_ms = 61_000; |
| 613 | app.subagent_cache.push(child); |
| 614 | app.agent_progress_meta.insert( |
| 615 | agent_id.to_string(), |
| 616 | AgentProgressMeta { |
| 617 | current_activity: Some(AgentCurrentActivity::bounded( |
| 618 | AgentCurrentActivityStatus::RunningTool, |
| 619 | Some("checking tests".to_string()), |
| 620 | Some("cargo test".to_string()), |
| 621 | Some(7), |
| 622 | )), |
| 623 | resolved_model: Some("stale-meta-model".to_string()), |
| 624 | ..AgentProgressMeta::default() |
| 625 | }, |
| 626 | ); |
| 627 | |
| 628 | let row = crate::tui::sidebar::sidebar_agent_rows(&app) |
| 629 | .into_iter() |
| 630 | .find(|row| row.id == agent_id) |
| 631 | .expect("primary agents row"); |
| 632 | let details = project_agent_details(&app, agent_id).expect("details projection"); |
| 633 | |
| 634 | assert_eq!(row.status, "tool"); |
| 635 | assert_eq!(row.steps_taken, 7); |
| 636 | assert_eq!(row.model.as_deref(), Some("kimi-k3")); |
| 637 | assert_eq!(row.duration_ms, Some(61_000)); |
| 638 | assert!(details.body.contains(&format!( |
| 639 | "State: {} · elapsed {} · {} steps", |
| 640 | row.status, |
| 641 | crate::elapsed::format_elapsed_ms(row.duration_ms.expect("duration")), |
| 642 | row.steps_taken |
| 643 | ))); |
| 644 | assert!(details.body.contains("Model: kimi-k3")); |
| 645 | assert!(!details.body.contains("stale-meta-model")); |
| 646 | } |
| 647 | |
| 648 | #[test] |
| 649 | fn projection_redacts_child_strings_and_never_exposes_raw_ids_or_none() { |
| 650 | let tmp = tempdir().expect("tempdir"); |
| 651 | let mut app = test_app(tmp.path().to_path_buf()); |
| 652 | let agent_id = "agent_secret_child"; |
| 653 | let parent_id = "agent_raw_parent"; |
| 654 | let mut parent = agent(parent_id, SubAgentStatus::Running); |
| 655 | parent.nickname = Some("Parent Whale".to_string()); |
| 656 | let mut child = agent(agent_id, SubAgentStatus::Running); |
| 657 | child.parent_run_id = Some(parent_id.to_string()); |
| 658 | child.spawn_depth = 2; |
| 659 | child.assignment.objective = format!( |
| 660 | "\u{1b}[31minspect {agent_id}\u{1b}[0m with api_key=sk-agent-details-secret-1234567890" |
| 661 | ); |
| 662 | child.git_branch = Some(format!("work/{parent_id}")); |
| 663 | child.model.clear(); |
| 664 | child.nickname = Some(format!("\u{1b}[35m{agent_id}\u{1b}[0m")); |
| 665 | app.subagent_cache.extend([parent, child]); |
| 666 | |
| 667 | let projection = project_agent_details(&app, agent_id).expect("projection"); |
| 668 | let all = format!("{}\n{}", projection.title, projection.body); |
| 669 | assert!(!all.contains(agent_id), "{all}"); |
| 670 | assert!(!all.contains(parent_id), "{all}"); |
| 671 | assert!(!all.contains("sk-agent-details-secret"), "{all}"); |
| 672 | assert!(!all.contains('\u{1b}'), "{all:?}"); |
| 673 | assert!(!all.contains("None"), "{all}"); |
| 674 | assert!(all.contains("[redacted]"), "{all}"); |
| 675 | } |
| 676 | |
| 677 | #[test] |
| 678 | fn external_workspace_is_basename_safe_and_branch_is_bounded() { |
| 679 | let mut app = test_app(PathBuf::from("/repo/main")); |
| 680 | let agent_id = "agent_external_workspace"; |
| 681 | let mut child = agent(agent_id, SubAgentStatus::Running); |
| 682 | child.workspace = Some(PathBuf::from("/private/customer/secret/repo-child")); |
| 683 | child.git_branch = Some("codex/details".to_string()); |
| 684 | app.subagent_cache.push(child); |
| 685 | |
| 686 | let body = project_agent_details(&app, agent_id) |
| 687 | .expect("projection") |
| 688 | .body; |
| 689 | assert!(body.contains("Workspace: repo-child"), "{body}"); |
| 690 | assert!(!body.contains("/private/customer/secret"), "{body}"); |
| 691 | assert!(body.contains("Branch: codex/details"), "{body}"); |
| 692 | } |
| 693 | |
| 694 | #[test] |
| 695 | fn recent_actions_are_bounded_and_render_only_structured_outcomes() { |
| 696 | let tmp = tempdir().expect("tempdir"); |
| 697 | let mut app = test_app(tmp.path().to_path_buf()); |
| 698 | let agent_id = "agent_recent_actions"; |
| 699 | app.subagent_cache |
| 700 | .push(agent(agent_id, SubAgentStatus::Running)); |
| 701 | let mut meta = AgentProgressMeta::default(); |
| 702 | for step in 1..=MAX_AGENT_RECENT_ACTIONS as u32 { |
| 703 | meta.recent_actions.push_back(AgentRecentAction::bounded( |
| 704 | if step == 2 { |
| 705 | "apply_patch" |
| 706 | } else { |
| 707 | "read_file" |
| 708 | }, |
| 709 | step, |
| 710 | step != 2, |
| 711 | )); |
| 712 | } |
| 713 | app.agent_progress_meta.insert(agent_id.to_string(), meta); |
| 714 | |
| 715 | let body = project_agent_details(&app, agent_id) |
| 716 | .expect("projection") |
| 717 | .body; |
| 718 | assert_eq!(body.matches("Recent:").count(), 3, "{body}"); |
| 719 | assert!(body.contains("Recent: ! apply_patch · step 2"), "{body}"); |
| 720 | } |
| 721 | |
| 722 | #[test] |
| 723 | fn alt_v_is_truthful_for_present_and_absent_evidence() { |
| 724 | let tmp = tempdir().expect("tempdir"); |
| 725 | let agent_id = "agent_evidence"; |
| 726 | let mut app = test_app(tmp.path().to_path_buf()); |
| 727 | app.subagent_cache |
| 728 | .push(agent(agent_id, SubAgentStatus::Running)); |
| 729 | let absent = project_agent_details(&app, agent_id).expect("projection"); |
| 730 | assert!(!absent.transcript_available); |
| 731 | let details_chord = crate::tui::shell_key_routing::tool_details_chord(); |
| 732 | let transcript_hint = format!("{details_chord}:transcript"); |
| 733 | assert!(!absent.body.contains(&transcript_hint)); |
| 734 | let mut absent_view = AgentDetailsView::new(absent, agent_id, 80); |
| 735 | assert!(matches!( |
| 736 | absent_view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)), |
| 737 | ViewAction::None |
| 738 | )); |
| 739 | |
| 740 | { |
| 741 | let mut store = app |
| 742 | .runtime_services |
| 743 | .handle_store |
| 744 | .try_lock() |
| 745 | .expect("handle store"); |
| 746 | let _ = store.insert_json( |
| 747 | format!("agent:{agent_id}"), |
| 748 | "full_transcript", |
| 749 | json!({ |
| 750 | "message_count": 1, |
| 751 | "messages": [{ |
| 752 | "role": "assistant", |
| 753 | "content": [{ |
| 754 | "type": "text", |
| 755 | "text": "exact evidence", |
| 756 | "cache_control": null |
| 757 | }] |
| 758 | }] |
| 759 | }), |
| 760 | ); |
| 761 | } |
| 762 | let present = project_agent_details(&app, agent_id).expect("projection"); |
| 763 | assert!(present.transcript_available); |
| 764 | assert!( |
| 765 | present.body.contains(&transcript_hint), |
| 766 | "expected {transcript_hint:?} in {}", |
| 767 | present.body |
| 768 | ); |
| 769 | assert!( |
| 770 | !present.body.contains("Alt/⌥V"), |
| 771 | "dual Alt/⌥ spelling must not appear: {}", |
| 772 | present.body |
| 773 | ); |
| 774 | let mut present_view = AgentDetailsView::new(present, agent_id, 80); |
| 775 | assert!(matches!( |
| 776 | present_view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)), |
| 777 | ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id: ref id }) if id == agent_id |
| 778 | )); |
| 779 | |
| 780 | assert!(open_agent_details(&mut app, agent_id)); |
| 781 | let events = app |
| 782 | .view_stack |
| 783 | .handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)); |
| 784 | assert!(matches!( |
| 785 | events.as_slice(), |
| 786 | [ViewEvent::OpenAgentTranscript { agent_id: id }] if id == agent_id |
| 787 | )); |
| 788 | assert!(crate::tui::mouse_ui::open_agent_chat_pager( |
| 789 | &mut app, agent_id |
| 790 | )); |
| 791 | assert!( |
| 792 | app.view_stack |
| 793 | .handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) |
| 794 | .is_empty(), |
| 795 | "transcript Esc closes only the top pager" |
| 796 | ); |
| 797 | assert!(matches!( |
| 798 | app.view_stack |
| 799 | .handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)) |
| 800 | .as_slice(), |
| 801 | [ViewEvent::OpenAgentTranscript { agent_id: id }] if id == agent_id |
| 802 | )); |
| 803 | } |
| 804 | |
| 805 | #[test] |
| 806 | fn close_keys_emit_receipt_and_80x24_render_stays_safe() { |
| 807 | let tmp = tempdir().expect("tempdir"); |
| 808 | let agent_id = "agent_render_80x24"; |
| 809 | let mut app = test_app(tmp.path().to_path_buf()); |
| 810 | app.subagent_cache |
| 811 | .push(agent(agent_id, SubAgentStatus::Running)); |
| 812 | let projection = project_agent_details(&app, agent_id).expect("projection"); |
| 813 | let mut view = AgentDetailsView::new(projection, agent_id, 80); |
| 814 | assert!(view.title().starts_with("Agent Details — ")); |
| 815 | assert!(view.body_text().contains("Exact evidence: unavailable")); |
| 816 | |
| 817 | let area = Rect::new(0, 0, 80, 24); |
| 818 | let mut buffer = Buffer::empty(area); |
| 819 | view.render(area, &mut buffer); |
| 820 | let rendered = (0..area.height) |
| 821 | .flat_map(|y| (0..area.width).map(move |x| (x, y))) |
| 822 | .map(|point| buffer[point].symbol()) |
| 823 | .collect::<String>(); |
| 824 | assert!(rendered.contains("Agent Details"), "{rendered}"); |
| 825 | assert!(rendered.contains("Assignment"), "{rendered}"); |
| 826 | assert!(!rendered.contains(agent_id), "{rendered}"); |
| 827 | |
| 828 | for code in [KeyCode::Esc, KeyCode::Left] { |
| 829 | assert!(matches!( |
| 830 | view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)), |
| 831 | ViewAction::EmitAndClose(ViewEvent::AgentDetailsClosed { agent_id: ref id }) |
| 832 | if id == agent_id |
| 833 | )); |
| 834 | } |
| 835 | } |
| 836 | } |
| 837 |