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