| 1 | //! In-transcript cards for sub-agent activity (issue #128). |
| 2 | //! |
| 3 | //! Two cards consume the #130 mailbox stream and render live in the chat |
| 4 | //! transcript: |
| 5 | //! |
| 6 | //! - [`DelegateCard`] — single `agent_spawn` invocation. Live tree of the |
| 7 | //! last 3 actions plus a header with status / glyph / role. |
| 8 | //! - [`FanoutCard`] — `rlm` fanout (or any future multi-child dispatch). |
| 9 | //! Dot-grid of worker slots (`●` filled, `○` pending) plus an aggregate |
| 10 | //! counts line. |
| 11 | //! |
| 12 | //! Both cards are state machines updated by [`apply_to_delegate`] / |
| 13 | //! [`apply_to_fanout`]. The sidebar (see `tui/sidebar.rs`) defers detail |
| 14 | //! to whichever card is active in the transcript, so these are the |
| 15 | //! primary status surface. |
| 16 | |
| 17 | use ratatui::style::{Color, Modifier, Style}; |
| 18 | use ratatui::text::{Line, Span}; |
| 19 | |
| 20 | use crate::palette; |
| 21 | use crate::tools::subagent::MailboxMessage; |
| 22 | use crate::tui::widgets::tool_card::{ToolFamily, family_glyph, family_label}; |
| 23 | |
| 24 | /// Maximum number of recent actions kept on a `DelegateCard`. Older entries |
| 25 | /// are dropped from the head; an ellipsis row signals truncation. |
| 26 | pub const DELEGATE_MAX_ACTIONS: usize = 3; |
| 27 | |
| 28 | /// Lifecycle of a delegated / fanned-out agent. |
| 29 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 30 | pub enum AgentLifecycle { |
| 31 | Pending, |
| 32 | Running, |
| 33 | Completed, |
| 34 | Failed, |
| 35 | Cancelled, |
| 36 | } |
| 37 | |
| 38 | impl AgentLifecycle { |
| 39 | fn is_terminal(self) -> bool { |
| 40 | matches!(self, Self::Completed | Self::Failed | Self::Cancelled) |
| 41 | } |
| 42 | |
| 43 | fn label(self) -> &'static str { |
| 44 | match self { |
| 45 | Self::Pending => "pending", |
| 46 | Self::Running => "running", |
| 47 | Self::Completed => "done", |
| 48 | Self::Failed => "failed", |
| 49 | Self::Cancelled => "cancelled", |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | fn color(self) -> Color { |
| 54 | match self { |
| 55 | Self::Pending => palette::TEXT_MUTED, |
| 56 | Self::Running => palette::STATUS_WARNING, |
| 57 | Self::Completed => palette::STATUS_SUCCESS, |
| 58 | Self::Failed => palette::STATUS_ERROR, |
| 59 | Self::Cancelled => palette::TEXT_MUTED, |
| 60 | } |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | /// Card for a single delegated `agent_spawn` invocation. |
| 65 | /// |
| 66 | /// Stores the last [`DELEGATE_MAX_ACTIONS`] action lines; older entries are |
| 67 | /// truncated and a single ellipsis row is rendered above the visible tail. |
| 68 | #[derive(Debug, Clone)] |
| 69 | pub struct DelegateCard { |
| 70 | pub agent_id: String, |
| 71 | pub agent_type: String, |
| 72 | pub status: AgentLifecycle, |
| 73 | pub summary: Option<String>, |
| 74 | actions: Vec<String>, |
| 75 | truncated: bool, |
| 76 | } |
| 77 | |
| 78 | impl DelegateCard { |
| 79 | #[must_use] |
| 80 | pub fn new(agent_id: impl Into<String>, agent_type: impl Into<String>) -> Self { |
| 81 | Self { |
| 82 | agent_id: agent_id.into(), |
| 83 | agent_type: agent_type.into(), |
| 84 | status: AgentLifecycle::Pending, |
| 85 | summary: None, |
| 86 | actions: Vec::new(), |
| 87 | truncated: false, |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | pub fn push_action(&mut self, action: impl Into<String>) { |
| 92 | self.actions.push(action.into()); |
| 93 | if self.actions.len() > DELEGATE_MAX_ACTIONS { |
| 94 | // Drop one head entry per overflow so steady-state is exactly |
| 95 | // DELEGATE_MAX_ACTIONS lines; the ellipsis row signals the rest. |
| 96 | self.actions.remove(0); |
| 97 | self.truncated = true; |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | #[must_use] |
| 102 | pub fn render_lines(&self, _width: u16) -> Vec<Line<'static>> { |
| 103 | let mut lines = Vec::with_capacity(self.actions.len() + 3); |
| 104 | lines.push(card_header( |
| 105 | ToolFamily::Delegate, |
| 106 | self.status, |
| 107 | &self.agent_type, |
| 108 | &self.agent_id, |
| 109 | )); |
| 110 | if self.truncated { |
| 111 | lines.push(Line::from(Span::styled( |
| 112 | " \u{2026}".to_string(), // … |
| 113 | Style::default().fg(palette::TEXT_MUTED), |
| 114 | ))); |
| 115 | } |
| 116 | for action in &self.actions { |
| 117 | lines.push(Line::from(vec![ |
| 118 | Span::styled(" \u{2502} ", Style::default().fg(palette::TEXT_DIM)), |
| 119 | Span::styled( |
| 120 | truncate_action(action, 200), |
| 121 | Style::default().fg(palette::TEXT_TOOL_OUTPUT), |
| 122 | ), |
| 123 | ])); |
| 124 | } |
| 125 | if self.status.is_terminal() |
| 126 | && let Some(summary) = self.summary.as_ref() |
| 127 | { |
| 128 | lines.push(Line::from(vec![ |
| 129 | Span::styled(" \u{2570} ", Style::default().fg(palette::TEXT_DIM)), |
| 130 | Span::styled( |
| 131 | truncate_action(summary, 200), |
| 132 | Style::default().fg(self.status.color()), |
| 133 | ), |
| 134 | ])); |
| 135 | } |
| 136 | lines |
| 137 | } |
| 138 | |
| 139 | /// Number of actions held — exposed for tests; bounded at |
| 140 | /// `DELEGATE_MAX_ACTIONS`. |
| 141 | #[must_use] |
| 142 | #[cfg(test)] |
| 143 | pub fn action_count(&self) -> usize { |
| 144 | self.actions.len() |
| 145 | } |
| 146 | |
| 147 | /// Whether the head was truncated (older actions dropped). |
| 148 | #[must_use] |
| 149 | #[cfg(test)] |
| 150 | pub fn truncated(&self) -> bool { |
| 151 | self.truncated |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | /// One worker slot in a fanout group. |
| 156 | #[derive(Debug, Clone)] |
| 157 | pub struct WorkerSlot { |
| 158 | /// Stable logical worker key. Stays tied to the worker slot even after a |
| 159 | /// concrete sub-agent id exists. |
| 160 | pub worker_id: String, |
| 161 | /// Concrete agent id once spawned; placeholders use the worker id. |
| 162 | pub agent_id: String, |
| 163 | pub status: AgentLifecycle, |
| 164 | } |
| 165 | |
| 166 | impl WorkerSlot { |
| 167 | #[must_use] |
| 168 | pub fn new(worker_id: impl Into<String>, status: AgentLifecycle) -> Self { |
| 169 | let worker_id = worker_id.into(); |
| 170 | Self { |
| 171 | agent_id: worker_id.clone(), |
| 172 | worker_id, |
| 173 | status, |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | /// Card for `rlm` (or any multi-child dispatch) fanout: dot-grid + |
| 179 | /// aggregate counts. |
| 180 | /// |
| 181 | /// Slots are added as `ChildSpawned` envelopes arrive (or pre-allocated by |
| 182 | /// the engine when the worker count is known up front); each slot |
| 183 | /// transitions independently as its `Completed` / `Failed` / `Cancelled` |
| 184 | /// envelope is observed. |
| 185 | #[derive(Debug, Clone)] |
| 186 | pub struct FanoutCard { |
| 187 | pub kind: String, |
| 188 | pub workers: Vec<WorkerSlot>, |
| 189 | } |
| 190 | |
| 191 | impl FanoutCard { |
| 192 | #[must_use] |
| 193 | pub fn new(kind: impl Into<String>) -> Self { |
| 194 | Self { |
| 195 | kind: kind.into(), |
| 196 | workers: Vec::new(), |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | /// Pre-seed worker slots when the fanout size is known up front. |
| 201 | #[allow(dead_code)] |
| 202 | pub fn with_workers<I, S>(mut self, ids: I) -> Self |
| 203 | where |
| 204 | I: IntoIterator<Item = S>, |
| 205 | S: Into<String>, |
| 206 | { |
| 207 | for id in ids { |
| 208 | self.workers |
| 209 | .push(WorkerSlot::new(id.into(), AgentLifecycle::Pending)); |
| 210 | } |
| 211 | self |
| 212 | } |
| 213 | |
| 214 | /// Update or insert a worker by id. |
| 215 | pub fn upsert_worker(&mut self, agent_id: &str, status: AgentLifecycle) { |
| 216 | if let Some(slot) = self |
| 217 | .workers |
| 218 | .iter_mut() |
| 219 | .find(|s| s.agent_id == agent_id || s.worker_id == agent_id) |
| 220 | { |
| 221 | slot.agent_id = agent_id.to_string(); |
| 222 | slot.status = status; |
| 223 | } else { |
| 224 | self.workers.push(WorkerSlot::new(agent_id, status)); |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | /// Attach a real agent id to the first pending placeholder slot. Fanout |
| 229 | /// cards are seeded from task ids before child agents exist; when a child |
| 230 | /// starts, this keeps the dot count stable instead of appending a second |
| 231 | /// circle for the same unit of work. |
| 232 | pub fn claim_pending_worker(&mut self, agent_id: &str, status: AgentLifecycle) { |
| 233 | if let Some(slot) = self.workers.iter_mut().find(|s| s.agent_id == agent_id) { |
| 234 | slot.status = status; |
| 235 | return; |
| 236 | } |
| 237 | if let Some(slot) = self |
| 238 | .workers |
| 239 | .iter_mut() |
| 240 | .find(|s| matches!(s.status, AgentLifecycle::Pending)) |
| 241 | { |
| 242 | slot.agent_id = agent_id.to_string(); |
| 243 | slot.status = status; |
| 244 | return; |
| 245 | } |
| 246 | self.upsert_worker(agent_id, status); |
| 247 | } |
| 248 | |
| 249 | fn counts(&self) -> (usize, usize, usize, usize) { |
| 250 | let mut done = 0usize; |
| 251 | let mut running = 0usize; |
| 252 | let mut failed = 0usize; |
| 253 | let mut pending = 0usize; |
| 254 | for slot in &self.workers { |
| 255 | match slot.status { |
| 256 | AgentLifecycle::Completed => done += 1, |
| 257 | AgentLifecycle::Running => running += 1, |
| 258 | AgentLifecycle::Failed | AgentLifecycle::Cancelled => failed += 1, |
| 259 | AgentLifecycle::Pending => pending += 1, |
| 260 | } |
| 261 | } |
| 262 | (done, running, failed, pending) |
| 263 | } |
| 264 | |
| 265 | #[must_use] |
| 266 | pub fn dot_grid(&self) -> String { |
| 267 | let mut s = String::with_capacity(self.workers.len()); |
| 268 | for slot in &self.workers { |
| 269 | let glyph = match slot.status { |
| 270 | AgentLifecycle::Completed => '\u{25CF}', // ● |
| 271 | AgentLifecycle::Running => '\u{25D0}', // ◐ |
| 272 | AgentLifecycle::Failed => '\u{00D7}', // × |
| 273 | AgentLifecycle::Cancelled => '\u{2298}', // ⊘ |
| 274 | AgentLifecycle::Pending => '\u{25CB}', // ○ |
| 275 | }; |
| 276 | s.push(glyph); |
| 277 | } |
| 278 | s |
| 279 | } |
| 280 | |
| 281 | #[must_use] |
| 282 | pub fn render_lines(&self, _width: u16) -> Vec<Line<'static>> { |
| 283 | let mut lines = Vec::with_capacity(3); |
| 284 | let header_status = self.aggregate_status(); |
| 285 | let title = format!("{} ({} workers)", self.kind, self.workers.len()); |
| 286 | let family = if self.kind == "rlm" { |
| 287 | ToolFamily::Rlm |
| 288 | } else { |
| 289 | ToolFamily::Fanout |
| 290 | }; |
| 291 | lines.push(card_header(family, header_status, &self.kind, &title)); |
| 292 | lines.push(Line::from(vec![ |
| 293 | Span::styled(" ", Style::default()), |
| 294 | Span::styled( |
| 295 | self.dot_grid(), |
| 296 | Style::default() |
| 297 | .fg(palette::DEEPSEEK_SKY) |
| 298 | .add_modifier(Modifier::BOLD), |
| 299 | ), |
| 300 | ])); |
| 301 | let (done, running, failed, pending) = self.counts(); |
| 302 | lines.push(Line::from(vec![ |
| 303 | Span::styled(" ", Style::default()), |
| 304 | Span::styled( |
| 305 | format!( |
| 306 | "{done} done \u{00B7} {running} running \u{00B7} {failed} failed \u{00B7} {pending} pending" |
| 307 | ), |
| 308 | Style::default().fg(palette::TEXT_MUTED), |
| 309 | ), |
| 310 | ])); |
| 311 | lines |
| 312 | } |
| 313 | |
| 314 | fn aggregate_status(&self) -> AgentLifecycle { |
| 315 | let (done, running, failed, pending) = self.counts(); |
| 316 | if running > 0 || pending > 0 { |
| 317 | AgentLifecycle::Running |
| 318 | } else if failed > 0 && done == 0 { |
| 319 | AgentLifecycle::Failed |
| 320 | } else if done > 0 { |
| 321 | AgentLifecycle::Completed |
| 322 | } else { |
| 323 | AgentLifecycle::Pending |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | /// Worker count (slots seeded or observed via mailbox). |
| 328 | #[must_use] |
| 329 | pub fn worker_count(&self) -> usize { |
| 330 | self.workers.len() |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | fn card_header( |
| 335 | family: ToolFamily, |
| 336 | status: AgentLifecycle, |
| 337 | role: &str, |
| 338 | detail: &str, |
| 339 | ) -> Line<'static> { |
| 340 | let glyph = family_glyph(family); |
| 341 | let verb = family_label(family); |
| 342 | let header_color = status.color(); |
| 343 | Line::from(vec![ |
| 344 | Span::styled( |
| 345 | format!("{glyph} "), |
| 346 | Style::default() |
| 347 | .fg(header_color) |
| 348 | .add_modifier(Modifier::BOLD), |
| 349 | ), |
| 350 | Span::styled( |
| 351 | verb.to_string(), |
| 352 | Style::default() |
| 353 | .fg(header_color) |
| 354 | .add_modifier(Modifier::BOLD), |
| 355 | ), |
| 356 | Span::raw(" "), |
| 357 | Span::styled(role.to_string(), Style::default().fg(palette::TEXT_PRIMARY)), |
| 358 | Span::raw(" "), |
| 359 | Span::styled( |
| 360 | format!("[{}]", status.label()), |
| 361 | Style::default().fg(header_color), |
| 362 | ), |
| 363 | Span::raw(" "), |
| 364 | Span::styled(detail.to_string(), Style::default().fg(palette::TEXT_MUTED)), |
| 365 | ]) |
| 366 | } |
| 367 | |
| 368 | fn truncate_action(text: &str, max: usize) -> String { |
| 369 | let trimmed = text.trim(); |
| 370 | if trimmed.chars().count() <= max { |
| 371 | trimmed.to_string() |
| 372 | } else { |
| 373 | let mut out: String = trimmed.chars().take(max.saturating_sub(1)).collect(); |
| 374 | out.push('\u{2026}'); |
| 375 | out |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | /// Apply a mailbox envelope to a `DelegateCard`. Returns `true` if the |
| 380 | /// state changed (UI may want to redraw); `false` if the envelope was for |
| 381 | /// a different `agent_id`. |
| 382 | pub fn apply_to_delegate(card: &mut DelegateCard, msg: &MailboxMessage) -> bool { |
| 383 | if msg.agent_id() != card.agent_id { |
| 384 | return false; |
| 385 | } |
| 386 | match msg { |
| 387 | MailboxMessage::Started { .. } => { |
| 388 | card.status = AgentLifecycle::Running; |
| 389 | } |
| 390 | MailboxMessage::Progress { status, .. } => { |
| 391 | card.status = AgentLifecycle::Running; |
| 392 | card.push_action(status); |
| 393 | } |
| 394 | MailboxMessage::ToolCallStarted { |
| 395 | tool_name, step, .. |
| 396 | } => { |
| 397 | card.push_action(format!("[{step}] {tool_name} started")); |
| 398 | } |
| 399 | MailboxMessage::ToolCallCompleted { |
| 400 | tool_name, |
| 401 | step, |
| 402 | ok, |
| 403 | .. |
| 404 | } => { |
| 405 | card.push_action(format!( |
| 406 | "[{step}] {tool_name} {}", |
| 407 | if *ok { "ok" } else { "failed" } |
| 408 | )); |
| 409 | } |
| 410 | MailboxMessage::Completed { summary, .. } => { |
| 411 | card.status = AgentLifecycle::Completed; |
| 412 | card.summary = Some(summary.clone()); |
| 413 | } |
| 414 | MailboxMessage::Failed { error, .. } => { |
| 415 | card.status = AgentLifecycle::Failed; |
| 416 | card.summary = Some(error.clone()); |
| 417 | } |
| 418 | MailboxMessage::Cancelled { .. } => { |
| 419 | card.status = AgentLifecycle::Cancelled; |
| 420 | } |
| 421 | MailboxMessage::ChildSpawned { .. } => { |
| 422 | // Delegate cards represent a single agent; child spawns belong |
| 423 | // to a sibling fanout card, not this one. |
| 424 | return false; |
| 425 | } |
| 426 | MailboxMessage::TokenUsage { .. } => { |
| 427 | // Cost accumulation happens in handle_subagent_mailbox (ui.rs) |
| 428 | // before this apply function is called; TokenUsage never reaches |
| 429 | // this arm in practice. |
| 430 | return false; |
| 431 | } |
| 432 | } |
| 433 | true |
| 434 | } |
| 435 | |
| 436 | /// Apply a mailbox envelope to a `FanoutCard`. Updates per-worker state |
| 437 | /// based on which child the envelope is about. Returns `true` on change. |
| 438 | pub fn apply_to_fanout(card: &mut FanoutCard, msg: &MailboxMessage) -> bool { |
| 439 | let id = msg.agent_id(); |
| 440 | match msg { |
| 441 | MailboxMessage::Started { .. } => { |
| 442 | card.claim_pending_worker(id, AgentLifecycle::Running); |
| 443 | true |
| 444 | } |
| 445 | MailboxMessage::Progress { .. } | MailboxMessage::ToolCallStarted { .. } => { |
| 446 | card.claim_pending_worker(id, AgentLifecycle::Running); |
| 447 | true |
| 448 | } |
| 449 | MailboxMessage::ToolCallCompleted { .. } => true, |
| 450 | MailboxMessage::Completed { .. } => { |
| 451 | card.upsert_worker(id, AgentLifecycle::Completed); |
| 452 | true |
| 453 | } |
| 454 | MailboxMessage::Failed { .. } => { |
| 455 | card.upsert_worker(id, AgentLifecycle::Failed); |
| 456 | true |
| 457 | } |
| 458 | MailboxMessage::Cancelled { .. } => { |
| 459 | card.upsert_worker(id, AgentLifecycle::Cancelled); |
| 460 | true |
| 461 | } |
| 462 | MailboxMessage::ChildSpawned { child_id, .. } => { |
| 463 | card.upsert_worker(child_id, AgentLifecycle::Pending); |
| 464 | true |
| 465 | } |
| 466 | MailboxMessage::TokenUsage { .. } => { |
| 467 | // Cost accumulation happens in handle_subagent_mailbox (ui.rs) |
| 468 | // before this apply function is called; TokenUsage never reaches |
| 469 | // this arm in practice. |
| 470 | true |
| 471 | } |
| 472 | } |
| 473 | } |
| 474 | |
| 475 | #[cfg(test)] |
| 476 | mod tests { |
| 477 | use super::*; |
| 478 | |
| 479 | fn render_to_strings(lines: &[Line<'static>]) -> Vec<String> { |
| 480 | lines |
| 481 | .iter() |
| 482 | .map(|line| { |
| 483 | line.spans |
| 484 | .iter() |
| 485 | .map(|span| span.content.as_ref()) |
| 486 | .collect::<String>() |
| 487 | }) |
| 488 | .collect() |
| 489 | } |
| 490 | |
| 491 | #[test] |
| 492 | fn delegate_card_truncates_to_last_three_actions_with_ellipsis() { |
| 493 | let mut card = DelegateCard::new("agent_001", "general"); |
| 494 | card.push_action("read README.md"); |
| 495 | card.push_action("grep TODO"); |
| 496 | card.push_action("edit src/lib.rs"); |
| 497 | // Up to the limit — no truncation yet. |
| 498 | assert!(!card.truncated()); |
| 499 | assert_eq!(card.action_count(), DELEGATE_MAX_ACTIONS); |
| 500 | |
| 501 | card.push_action("write tests"); |
| 502 | card.push_action("run cargo test"); |
| 503 | assert!(card.truncated(), "truncation flag flips on overflow"); |
| 504 | assert_eq!( |
| 505 | card.action_count(), |
| 506 | DELEGATE_MAX_ACTIONS, |
| 507 | "stable steady-state size" |
| 508 | ); |
| 509 | |
| 510 | let rendered = render_to_strings(&card.render_lines(80)); |
| 511 | assert!( |
| 512 | rendered.iter().any(|line| line.contains('\u{2026}')), |
| 513 | "ellipsis indicator must render: got {rendered:?}" |
| 514 | ); |
| 515 | // The oldest two actions ("read README.md", "grep TODO") were dropped. |
| 516 | assert!( |
| 517 | !rendered.iter().any(|line| line.contains("read README.md")), |
| 518 | "oldest action evicted: got {rendered:?}" |
| 519 | ); |
| 520 | assert!( |
| 521 | rendered.iter().any(|line| line.contains("run cargo test")), |
| 522 | "newest action retained: got {rendered:?}" |
| 523 | ); |
| 524 | assert!( |
| 525 | rendered.iter().any(|line| line.contains("write tests")), |
| 526 | "second-newest retained: got {rendered:?}" |
| 527 | ); |
| 528 | assert!( |
| 529 | rendered.iter().any(|line| line.contains("edit src/lib.rs")), |
| 530 | "third-newest retained: got {rendered:?}" |
| 531 | ); |
| 532 | } |
| 533 | |
| 534 | #[test] |
| 535 | fn delegate_card_terminal_status_renders_summary_row() { |
| 536 | let mut card = DelegateCard::new("agent_002", "explore"); |
| 537 | card.push_action("listing files"); |
| 538 | let msg = MailboxMessage::Completed { |
| 539 | agent_id: "agent_002".into(), |
| 540 | summary: "scanned 42 files, no TODOs found".into(), |
| 541 | }; |
| 542 | assert!(apply_to_delegate(&mut card, &msg)); |
| 543 | assert_eq!(card.status, AgentLifecycle::Completed); |
| 544 | let rendered = render_to_strings(&card.render_lines(80)); |
| 545 | assert!( |
| 546 | rendered |
| 547 | .iter() |
| 548 | .any(|line| line.contains("scanned 42 files")), |
| 549 | "summary row renders on terminal status: got {rendered:?}" |
| 550 | ); |
| 551 | } |
| 552 | |
| 553 | #[test] |
| 554 | fn delegate_card_ignores_envelopes_for_other_agents() { |
| 555 | let mut card = DelegateCard::new("agent_a", "general"); |
| 556 | let other = MailboxMessage::progress("agent_b", "noise"); |
| 557 | assert!(!apply_to_delegate(&mut card, &other)); |
| 558 | assert_eq!(card.action_count(), 0); |
| 559 | } |
| 560 | |
| 561 | #[test] |
| 562 | fn fanout_card_dot_grid_renders_stateful_worker_slots() { |
| 563 | let mut card = FanoutCard::new("fanout") |
| 564 | .with_workers(["w_1", "w_2", "w_3", "w_4", "w_5", "w_6", "w_7"]); |
| 565 | card.upsert_worker("w_1", AgentLifecycle::Completed); |
| 566 | card.upsert_worker("w_2", AgentLifecycle::Completed); |
| 567 | card.upsert_worker("w_3", AgentLifecycle::Running); |
| 568 | card.upsert_worker("w_4", AgentLifecycle::Failed); |
| 569 | // 5/6/7 stay Pending. |
| 570 | |
| 571 | // Completed fills; running and failed are distinct; pending stays open. |
| 572 | assert_eq!( |
| 573 | card.dot_grid(), |
| 574 | "\u{25CF}\u{25CF}\u{25D0}\u{00D7}\u{25CB}\u{25CB}\u{25CB}" |
| 575 | ); |
| 576 | } |
| 577 | |
| 578 | #[test] |
| 579 | fn fanout_card_aggregate_counts_match_dot_grid() { |
| 580 | let mut card = FanoutCard::new("rlm").with_workers(["w_1", "w_2", "w_3", "w_4"]); |
| 581 | card.upsert_worker("w_1", AgentLifecycle::Completed); |
| 582 | card.upsert_worker("w_2", AgentLifecycle::Completed); |
| 583 | card.upsert_worker("w_3", AgentLifecycle::Completed); |
| 584 | card.upsert_worker("w_4", AgentLifecycle::Failed); |
| 585 | let rendered = render_to_strings(&card.render_lines(80)); |
| 586 | // The stats row is the one carrying "running" too; the header may |
| 587 | // mention "done" alone via the lifecycle status badge. |
| 588 | let stats = rendered |
| 589 | .iter() |
| 590 | .find(|line| line.contains("running") && line.contains("pending")) |
| 591 | .expect("counts line present"); |
| 592 | assert!(stats.contains("3 done"), "completed count: {stats}"); |
| 593 | assert!( |
| 594 | stats.contains("1 failed"), |
| 595 | "failed/cancelled fold into the same bucket: {stats}" |
| 596 | ); |
| 597 | assert!(stats.contains("0 running"), "no running: {stats}"); |
| 598 | assert!(stats.contains("0 pending"), "no pending: {stats}"); |
| 599 | } |
| 600 | |
| 601 | #[test] |
| 602 | fn fanout_apply_inserts_unknown_worker_via_child_spawned() { |
| 603 | let mut card = FanoutCard::new("fanout"); |
| 604 | let msg = MailboxMessage::ChildSpawned { |
| 605 | parent_id: "root".into(), |
| 606 | child_id: "agent_late".into(), |
| 607 | }; |
| 608 | assert!(apply_to_fanout(&mut card, &msg)); |
| 609 | assert_eq!(card.worker_count(), 1); |
| 610 | assert_eq!(card.workers[0].agent_id, "agent_late"); |
| 611 | assert_eq!(card.workers[0].status, AgentLifecycle::Pending); |
| 612 | } |
| 613 | |
| 614 | #[test] |
| 615 | fn fanout_started_claims_seeded_pending_slot_without_growing_grid() { |
| 616 | let mut card = FanoutCard::new("fanout").with_workers(["task:a", "task:b"]); |
| 617 | let started = |
| 618 | MailboxMessage::started("agent_live", crate::tools::subagent::SubAgentType::General); |
| 619 | |
| 620 | assert!(apply_to_fanout(&mut card, &started)); |
| 621 | |
| 622 | assert_eq!(card.worker_count(), 2); |
| 623 | assert_eq!(card.workers[0].agent_id, "agent_live"); |
| 624 | assert_eq!(card.workers[0].status, AgentLifecycle::Running); |
| 625 | assert_eq!(card.workers[1].agent_id, "task:b"); |
| 626 | assert_eq!(card.workers[1].status, AgentLifecycle::Pending); |
| 627 | } |
| 628 | |
| 629 | #[test] |
| 630 | fn fanout_apply_transitions_worker_through_lifecycle() { |
| 631 | let mut card = FanoutCard::new("fanout").with_workers(["w_1"]); |
| 632 | let started = MailboxMessage::started("w_1", crate::tools::subagent::SubAgentType::General); |
| 633 | apply_to_fanout(&mut card, &started); |
| 634 | assert_eq!(card.workers[0].status, AgentLifecycle::Running); |
| 635 | |
| 636 | let done = MailboxMessage::Completed { |
| 637 | agent_id: "w_1".into(), |
| 638 | summary: "ok".into(), |
| 639 | }; |
| 640 | apply_to_fanout(&mut card, &done); |
| 641 | assert_eq!(card.workers[0].status, AgentLifecycle::Completed); |
| 642 | } |
| 643 | |
| 644 | #[test] |
| 645 | fn fanout_dot_grid_arithmetic_for_various_n() { |
| 646 | // Spot-check several fanout sizes with a mix of states; this is the |
| 647 | // arithmetic snapshot the issue acceptance calls out. |
| 648 | let cases: &[(usize, usize, &str)] = &[ |
| 649 | (1, 0, "\u{25CB}"), |
| 650 | (1, 1, "\u{25CF}"), |
| 651 | (3, 2, "\u{25CF}\u{25CF}\u{25CB}"), |
| 652 | ( |
| 653 | 7, |
| 654 | 3, |
| 655 | "\u{25CF}\u{25CF}\u{25CF}\u{25CB}\u{25CB}\u{25CB}\u{25CB}", |
| 656 | ), |
| 657 | ]; |
| 658 | for (total, done, expected) in cases { |
| 659 | let ids: Vec<String> = (0..*total).map(|i| format!("w_{i}")).collect(); |
| 660 | let mut card = FanoutCard::new("fanout").with_workers(ids.iter().cloned()); |
| 661 | for id in ids.iter().take(*done) { |
| 662 | card.upsert_worker(id, AgentLifecycle::Completed); |
| 663 | } |
| 664 | assert_eq!( |
| 665 | card.dot_grid(), |
| 666 | *expected, |
| 667 | "fanout dot-grid for total={total} done={done}", |
| 668 | ); |
| 669 | } |
| 670 | } |
| 671 | } |
| 672 |