| 1 | //! Sub-agent row projection shared by the agent details view and the |
| 2 | //! roster: display name, typed status word, and child progress per worker. |
| 3 | //! |
| 4 | //! This is what is left of the classic sidebar (Pinned / Activity / Agents / |
| 5 | //! Context line panels). Those panels were retired when every dock view moved |
| 6 | //! onto the work surface's row pipeline (2026-09-02); the projection below is |
| 7 | //! the part other surfaces still read. |
| 8 | |
| 9 | use crate::tools::subagent::{AgentWorkerStatus, SubAgentStatus, localized_whale_display_names}; |
| 10 | |
| 11 | use super::app::{AgentCurrentActivityStatus, App}; |
| 12 | |
| 13 | #[derive(Debug, Clone, Default)] |
| 14 | pub struct SidebarAgentRow { |
| 15 | pub id: String, |
| 16 | pub parent_run_id: Option<String>, |
| 17 | pub name: String, |
| 18 | pub model: Option<String>, |
| 19 | pub status: String, |
| 20 | pub steps_taken: u32, |
| 21 | pub duration_ms: Option<u64>, |
| 22 | /// `(settled, total)` over this row's direct children, when it has any |
| 23 | /// (#5479). A fan-out parent's own status says nothing about whether the |
| 24 | /// work it launched is finished; this is the "5/6 agents done" fact the |
| 25 | /// rail otherwise makes you count by eye. `None` for a leaf. |
| 26 | pub children_settled: Option<(usize, usize)>, |
| 27 | } |
| 28 | |
| 29 | pub(crate) fn sidebar_agent_rows(app: &App) -> Vec<SidebarAgentRow> { |
| 30 | let cached_ids: std::collections::HashSet<&str> = app |
| 31 | .subagent_cache |
| 32 | .iter() |
| 33 | .map(|agent| agent.agent_id.as_str()) |
| 34 | .collect(); |
| 35 | let display_names = localized_whale_display_names( |
| 36 | app.subagent_cache |
| 37 | .iter() |
| 38 | .map(|agent| (agent.agent_id.as_str(), agent.nickname.as_deref())), |
| 39 | app.ui_locale.tag(), |
| 40 | ); |
| 41 | let mut rows: Vec<SidebarAgentRow> = app |
| 42 | .subagent_cache |
| 43 | .iter() |
| 44 | .map(|agent| { |
| 45 | let current_activity = app |
| 46 | .agent_progress_meta |
| 47 | .get(&agent.agent_id) |
| 48 | .and_then(|meta| meta.current_activity.as_ref()); |
| 49 | // The dispatch name leads (#5287). Generated whales name the |
| 50 | // agents that have none, locale-derived from the neutral agent |
| 51 | // id; never replay a persisted label from another language. |
| 52 | let display_name = app |
| 53 | .agent_given_name(&agent.agent_id) |
| 54 | .or_else(|| { |
| 55 | agent |
| 56 | .child_route |
| 57 | .as_ref() |
| 58 | .and_then(|route| route.resolved_profile_id.as_deref()) |
| 59 | .map(str::trim) |
| 60 | .filter(|profile| !profile.is_empty()) |
| 61 | .map(str::to_string) |
| 62 | }) |
| 63 | .or_else(|| display_names.get(&agent.agent_id).cloned()) |
| 64 | .or_else(|| app.agent_label_map.get(&agent.agent_id).cloned()) |
| 65 | .unwrap_or_else(|| agent.name.clone()); |
| 66 | SidebarAgentRow { |
| 67 | id: agent.agent_id.clone(), |
| 68 | parent_run_id: agent.parent_run_id.clone(), |
| 69 | name: display_name, |
| 70 | model: Some(agent.model.clone()).filter(|model| !model.trim().is_empty()), |
| 71 | status: current_activity |
| 72 | .map(|activity| { |
| 73 | sidebar_current_activity_status_text(activity.status, app.ui_locale) |
| 74 | }) |
| 75 | .or_else(|| { |
| 76 | agent.worker_status.map(|status| { |
| 77 | std::borrow::Cow::Borrowed(sidebar_worker_status_text(status)) |
| 78 | }) |
| 79 | }) |
| 80 | .unwrap_or_else(|| { |
| 81 | std::borrow::Cow::Borrowed(subagent_status_text(&agent.status)) |
| 82 | }) |
| 83 | .into_owned(), |
| 84 | steps_taken: agent.steps_taken, |
| 85 | duration_ms: Some(agent.duration_ms), |
| 86 | // Filled in by `annotate_child_progress` once every row exists. |
| 87 | children_settled: None, |
| 88 | } |
| 89 | }) |
| 90 | .collect(); |
| 91 | |
| 92 | rows.extend( |
| 93 | app.agent_progress |
| 94 | .iter() |
| 95 | .filter(|(id, _)| !cached_ids.contains(id.as_str())) |
| 96 | .map(|(id, _progress)| { |
| 97 | // Progress-only rows do not carry a generated whale name yet; |
| 98 | // keep their existing stable Agent-N placeholder until the |
| 99 | // manager snapshot arrives. |
| 100 | let display_name = app |
| 101 | .agent_label_map |
| 102 | .get(id.as_str()) |
| 103 | .cloned() |
| 104 | .unwrap_or_else(|| id.clone()); |
| 105 | let meta = app.agent_progress_meta.get(id.as_str()); |
| 106 | let current_activity = meta.and_then(|meta| meta.current_activity.as_ref()); |
| 107 | SidebarAgentRow { |
| 108 | id: id.clone(), |
| 109 | parent_run_id: meta.and_then(|meta| meta.parent_run_id.clone()), |
| 110 | name: display_name, |
| 111 | model: meta.and_then(|meta| meta.resolved_model.clone()), |
| 112 | status: current_activity |
| 113 | .map(|activity| { |
| 114 | sidebar_current_activity_status_text(activity.status, app.ui_locale) |
| 115 | }) |
| 116 | .unwrap_or(std::borrow::Cow::Borrowed(sidebar_worker_status_text( |
| 117 | AgentWorkerStatus::Running, |
| 118 | ))) |
| 119 | .into_owned(), |
| 120 | steps_taken: 0, |
| 121 | duration_ms: None, |
| 122 | children_settled: None, |
| 123 | } |
| 124 | }), |
| 125 | ); |
| 126 | |
| 127 | let mut rows = sort_sidebar_agent_rows_as_tree(rows); |
| 128 | annotate_child_progress(&mut rows); |
| 129 | rows |
| 130 | } |
| 131 | |
| 132 | /// Fill in each row's `children_settled` from its direct children. |
| 133 | /// |
| 134 | /// Counted over the rows actually present: a child whose record has aged out of |
| 135 | /// the ledger cannot be counted, and inventing a denominator that included it |
| 136 | /// would misreport progress as worse than it is. |
| 137 | fn annotate_child_progress(rows: &mut [SidebarAgentRow]) { |
| 138 | let mut totals: std::collections::HashMap<String, (usize, usize)> = |
| 139 | std::collections::HashMap::new(); |
| 140 | for row in rows.iter() { |
| 141 | let Some(parent) = row.parent_run_id.as_deref() else { |
| 142 | continue; |
| 143 | }; |
| 144 | let entry = totals.entry(parent.to_string()).or_insert((0, 0)); |
| 145 | entry.1 += 1; |
| 146 | if sidebar_agent_status_is_terminal(row.status.as_str()) { |
| 147 | entry.0 += 1; |
| 148 | } |
| 149 | } |
| 150 | for row in rows.iter_mut() { |
| 151 | row.children_settled = totals.get(&row.id).copied(); |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | fn sort_sidebar_agent_rows_as_tree(rows: Vec<SidebarAgentRow>) -> Vec<SidebarAgentRow> { |
| 156 | let known_ids: std::collections::HashSet<String> = |
| 157 | rows.iter().map(|row| row.id.clone()).collect(); |
| 158 | let mut children: std::collections::HashMap<String, Vec<usize>> = |
| 159 | std::collections::HashMap::new(); |
| 160 | let mut roots = Vec::new(); |
| 161 | |
| 162 | for (idx, row) in rows.iter().enumerate() { |
| 163 | if let Some(parent) = row.parent_run_id.as_deref() |
| 164 | && known_ids.contains(parent) |
| 165 | { |
| 166 | children.entry(parent.to_string()).or_default().push(idx); |
| 167 | continue; |
| 168 | } |
| 169 | roots.push(idx); |
| 170 | } |
| 171 | |
| 172 | fn push_tree( |
| 173 | idx: usize, |
| 174 | rows: &[SidebarAgentRow], |
| 175 | children: &std::collections::HashMap<String, Vec<usize>>, |
| 176 | seen: &mut std::collections::HashSet<usize>, |
| 177 | order: &mut Vec<usize>, |
| 178 | ) { |
| 179 | if !seen.insert(idx) { |
| 180 | return; |
| 181 | } |
| 182 | order.push(idx); |
| 183 | if let Some(child_indices) = children.get(&rows[idx].id) { |
| 184 | for child_idx in child_indices { |
| 185 | push_tree(*child_idx, rows, children, seen, order); |
| 186 | } |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | let mut order = Vec::with_capacity(rows.len()); |
| 191 | let mut seen = std::collections::HashSet::new(); |
| 192 | for idx in roots { |
| 193 | push_tree(idx, &rows, &children, &mut seen, &mut order); |
| 194 | } |
| 195 | for idx in 0..rows.len() { |
| 196 | push_tree(idx, &rows, &children, &mut seen, &mut order); |
| 197 | } |
| 198 | |
| 199 | // Materialize by move instead of cloning each row a second time (#3898): |
| 200 | // `seen` guarantees every index lands in `order` exactly once, so each |
| 201 | // slot is taken exactly once and no row is dropped. |
| 202 | let mut slots: Vec<Option<SidebarAgentRow>> = rows.into_iter().map(Some).collect(); |
| 203 | order |
| 204 | .into_iter() |
| 205 | .map(|idx| slots[idx].take().expect("each row emitted exactly once")) |
| 206 | .collect() |
| 207 | } |
| 208 | |
| 209 | fn subagent_status_text(status: &SubAgentStatus) -> &'static str { |
| 210 | match status { |
| 211 | SubAgentStatus::Running => "running", |
| 212 | SubAgentStatus::Completed => "done", |
| 213 | SubAgentStatus::Interrupted(_) => "interrupted", |
| 214 | SubAgentStatus::Failed(_) => "failed", |
| 215 | SubAgentStatus::Cancelled => "canceled", |
| 216 | SubAgentStatus::BudgetExhausted => "budget", |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | fn sidebar_worker_status_text(status: AgentWorkerStatus) -> &'static str { |
| 221 | match status { |
| 222 | AgentWorkerStatus::Queued => "queued", |
| 223 | AgentWorkerStatus::Starting => "starting", |
| 224 | AgentWorkerStatus::Running => "running", |
| 225 | AgentWorkerStatus::WaitingForUser => "waiting", |
| 226 | AgentWorkerStatus::ModelWait => "model wait", |
| 227 | AgentWorkerStatus::RunningTool => "tool", |
| 228 | AgentWorkerStatus::Completed => "done", |
| 229 | AgentWorkerStatus::Failed => "failed", |
| 230 | AgentWorkerStatus::Cancelled => "canceled", |
| 231 | AgentWorkerStatus::Interrupted => "interrupted", |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | fn sidebar_current_activity_status_text( |
| 236 | status: AgentCurrentActivityStatus, |
| 237 | locale: codewhale_localization::Locale, |
| 238 | ) -> std::borrow::Cow<'static, str> { |
| 239 | // A parked husk gets its own word, translated (#5906) — "waiting" here |
| 240 | // would be the same lie the work surface used to tell. |
| 241 | if status == AgentCurrentActivityStatus::Parked { |
| 242 | return codewhale_localization::tr( |
| 243 | locale, |
| 244 | codewhale_localization::MessageId::AgentStatusParked, |
| 245 | ); |
| 246 | } |
| 247 | std::borrow::Cow::Borrowed(match status { |
| 248 | AgentCurrentActivityStatus::Queued => "queued", |
| 249 | AgentCurrentActivityStatus::Starting => "starting", |
| 250 | AgentCurrentActivityStatus::Running => "running", |
| 251 | AgentCurrentActivityStatus::ModelWait => "model wait", |
| 252 | AgentCurrentActivityStatus::RunningTool => "tool", |
| 253 | AgentCurrentActivityStatus::Waiting => "waiting", |
| 254 | AgentCurrentActivityStatus::Done => "done", |
| 255 | AgentCurrentActivityStatus::Failed => "failed", |
| 256 | AgentCurrentActivityStatus::Canceled => "canceled", |
| 257 | AgentCurrentActivityStatus::Interrupted => "interrupted", |
| 258 | AgentCurrentActivityStatus::Parked => unreachable!("handled above"), |
| 259 | }) |
| 260 | } |
| 261 | |
| 262 | fn sidebar_agent_status_is_terminal(status: &str) -> bool { |
| 263 | matches!( |
| 264 | status, |
| 265 | "done" | "canceled" | "failed" | "interrupted" | "budget" |
| 266 | ) |
| 267 | } |
| 268 | |
| 269 | #[cfg(test)] |
| 270 | mod tests { |
| 271 | use super::sidebar_agent_rows; |
| 272 | use crate::config::Config; |
| 273 | use crate::tui::app::{ |
| 274 | AgentCurrentActivity, AgentCurrentActivityStatus, AgentProgressMeta, App, |
| 275 | SidebarHoverSection, SidebarHoverState, TuiOptions, |
| 276 | }; |
| 277 | use codewhale_localization::Locale; |
| 278 | use std::path::PathBuf; |
| 279 | |
| 280 | fn create_test_app() -> App { |
| 281 | let options = TuiOptions { |
| 282 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 283 | }; |
| 284 | let mut app = App::new(options, &Config::default()); |
| 285 | // Legacy strip geometry (see ui.rs); Bottom default has its own tests. |
| 286 | app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Top; |
| 287 | app |
| 288 | } |
| 289 | |
| 290 | // ---- Sidebar hover tooltip tests ---- |
| 291 | |
| 292 | #[test] |
| 293 | fn sidebar_hover_state_default_is_empty() { |
| 294 | let state = SidebarHoverState::default(); |
| 295 | assert!(state.sections.is_empty()); |
| 296 | } |
| 297 | |
| 298 | #[test] |
| 299 | fn sidebar_hover_section_stores_lines() { |
| 300 | use ratatui::layout::Rect; |
| 301 | let section = SidebarHoverSection { |
| 302 | content_area: Rect::new(1, 1, 38, 8), |
| 303 | lines: vec!["line 1".to_string(), "line 2".to_string()], |
| 304 | rows: vec![], |
| 305 | }; |
| 306 | assert_eq!(section.lines.len(), 2); |
| 307 | assert_eq!(section.lines[0], "line 1"); |
| 308 | assert!(section.content_area.x > 0); |
| 309 | } |
| 310 | |
| 311 | #[test] |
| 312 | fn hover_line_matching_respects_content_area_offset() { |
| 313 | use ratatui::layout::Rect; |
| 314 | let section = SidebarHoverSection { |
| 315 | content_area: Rect::new(62, 2, 36, 6), |
| 316 | lines: vec![ |
| 317 | "first".to_string(), |
| 318 | "second".to_string(), |
| 319 | "third".to_string(), |
| 320 | ], |
| 321 | rows: vec![], |
| 322 | }; |
| 323 | |
| 324 | // Mouse within content area, first line |
| 325 | let line_idx = (2u16.saturating_sub(section.content_area.y)) as usize; |
| 326 | assert_eq!(section.lines[line_idx], "first"); |
| 327 | |
| 328 | // Mouse within content area, second line |
| 329 | let line_idx = (3u16.saturating_sub(section.content_area.y)) as usize; |
| 330 | assert_eq!(section.lines[line_idx], "second"); |
| 331 | |
| 332 | // Mouse outside content area (above) — row < content_area.y |
| 333 | assert!((1u16) < section.content_area.y); |
| 334 | } |
| 335 | |
| 336 | // ── #3030: stable labels instead of raw internal ids ─────────────────── |
| 337 | |
| 338 | #[test] |
| 339 | fn ensure_agent_label_assigns_stable_sequential_labels() { |
| 340 | let mut app = create_test_app(); |
| 341 | assert_eq!(app.ensure_agent_label("agent_aaa111"), "Agent 1"); |
| 342 | assert_eq!(app.ensure_agent_label("agent_bbb222"), "Agent 2"); |
| 343 | // Re-seeing a known agent keeps its original label. |
| 344 | assert_eq!(app.ensure_agent_label("agent_aaa111"), "Agent 1"); |
| 345 | assert_eq!(app.agent_counter, 2); |
| 346 | // Read-only lookup falls back to the raw id for unknown agents. |
| 347 | assert_eq!(app.agent_display_label("agent_bbb222"), "Agent 2"); |
| 348 | assert_eq!(app.agent_display_label("agent_zzz999"), "agent_zzz999"); |
| 349 | } |
| 350 | |
| 351 | #[test] |
| 352 | fn ensure_agent_label_prefers_identity_over_the_counter() { |
| 353 | let mut app = create_test_app(); |
| 354 | let route = |profile: Option<&str>, role: &str| { |
| 355 | Some(crate::tools::subagent::ChildRouteReceipt { |
| 356 | requested_type: "custom".to_string(), |
| 357 | requested_profile: profile.map(str::to_string), |
| 358 | resolved_profile_id: None, |
| 359 | profile_origin: None, |
| 360 | canonical_role: role.to_string(), |
| 361 | provider_id: "deepseek".to_string(), |
| 362 | model_id: "deepseek-v4-pro".to_string(), |
| 363 | route_source: "roster".to_string(), |
| 364 | fallback_note: None, |
| 365 | requested_reasoning: "inherit".to_string(), |
| 366 | effective_reasoning: None, |
| 367 | runtime_version: "test".to_string(), |
| 368 | runtime_build_sha: "unknown".to_string(), |
| 369 | }) |
| 370 | }; |
| 371 | |
| 372 | let mut named = cached_agent("agent_named", None); |
| 373 | named.name = "branch-triage".to_string(); |
| 374 | app.subagent_cache.push(named); |
| 375 | |
| 376 | let mut role = cached_agent("agent_role", None); |
| 377 | role.assignment.role = Some("reviewer".to_string()); |
| 378 | app.subagent_cache.push(role); |
| 379 | |
| 380 | let mut profile = cached_agent("agent_profile", None); |
| 381 | profile.assignment.role = None; |
| 382 | profile.child_route = route(Some("release-lead"), "custom"); |
| 383 | app.subagent_cache.push(profile); |
| 384 | |
| 385 | let mut canonical = cached_agent("agent_canonical", None); |
| 386 | canonical.assignment.role = None; |
| 387 | canonical.child_route = route(None, "planner"); |
| 388 | app.subagent_cache.push(canonical); |
| 389 | |
| 390 | let mut typed = cached_agent("agent_typed", None); |
| 391 | typed.assignment.role = None; |
| 392 | typed.agent_type = crate::tools::subagent::FleetRole::Builder; |
| 393 | app.subagent_cache.push(typed); |
| 394 | |
| 395 | // The dispatch name leads, annotated with the role when the role is |
| 396 | // not already part of the name. |
| 397 | assert_eq!( |
| 398 | app.ensure_agent_label("agent_named"), |
| 399 | "branch-triage · general" |
| 400 | ); |
| 401 | // Unnamed children go by their role (#6565: the engine's one name); |
| 402 | // a suffix appears only when another agent already shows that name. |
| 403 | assert_eq!(app.ensure_agent_label("agent_role"), "reviewer"); |
| 404 | assert_eq!(app.ensure_agent_label("agent_profile"), "release-lead"); |
| 405 | assert_eq!(app.ensure_agent_label("agent_canonical"), "planner"); |
| 406 | assert_eq!(app.ensure_agent_label("agent_typed"), "implement"); |
| 407 | |
| 408 | // A progress-only agent first seen before its metadata arrives gets a |
| 409 | // counter placeholder, then upgrades once the identity is observed. |
| 410 | assert_eq!(app.ensure_agent_label("agent_late"), "Agent 1"); |
| 411 | let mut late = cached_agent("agent_late", None); |
| 412 | late.assignment.role = Some("verifier".to_string()); |
| 413 | app.subagent_cache.push(late); |
| 414 | assert_eq!(app.ensure_agent_label("agent_late"), "test"); |
| 415 | } |
| 416 | |
| 417 | #[test] |
| 418 | fn ensure_agent_label_disambiguates_concurrent_same_role_children() { |
| 419 | let mut app = create_test_app(); |
| 420 | |
| 421 | let mut first = cached_agent("agent_builder_a", None); |
| 422 | first.assignment.role = None; |
| 423 | first.agent_type = crate::tools::subagent::FleetRole::Builder; |
| 424 | app.subagent_cache.push(first); |
| 425 | |
| 426 | let mut second = cached_agent("agent_builder_b", None); |
| 427 | second.assignment.role = None; |
| 428 | second.agent_type = crate::tools::subagent::FleetRole::Builder; |
| 429 | app.subagent_cache.push(second); |
| 430 | |
| 431 | assert_eq!(app.ensure_agent_label("agent_builder_a"), "implement"); |
| 432 | assert_eq!(app.ensure_agent_label("agent_builder_b"), "implement · 2"); |
| 433 | // Stability: re-seeing a known builder keeps its assigned label. |
| 434 | assert_eq!(app.ensure_agent_label("agent_builder_a"), "implement"); |
| 435 | assert_eq!(app.ensure_agent_label("agent_builder_b"), "implement · 2"); |
| 436 | |
| 437 | // A different role needs no suffix. |
| 438 | let mut reviewer = cached_agent("agent_reviewer_a", None); |
| 439 | reviewer.assignment.role = Some("reviewer".to_string()); |
| 440 | app.subagent_cache.push(reviewer); |
| 441 | assert_eq!(app.ensure_agent_label("agent_reviewer_a"), "reviewer"); |
| 442 | } |
| 443 | |
| 444 | #[test] |
| 445 | fn ensure_agent_label_named_child_skips_role_suffix_when_present() { |
| 446 | let mut app = create_test_app(); |
| 447 | |
| 448 | let mut named = cached_agent("agent_named", None); |
| 449 | named.name = "release-lead".to_string(); |
| 450 | named.assignment.role = None; |
| 451 | named.child_route = Some(crate::tools::subagent::ChildRouteReceipt { |
| 452 | requested_type: "custom".to_string(), |
| 453 | requested_profile: Some("release-lead".to_string()), |
| 454 | resolved_profile_id: None, |
| 455 | profile_origin: None, |
| 456 | canonical_role: "release-lead".to_string(), |
| 457 | provider_id: "deepseek".to_string(), |
| 458 | model_id: "deepseek-v4-pro".to_string(), |
| 459 | route_source: "roster".to_string(), |
| 460 | fallback_note: None, |
| 461 | requested_reasoning: "inherit".to_string(), |
| 462 | effective_reasoning: None, |
| 463 | runtime_version: "test".to_string(), |
| 464 | runtime_build_sha: "unknown".to_string(), |
| 465 | }); |
| 466 | app.subagent_cache.push(named); |
| 467 | |
| 468 | // The role is already part of the name, so no duplicate suffix. |
| 469 | assert_eq!(app.ensure_agent_label("agent_named"), "release-lead"); |
| 470 | } |
| 471 | |
| 472 | fn cached_agent( |
| 473 | agent_id: &str, |
| 474 | nickname: Option<&str>, |
| 475 | ) -> crate::tools::subagent::SubAgentResult { |
| 476 | crate::tools::subagent::SubAgentResult { |
| 477 | usage: None, |
| 478 | // An unnamed dispatch: the manager seeds `name` with the agent id |
| 479 | // and only replaces it when the caller supplied one. |
| 480 | name: agent_id.to_string(), |
| 481 | agent_id: agent_id.to_string(), |
| 482 | context_mode: "fresh".to_string(), |
| 483 | fork_context: false, |
| 484 | workspace: None, |
| 485 | git_branch: None, |
| 486 | agent_type: crate::tools::subagent::FleetRole::Worker, |
| 487 | assignment: crate::tools::subagent::SubAgentAssignment { |
| 488 | native_preset: None, |
| 489 | objective: "task".to_string(), |
| 490 | role: Some("worker".to_string()), |
| 491 | }, |
| 492 | model: String::new(), |
| 493 | nickname: nickname.map(str::to_string), |
| 494 | status: crate::tools::subagent::SubAgentStatus::Running, |
| 495 | worker_status: None, |
| 496 | runtime_permissions: None, |
| 497 | parent_run_id: None, |
| 498 | spawn_depth: 0, |
| 499 | child_route: None, |
| 500 | result: None, |
| 501 | steps_taken: 1, |
| 502 | checkpoint: None, |
| 503 | needs_input: None, |
| 504 | duration_ms: 100, |
| 505 | started_at: None, |
| 506 | from_prior_session: false, |
| 507 | idle_ms: None, |
| 508 | heartbeat_timeout_ms: None, |
| 509 | } |
| 510 | } |
| 511 | |
| 512 | // === #5479: a fan-out parent shows how much of its fan-out is done === |
| 513 | |
| 514 | #[test] |
| 515 | fn a_fanout_parent_row_reports_how_many_children_have_settled() { |
| 516 | let mut app = create_test_app(); |
| 517 | let parent = cached_agent("workflow_parent", None); |
| 518 | for index in 0..6 { |
| 519 | let mut child = cached_agent(&format!("child_{index}"), None); |
| 520 | child.parent_run_id = Some("workflow_parent".to_string()); |
| 521 | child.spawn_depth = 1; |
| 522 | if index < 5 { |
| 523 | child.status = crate::tools::subagent::SubAgentStatus::Completed; |
| 524 | child.worker_status = Some(crate::tools::subagent::AgentWorkerStatus::Completed); |
| 525 | } |
| 526 | app.subagent_cache.push(child); |
| 527 | } |
| 528 | app.subagent_cache.push(parent); |
| 529 | |
| 530 | let rows = sidebar_agent_rows(&app); |
| 531 | let parent_row = rows |
| 532 | .iter() |
| 533 | .find(|row| row.id == "workflow_parent") |
| 534 | .expect("parent row"); |
| 535 | assert_eq!( |
| 536 | parent_row.children_settled, |
| 537 | Some((5, 6)), |
| 538 | "the parent's own status says nothing about its fan-out" |
| 539 | ); |
| 540 | for row in rows.iter().filter(|row| row.id != "workflow_parent") { |
| 541 | assert_eq!( |
| 542 | row.children_settled, None, |
| 543 | "a leaf must not claim a fan-out it does not have" |
| 544 | ); |
| 545 | } |
| 546 | } |
| 547 | |
| 548 | #[test] |
| 549 | fn a_parent_whose_children_aged_out_reports_no_progress_rather_than_zero() { |
| 550 | // A denominator that counted rows no longer in the ledger would report |
| 551 | // progress as worse than it is. |
| 552 | let mut app = create_test_app(); |
| 553 | app.subagent_cache.push(cached_agent("lonely_parent", None)); |
| 554 | let rows = sidebar_agent_rows(&app); |
| 555 | assert_eq!(rows[0].children_settled, None); |
| 556 | } |
| 557 | |
| 558 | #[test] |
| 559 | fn sidebar_agent_rows_use_worker_status_from_cached_agents() { |
| 560 | let mut app = create_test_app(); |
| 561 | let mut agent = cached_agent("agent_model_wait", Some("Blue")); |
| 562 | agent.worker_status = Some(crate::tools::subagent::AgentWorkerStatus::ModelWait); |
| 563 | app.subagent_cache.push(agent); |
| 564 | |
| 565 | let rows = sidebar_agent_rows(&app); |
| 566 | |
| 567 | assert_eq!(rows.len(), 1); |
| 568 | assert_eq!(rows[0].status, "model wait"); |
| 569 | } |
| 570 | |
| 571 | #[test] |
| 572 | fn sidebar_agent_rows_project_typed_lifecycle_fixtures() { |
| 573 | let mut app = create_test_app(); |
| 574 | let fixtures = [ |
| 575 | ( |
| 576 | "agent_running", |
| 577 | "Running", |
| 578 | crate::tools::subagent::SubAgentStatus::Running, |
| 579 | crate::tools::subagent::AgentWorkerStatus::RunningTool, |
| 580 | AgentCurrentActivityStatus::RunningTool, |
| 581 | "tool", |
| 582 | ), |
| 583 | ( |
| 584 | "agent_waiting", |
| 585 | "Waiting", |
| 586 | crate::tools::subagent::SubAgentStatus::Interrupted("approval".to_string()), |
| 587 | crate::tools::subagent::AgentWorkerStatus::WaitingForUser, |
| 588 | AgentCurrentActivityStatus::Waiting, |
| 589 | "waiting", |
| 590 | ), |
| 591 | ( |
| 592 | "agent_failed", |
| 593 | "Failed", |
| 594 | crate::tools::subagent::SubAgentStatus::Failed("verification".to_string()), |
| 595 | crate::tools::subagent::AgentWorkerStatus::Failed, |
| 596 | AgentCurrentActivityStatus::Failed, |
| 597 | "failed", |
| 598 | ), |
| 599 | ( |
| 600 | "agent_done", |
| 601 | "Done", |
| 602 | crate::tools::subagent::SubAgentStatus::Completed, |
| 603 | crate::tools::subagent::AgentWorkerStatus::Completed, |
| 604 | AgentCurrentActivityStatus::Done, |
| 605 | "done", |
| 606 | ), |
| 607 | ]; |
| 608 | for (id, nickname, status, worker_status, activity_status, _) in &fixtures { |
| 609 | let mut agent = cached_agent(id, Some(nickname)); |
| 610 | agent.status = status.clone(); |
| 611 | agent.worker_status = Some(*worker_status); |
| 612 | app.subagent_cache.push(agent); |
| 613 | app.agent_progress_meta.insert( |
| 614 | (*id).to_string(), |
| 615 | AgentProgressMeta { |
| 616 | current_activity: Some(AgentCurrentActivity::bounded( |
| 617 | *activity_status, |
| 618 | (*id == "agent_waiting").then_some("approval required".to_string()), |
| 619 | (*id == "agent_running").then_some("read_file".to_string()), |
| 620 | Some(2), |
| 621 | )), |
| 622 | ..AgentProgressMeta::default() |
| 623 | }, |
| 624 | ); |
| 625 | } |
| 626 | |
| 627 | let rows = sidebar_agent_rows(&app); |
| 628 | for (id, _, _, _, _, expected_status) in fixtures { |
| 629 | let row = rows |
| 630 | .iter() |
| 631 | .find(|row| row.id == id) |
| 632 | .expect("typed lifecycle row"); |
| 633 | assert_eq!(row.status, expected_status); |
| 634 | } |
| 635 | } |
| 636 | |
| 637 | #[test] |
| 638 | fn sidebar_progress_only_rows_never_infer_status_from_display_text() { |
| 639 | let mut app = create_test_app(); |
| 640 | app.ensure_agent_label("agent_queued"); |
| 641 | app.agent_progress.insert( |
| 642 | "agent_queued".to_string(), |
| 643 | "queued waiting failed completed".to_string(), |
| 644 | ); |
| 645 | |
| 646 | let rows = sidebar_agent_rows(&app); |
| 647 | |
| 648 | assert_eq!(rows.len(), 1); |
| 649 | assert_eq!(rows[0].name, "Agent 1"); |
| 650 | assert_eq!(rows[0].status, "running"); |
| 651 | |
| 652 | app.agent_progress_meta.insert( |
| 653 | "agent_queued".to_string(), |
| 654 | AgentProgressMeta { |
| 655 | current_activity: Some(AgentCurrentActivity::bounded( |
| 656 | AgentCurrentActivityStatus::Queued, |
| 657 | Some("waiting for launch permit".to_string()), |
| 658 | None, |
| 659 | None, |
| 660 | )), |
| 661 | ..AgentProgressMeta::default() |
| 662 | }, |
| 663 | ); |
| 664 | crate::tui::ui::record_agent_spawned_route(&mut app, "agent_queued", "deepseek-v4-pro"); |
| 665 | let rows = sidebar_agent_rows(&app); |
| 666 | assert_eq!(rows[0].status, "queued"); |
| 667 | assert_eq!(rows[0].model.as_deref(), Some("deepseek-v4-pro")); |
| 668 | } |
| 669 | |
| 670 | #[test] |
| 671 | fn sidebar_agent_rows_preserve_explicit_names_and_derive_whales_from_locale() { |
| 672 | let mut app = create_test_app(); |
| 673 | let agent_id = "agent_cafe0123"; |
| 674 | app.ensure_agent_label(agent_id); |
| 675 | app.subagent_cache |
| 676 | .push(cached_agent(agent_id, Some("doc-fixer"))); |
| 677 | |
| 678 | let rows = super::sidebar_agent_rows(&app); |
| 679 | assert_eq!( |
| 680 | rows[0].name, "doc-fixer", |
| 681 | "an explicit custom nickname remains user-owned" |
| 682 | ); |
| 683 | |
| 684 | // Without an explicit nickname, display is derived from the neutral id |
| 685 | // in the active UI locale rather than from the old Agent-N label. |
| 686 | app.subagent_cache[0].nickname = None; |
| 687 | let rows = super::sidebar_agent_rows(&app); |
| 688 | assert_eq!( |
| 689 | rows[0].name, |
| 690 | crate::tools::subagent::whale_name_for_id_in_locale(agent_id, "en") |
| 691 | ); |
| 692 | } |
| 693 | |
| 694 | #[test] |
| 695 | fn sidebar_agent_rows_lead_with_the_dispatch_name() { |
| 696 | // #5287: operators dispatch by name and think by name, so the session |
| 697 | // name outranks both the generated whale and the Agent-N label. |
| 698 | let mut app = create_test_app(); |
| 699 | let agent_id = "agent_cafe0123"; |
| 700 | app.ensure_agent_label(agent_id); |
| 701 | let whale = crate::tools::subagent::whale_name_for_id_in_locale(agent_id, "en"); |
| 702 | let mut agent = cached_agent(agent_id, Some(&whale)); |
| 703 | agent.name = "branch-triage".to_string(); |
| 704 | app.subagent_cache.push(agent); |
| 705 | |
| 706 | let rows = super::sidebar_agent_rows(&app); |
| 707 | assert_eq!(rows[0].name, "branch-triage"); |
| 708 | } |
| 709 | |
| 710 | #[test] |
| 711 | fn sidebar_agent_rows_prefer_resolved_profile_over_generated_whale() { |
| 712 | let mut app = create_test_app(); |
| 713 | let agent_id = "agent_cafe0123"; |
| 714 | app.ensure_agent_label(agent_id); |
| 715 | let whale = crate::tools::subagent::whale_name_for_id_in_locale(agent_id, "en"); |
| 716 | let mut agent = cached_agent(agent_id, Some(&whale)); |
| 717 | agent.child_route = Some(crate::tools::subagent::ChildRouteReceipt { |
| 718 | requested_type: "custom".to_string(), |
| 719 | requested_profile: Some("DeepSeek V4 Flash".to_string()), |
| 720 | resolved_profile_id: Some("flash-scout".to_string()), |
| 721 | profile_origin: Some("fleet:release".to_string()), |
| 722 | canonical_role: "scout".to_string(), |
| 723 | provider_id: "deepseek".to_string(), |
| 724 | model_id: "deepseek-v4-flash-vision-exp".to_string(), |
| 725 | route_source: "fleet".to_string(), |
| 726 | fallback_note: None, |
| 727 | requested_reasoning: "inherit".to_string(), |
| 728 | effective_reasoning: None, |
| 729 | runtime_version: "test".to_string(), |
| 730 | runtime_build_sha: "unknown".to_string(), |
| 731 | }); |
| 732 | app.subagent_cache.push(agent); |
| 733 | |
| 734 | let rows = super::sidebar_agent_rows(&app); |
| 735 | assert_eq!(rows[0].name, "flash-scout"); |
| 736 | } |
| 737 | |
| 738 | #[test] |
| 739 | fn english_sidebar_relocalizes_mixed_persisted_whale_names() { |
| 740 | let mut app = create_test_app(); |
| 741 | app.ui_locale = Locale::En; |
| 742 | for (agent_id, legacy_locale) in [ |
| 743 | ("agent_locale_a", "zh-Hans"), |
| 744 | ("agent_locale_b", "ja"), |
| 745 | ("agent_locale_c", "vi"), |
| 746 | ] { |
| 747 | let legacy_name = |
| 748 | crate::tools::subagent::whale_name_for_id_in_locale(agent_id, legacy_locale); |
| 749 | app.subagent_cache |
| 750 | .push(cached_agent(agent_id, Some(&legacy_name))); |
| 751 | } |
| 752 | |
| 753 | let rows = super::sidebar_agent_rows(&app); |
| 754 | assert_eq!(rows.len(), 3); |
| 755 | for row in rows { |
| 756 | assert!( |
| 757 | row.name.is_ascii(), |
| 758 | "English Fleet display leaked a prior-locale whale: {}", |
| 759 | row.name |
| 760 | ); |
| 761 | assert_eq!( |
| 762 | row.name, |
| 763 | crate::tools::subagent::whale_name_for_id_in_locale(&row.id, "en") |
| 764 | ); |
| 765 | } |
| 766 | } |
| 767 | |
| 768 | // === #5906: parked husks vs. children that actually asked ============ |
| 769 | |
| 770 | /// A child parked at the parent's turn end is handed a `needs_input` note |
| 771 | /// phrased as a question ("Resume this parked child with ..."), which is |
| 772 | /// why every surface used to label it `waiting`. Build one exactly the way |
| 773 | /// the runtime does and assert the row names the state instead. |
| 774 | fn parked_agent(agent_id: &str) -> crate::tools::subagent::SubAgentResult { |
| 775 | let mut agent = cached_agent(agent_id, None); |
| 776 | agent.status = crate::tools::subagent::SubAgentStatus::Interrupted( |
| 777 | "Parent turn ended before this turn-owned child settled.".to_string(), |
| 778 | ); |
| 779 | agent.worker_status = Some(crate::tools::subagent::AgentWorkerStatus::WaitingForUser); |
| 780 | agent.needs_input = Some(crate::tools::subagent::SubAgentNeedsInput { |
| 781 | question: format!( |
| 782 | "Resume this parked child with agent(action=\"start\", resume_from=\"{agent_id}\")." |
| 783 | ), |
| 784 | }); |
| 785 | agent.checkpoint = Some(crate::tools::subagent::SubAgentCheckpoint { |
| 786 | checkpoint_id: format!("{agent_id}:step:2"), |
| 787 | agent_id: agent_id.to_string(), |
| 788 | continuation_handle: format!("agent:{agent_id}:checkpoint"), |
| 789 | reason: "Parent turn ended before this turn-owned child settled.".to_string(), |
| 790 | continuable: true, |
| 791 | steps_taken: 2, |
| 792 | message_count: 4, |
| 793 | created_at_ms: 1_000, |
| 794 | messages: Vec::new(), |
| 795 | omitted_messages: 0, |
| 796 | parked_at_turn_end: true, |
| 797 | }); |
| 798 | agent |
| 799 | } |
| 800 | |
| 801 | fn asking_agent(agent_id: &str) -> crate::tools::subagent::SubAgentResult { |
| 802 | let mut agent = cached_agent(agent_id, None); |
| 803 | agent.worker_status = Some(crate::tools::subagent::AgentWorkerStatus::WaitingForUser); |
| 804 | agent.needs_input = Some(crate::tools::subagent::SubAgentNeedsInput { |
| 805 | question: "Which path should I use?".to_string(), |
| 806 | }); |
| 807 | agent |
| 808 | } |
| 809 | |
| 810 | #[test] |
| 811 | fn a_parked_row_says_parked_and_a_real_question_still_says_waiting() { |
| 812 | let mut app = create_test_app(); |
| 813 | app.subagent_cache.push(parked_agent("agent_parked")); |
| 814 | app.subagent_cache.push(asking_agent("agent_asking")); |
| 815 | crate::tui::subagent_routing::reconcile_subagent_activity_state(&mut app); |
| 816 | |
| 817 | let rows = sidebar_agent_rows(&app); |
| 818 | let parked = rows |
| 819 | .iter() |
| 820 | .find(|row| row.id == "agent_parked") |
| 821 | .expect("parked row"); |
| 822 | let asking = rows |
| 823 | .iter() |
| 824 | .find(|row| row.id == "agent_asking") |
| 825 | .expect("asking row"); |
| 826 | |
| 827 | assert_eq!(parked.status, "parked"); |
| 828 | assert_ne!( |
| 829 | parked.status, "waiting", |
| 830 | "a parked husk must not wear the label a child a user can answer wears" |
| 831 | ); |
| 832 | assert_eq!(asking.status, "waiting"); |
| 833 | } |
| 834 | |
| 835 | /// The status word is registry copy, not a hardcoded English literal. |
| 836 | #[test] |
| 837 | fn the_parked_status_word_follows_the_ui_locale() { |
| 838 | let mut app = create_test_app(); |
| 839 | app.ui_locale = Locale::De; |
| 840 | app.subagent_cache.push(parked_agent("agent_parked_de")); |
| 841 | crate::tui::subagent_routing::reconcile_subagent_activity_state(&mut app); |
| 842 | |
| 843 | let rows = sidebar_agent_rows(&app); |
| 844 | assert_eq!( |
| 845 | rows[0].status, |
| 846 | codewhale_localization::tr( |
| 847 | Locale::De, |
| 848 | codewhale_localization::MessageId::AgentStatusParked |
| 849 | ) |
| 850 | ); |
| 851 | } |
| 852 | |
| 853 | // --- Unicode / CJK / terminal-width QA (issue #3488) ------------------- |
| 854 | // The sub-agent overlay renders CJK display names next to ASCII ids, |
| 855 | // numeric columns (step count, elapsed), status verbs, and branch lines. |
| 856 | // These guard that a CJK name never shifts the status columns, corrupts the |
| 857 | // panel border, or hides the running/completed state (#3488 dogfood case: |
| 858 | // a worker named 抹香鲸). |
| 859 | } |
| 860 |