返回 CodeWhale
agent_card.rs
根目录 / crates / tui / src / tui / widgets / agent_card.rs
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` 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); header owns lifecycle.
10 //!
11 //! Both cards are state machines updated by [`apply_to_delegate`] /
12 //! [`apply_to_fanout`]. The sidebar (see `tui/sidebar.rs`) defers detail
13 //! to whichever card is active in the transcript, so these are the
14 //! primary status surface.
15
16 use std::time::Instant;
17
18 use ratatui::style::{Color, Modifier, Style};
19 use ratatui::text::{Line, Span};
20
21 use crate::fleet::role::public_role_label;
22 use crate::todo_snapshot::{TodoCardProjection, card_omission_line, card_todo_projection};
23 use crate::tools::subagent::MailboxMessage;
24 use crate::tools::todo::TodoListSnapshot;
25 use crate::tui::ui_text::truncate_line_to_width;
26 use crate::tui::widgets::tool_card::{ToolFamily, family_glyph};
27 use codewhale_palette as palette;
28 use unicode_width::UnicodeWidthStr;
29
30 /// Maximum number of recent actions kept on a `DelegateCard`. Older entries
31 /// are dropped from the head; an ellipsis row signals truncation.
32 pub const DELEGATE_MAX_ACTIONS: usize = 3;
33
34 /// Lifecycle of a delegated / fanned-out agent.
35 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
36 pub enum AgentLifecycle {
37 Pending,
38 Running,
39 Completed,
40 Failed,
41 Cancelled,
42 /// Interrupted with a continuable checkpoint (e.g. API timeout); not
43 /// running, but recoverable from its checkpoint.
44 Interrupted,
45 }
46
47 impl AgentLifecycle {
48 fn is_terminal(self) -> bool {
49 matches!(
50 self,
51 Self::Completed | Self::Failed | Self::Cancelled | Self::Interrupted
52 )
53 }
54
55 #[must_use]
56 pub fn label(self) -> &'static str {
57 match self {
58 Self::Pending => "pending",
59 Self::Running => "running",
60 Self::Completed => "done",
61 Self::Failed => "failed",
62 Self::Cancelled => "cancelled",
63 Self::Interrupted => "interrupted",
64 }
65 }
66
67 /// Semantic status color only — never the whole-card identity tint.
68 /// cyan/teal = running, amber = waiting/pending, green = done, red = failed.
69 #[must_use]
70 pub fn ink(self) -> codewhale_palette::grammar::ChromeInk {
71 match self {
72 Self::Pending => codewhale_palette::grammar::ChromeInk::Waiting,
73 Self::Running => codewhale_palette::grammar::ChromeInk::Active,
74 Self::Completed => codewhale_palette::grammar::ChromeInk::Outcome,
75 Self::Failed => codewhale_palette::grammar::ChromeInk::Failure,
76 Self::Cancelled => codewhale_palette::grammar::ChromeInk::Metadata,
77 Self::Interrupted => codewhale_palette::grammar::ChromeInk::Attention,
78 }
79 }
80
81 #[must_use]
82 pub fn color(self, theme: &palette::UiTheme) -> Color {
83 self.ink().color(theme)
84 }
85 }
86
87 /// Card for a single delegated `agent` invocation.
88 ///
89 /// Stores the last [`DELEGATE_MAX_ACTIONS`] action lines; older entries are
90 /// truncated and a single ellipsis row is rendered above the visible tail.
91 #[derive(Debug, Clone)]
92 pub struct DelegateCard {
93 pub agent_id: String,
94 pub agent_type: String,
95 pub status: AgentLifecycle,
96 pub summary: Option<String>,
97 actions: Vec<String>,
98 truncated: bool,
99 pub started_at: Option<Instant>,
100 pub finished_at: Option<Instant>,
101 /// The last To-do snapshot **this** agent published for itself (#4810).
102 ///
103 /// `None` means the child has never reported Work state — the card says
104 /// nothing rather than borrowing the parent's or a sibling's list. The
105 /// snapshot is only ever written from an envelope whose `agent_id` matches
106 /// [`Self::agent_id`], which is what keeps sibling cards disjoint.
107 todo: Option<TodoListSnapshot>,
108 }
109
110 impl DelegateCard {
111 #[must_use]
112 pub fn new(agent_id: impl Into<String>, agent_type: impl Into<String>) -> Self {
113 Self {
114 agent_id: agent_id.into(),
115 agent_type: agent_type.into(),
116 status: AgentLifecycle::Pending,
117 summary: None,
118 actions: Vec::new(),
119 truncated: false,
120 started_at: None,
121 finished_at: None,
122 todo: None,
123 }
124 }
125
126 /// Record this agent's own To-do snapshot. Returns whether the visible
127 /// projection changed (an update that renders identically is not a
128 /// redraw). Callers must only pass a snapshot published by this agent.
129 pub fn set_todo(&mut self, todo: TodoListSnapshot) -> bool {
130 let before = self.todo.as_ref().and_then(card_todo_projection);
131 let after = card_todo_projection(&todo);
132 self.todo = Some(todo);
133 before != after
134 }
135
136 /// The child's own To-do projection, if it has reported any work.
137 #[must_use]
138 pub fn todo_projection(&self) -> Option<TodoCardProjection> {
139 self.todo.as_ref().and_then(card_todo_projection)
140 }
141
142 /// Project this direct sub-agent card onto the shared workflow history
143 /// renderer (#4122) so collapsed/expanded concepts stay aligned.
144 #[must_use]
145 #[allow(dead_code)] // public #4122 convergence API; covered by unit tests
146 pub fn as_workflow_history_panel(
147 &self,
148 started_at_ms: u64,
149 completed_at_ms: Option<u64>,
150 ) -> crate::tui::widgets::workflow_panel::WorkflowPanel {
151 use crate::tui::widgets::workflow_panel::{WorkflowPanel, WorkflowPanelLifecycle};
152 let lifecycle = match self.status {
153 AgentLifecycle::Pending => WorkflowPanelLifecycle::Pending,
154 AgentLifecycle::Running => WorkflowPanelLifecycle::Running,
155 AgentLifecycle::Completed => WorkflowPanelLifecycle::Succeeded,
156 AgentLifecycle::Failed => WorkflowPanelLifecycle::Failed,
157 AgentLifecycle::Cancelled => WorkflowPanelLifecycle::Cancelled,
158 AgentLifecycle::Interrupted => WorkflowPanelLifecycle::Failed,
159 };
160 WorkflowPanel::from_direct_subagent(
161 self.agent_id.clone(),
162 public_role_label(&self.agent_type),
163 lifecycle,
164 started_at_ms,
165 completed_at_ms,
166 self.summary.clone(),
167 if matches!(self.status, AgentLifecycle::Failed) {
168 self.summary.clone()
169 } else {
170 None
171 },
172 )
173 }
174
175 pub fn push_action(&mut self, action: impl Into<String>) {
176 self.actions.push(action.into());
177 if self.actions.len() > DELEGATE_MAX_ACTIONS {
178 // Drop one head entry per overflow so steady-state is exactly
179 // DELEGATE_MAX_ACTIONS lines; the ellipsis row signals the rest.
180 self.actions.remove(0);
181 self.truncated = true;
182 }
183 }
184
185 #[must_use]
186 pub fn render_lines(&self, width: u16, theme: &palette::UiTheme) -> Vec<Line<'static>> {
187 let mut lines = Vec::with_capacity(self.actions.len() + 3);
188 let content_width = usize::from(width);
189 let role = public_role_label(&self.agent_type);
190 let short_id = crate::session_manager::truncate_id(&self.agent_id).to_string();
191 let detail = if self.status.is_terminal() {
192 String::new()
193 } else if let Some(action) = self.actions.last() {
194 truncate_action(action, 72)
195 } else {
196 short_id
197 };
198 lines.push(delegate_header(
199 self.status,
200 &role,
201 &detail,
202 content_width,
203 theme,
204 ));
205 // The child's own Work state sits directly under its header, above the
206 // action tail: what it is working on outranks what it just did.
207 if let Some(todo) = self.todo_projection() {
208 let prefix = "\u{22EF} "; // ⋯
209 lines.push(Line::from(vec![
210 Span::styled(prefix, Style::default().fg(palette::TEXT_DIM)),
211 Span::styled(
212 truncate_action(&todo.header, line_detail_width(content_width, prefix)),
213 Style::default()
214 .fg(codewhale_palette::grammar::ChromeInk::Metadata.color(theme)),
215 ),
216 ]));
217 let item_prefix = " ";
218 for item in &todo.items {
219 lines.push(Line::from(vec![
220 Span::raw(item_prefix),
221 Span::styled(
222 truncate_action(item, line_detail_width(content_width, item_prefix)),
223 Style::default()
224 .fg(codewhale_palette::grammar::ChromeInk::Metadata.color(theme)),
225 ),
226 ]));
227 }
228 if todo.omitted > 0 {
229 lines.push(Line::from(vec![
230 Span::raw(item_prefix),
231 Span::styled(
232 truncate_action(
233 &card_omission_line(todo.omitted),
234 line_detail_width(content_width, item_prefix),
235 ),
236 Style::default()
237 .fg(codewhale_palette::grammar::ChromeInk::Metadata.color(theme)),
238 ),
239 ]));
240 }
241 }
242 if self.truncated {
243 lines.push(Line::from(Span::styled(
244 "\u{2026}".to_string(), // …
245 Style::default().fg(codewhale_palette::grammar::ChromeInk::Metadata.color(theme)),
246 )));
247 }
248 for action in self
249 .actions
250 .iter()
251 .take(self.actions.len().saturating_sub(1))
252 {
253 let prefix = "\u{2502} ";
254 lines.push(Line::from(vec![
255 Span::styled(
256 prefix,
257 Style::default()
258 .fg(codewhale_palette::grammar::ChromeInk::Metadata.color(theme)),
259 ),
260 Span::styled(
261 truncate_action(action, line_detail_width(content_width, prefix).min(200)),
262 Style::default()
263 .fg(codewhale_palette::grammar::ChromeInk::Metadata.color(theme)),
264 ),
265 ]));
266 }
267 if self.status.is_terminal() {
268 let mut terminal = self.status.label().to_string();
269 if let (Some(started), Some(finished)) = (self.started_at, self.finished_at) {
270 terminal.push_str(" · ");
271 terminal.push_str(&crate::elapsed::format_elapsed_ms(
272 finished.duration_since(started).as_millis() as u64,
273 ));
274 }
275 if let Some(summary) = self
276 .summary
277 .as_deref()
278 .filter(|summary| !summary.is_empty())
279 {
280 terminal.push_str(" · ");
281 terminal.push_str(summary);
282 }
283 let prefix = "\u{2570} ";
284 lines.push(Line::from(Span::styled(
285 format!("{prefix}{terminal}"),
286 Style::default().fg(self.status.color(theme)),
287 )));
288 }
289 lines
290 }
291
292 /// Number of actions held — exposed for tests; bounded at
293 /// `DELEGATE_MAX_ACTIONS`.
294 #[must_use]
295 #[cfg(test)]
296 pub fn action_count(&self) -> usize {
297 self.actions.len()
298 }
299
300 /// Whether the head was truncated (older actions dropped).
301 #[must_use]
302 #[cfg(test)]
303 pub fn truncated(&self) -> bool {
304 self.truncated
305 }
306 }
307
308 /// One worker slot in a fanout group.
309 #[derive(Debug, Clone)]
310 pub struct WorkerSlot {
311 /// Stable logical worker key. Stays tied to the worker slot even after a
312 /// concrete sub-agent id exists.
313 pub worker_id: String,
314 /// Concrete agent id once spawned; placeholders use the worker id.
315 pub agent_id: String,
316 pub status: AgentLifecycle,
317 }
318
319 impl WorkerSlot {
320 #[must_use]
321 pub fn new(worker_id: impl Into<String>, status: AgentLifecycle) -> Self {
322 let worker_id = worker_id.into();
323 Self {
324 agent_id: worker_id.clone(),
325 worker_id,
326 status,
327 }
328 }
329 }
330
331 /// Card for `rlm` (or any multi-child dispatch) fanout: dot-grid +
332 /// aggregate counts.
333 ///
334 /// Slots are added as `ChildSpawned` envelopes arrive (or pre-allocated by
335 /// the engine when the worker count is known up front); each slot
336 /// transitions independently as its `Completed` / `Failed` / `Cancelled`
337 /// envelope is observed.
338 #[derive(Debug, Clone)]
339 pub struct FanoutCard {
340 pub kind: String,
341 pub workers: Vec<WorkerSlot>,
342 }
343
344 impl FanoutCard {
345 #[must_use]
346 pub fn new(kind: impl Into<String>) -> Self {
347 Self {
348 kind: kind.into(),
349 workers: Vec::new(),
350 }
351 }
352
353 /// Pre-seed worker slots when the fanout size is known up front.
354 #[cfg_attr(not(test), expect(dead_code))]
355 pub fn with_workers<I, S>(mut self, ids: I) -> Self
356 where
357 I: IntoIterator<Item = S>,
358 S: Into<String>,
359 {
360 for id in ids {
361 self.workers
362 .push(WorkerSlot::new(id.into(), AgentLifecycle::Pending));
363 }
364 self
365 }
366
367 /// Update or insert a worker by id. Returns whether the visible state
368 /// changed and the card should be redrawn.
369 pub fn upsert_worker(&mut self, agent_id: &str, status: AgentLifecycle) -> bool {
370 if let Some(slot) = self
371 .workers
372 .iter_mut()
373 .find(|s| s.agent_id == agent_id || s.worker_id == agent_id)
374 {
375 if slot.agent_id == agent_id && slot.status == status {
376 return false;
377 }
378 slot.agent_id = agent_id.to_string();
379 slot.status = status;
380 true
381 } else {
382 self.workers.push(WorkerSlot::new(agent_id, status));
383 true
384 }
385 }
386
387 /// Attach a real agent id to the first pending placeholder slot. Fanout
388 /// cards are seeded from task ids before child agents exist; when a child
389 /// starts, this keeps the dot count stable instead of appending a second
390 /// circle for the same unit of work.
391 pub fn claim_pending_worker(&mut self, agent_id: &str, status: AgentLifecycle) -> bool {
392 if let Some(slot) = self.workers.iter_mut().find(|s| s.agent_id == agent_id) {
393 if slot.status == status {
394 return false;
395 }
396 slot.status = status;
397 return true;
398 }
399 if let Some(slot) = self
400 .workers
401 .iter_mut()
402 .find(|s| matches!(s.status, AgentLifecycle::Pending))
403 {
404 slot.agent_id = agent_id.to_string();
405 slot.status = status;
406 return true;
407 }
408 self.upsert_worker(agent_id, status)
409 }
410
411 fn counts(&self) -> (usize, usize, usize, usize) {
412 let mut done = 0usize;
413 let mut running = 0usize;
414 let mut failed = 0usize;
415 let mut pending = 0usize;
416 for slot in &self.workers {
417 match slot.status {
418 AgentLifecycle::Completed => done += 1,
419 AgentLifecycle::Running => running += 1,
420 AgentLifecycle::Failed
421 | AgentLifecycle::Cancelled
422 | AgentLifecycle::Interrupted => failed += 1,
423 AgentLifecycle::Pending => pending += 1,
424 }
425 }
426 (done, running, failed, pending)
427 }
428
429 #[must_use]
430 pub fn dot_grid(&self) -> String {
431 let mut s = String::with_capacity(self.workers.len());
432 for slot in &self.workers {
433 let glyph = match slot.status {
434 AgentLifecycle::Completed => '\u{25CF}', // ●
435 AgentLifecycle::Running => '\u{25D0}', // ◐
436 AgentLifecycle::Failed => '\u{00D7}', // ×
437 AgentLifecycle::Cancelled => '\u{2298}', // ⊘
438 AgentLifecycle::Pending => '\u{25CB}', // ○
439 AgentLifecycle::Interrupted => '\u{25CC}', // ◌
440 };
441 s.push(glyph);
442 }
443 s
444 }
445
446 #[must_use]
447 pub fn render_lines(&self, _width: u16, theme: &palette::UiTheme) -> Vec<Line<'static>> {
448 let header_status = self.aggregate_status();
449 let count = self.workers.len();
450 let count_label = if count == 1 { "agent" } else { "agents" };
451 vec![Line::from(vec![
452 Span::styled(
453 family_glyph(ToolFamily::Fanout),
454 Style::default()
455 .fg(header_status.color(theme))
456 .add_modifier(Modifier::BOLD),
457 ),
458 Span::raw(" "),
459 Span::styled(
460 format!("{count} {count_label}"),
461 Style::default()
462 .fg(codewhale_palette::grammar::ChromeInk::Identity.color(theme))
463 .add_modifier(Modifier::BOLD),
464 ),
465 Span::raw(" "),
466 Span::styled(
467 self.dot_grid(),
468 Style::default()
469 .fg(codewhale_palette::grammar::ChromeInk::Metadata.color(theme))
470 .add_modifier(Modifier::BOLD),
471 ),
472 ])]
473 }
474
475 fn aggregate_status(&self) -> AgentLifecycle {
476 self.aggregate_status_public()
477 }
478
479 /// Public aggregate lifecycle for the activity shelf and other projectors.
480 #[must_use]
481 pub fn aggregate_status_public(&self) -> AgentLifecycle {
482 let (done, running, failed, pending) = self.counts();
483 if running > 0 {
484 AgentLifecycle::Running
485 } else if pending > 0 {
486 // Pending workers wait — amber attention, not "running" teal.
487 AgentLifecycle::Pending
488 } else if self
489 .workers
490 .iter()
491 .any(|slot| matches!(slot.status, AgentLifecycle::Interrupted))
492 {
493 AgentLifecycle::Interrupted
494 } else if failed > 0 && done == 0 {
495 AgentLifecycle::Failed
496 } else if done > 0 {
497 AgentLifecycle::Completed
498 } else {
499 AgentLifecycle::Pending
500 }
501 }
502
503 /// Worker count (slots seeded or observed via mailbox).
504 #[must_use]
505 pub fn worker_count(&self) -> usize {
506 self.workers.len()
507 }
508 }
509
510 fn delegate_header(
511 status: AgentLifecycle,
512 role: &str,
513 detail: &str,
514 width: usize,
515 theme: &palette::UiTheme,
516 ) -> Line<'static> {
517 let glyph = format!("{} ", family_glyph(ToolFamily::Delegate));
518 let fixed_parts: Vec<&str> = vec![glyph.as_str(), role, " "];
519 let fixed_width = fixed_parts
520 .iter()
521 .map(|text| UnicodeWidthStr::width(*text))
522 .sum::<usize>();
523 let detail = truncate_action(detail, width.saturating_sub(fixed_width));
524 let spans = vec![
525 Span::styled(
526 glyph,
527 Style::default()
528 .fg(status.color(theme))
529 .add_modifier(Modifier::BOLD),
530 ),
531 Span::styled(
532 role.to_string(),
533 Style::default()
534 .fg(codewhale_palette::grammar::ChromeInk::Identity.color(theme))
535 .add_modifier(Modifier::BOLD),
536 ),
537 Span::raw(" "),
538 Span::styled(
539 detail,
540 Style::default().fg(codewhale_palette::grammar::ChromeInk::Metadata.color(theme)),
541 ),
542 ];
543 Line::from(spans)
544 }
545
546 fn line_detail_width(line_width: usize, prefix: &str) -> usize {
547 line_width.saturating_sub(UnicodeWidthStr::width(prefix))
548 }
549
550 fn truncate_action(text: &str, max: usize) -> String {
551 truncate_line_to_width(text.trim(), max)
552 }
553
554 /// Apply a mailbox envelope to a `DelegateCard`. Returns `true` if the
555 /// state changed (UI may want to redraw); `false` if the envelope was for
556 /// a different `agent_id`.
557 pub fn apply_to_delegate(card: &mut DelegateCard, msg: &MailboxMessage) -> bool {
558 if msg.agent_id() != card.agent_id {
559 return false;
560 }
561 let was_terminal = card.status.is_terminal();
562 if card.started_at.is_none()
563 && matches!(
564 msg,
565 MailboxMessage::Started { .. }
566 | MailboxMessage::Progress { .. }
567 | MailboxMessage::ToolCallStarted { .. }
568 | MailboxMessage::ToolCallCompleted { .. }
569 | MailboxMessage::WorkState { .. }
570 )
571 {
572 card.started_at = Some(Instant::now());
573 }
574 match msg {
575 MailboxMessage::Started { .. } => {
576 if card.status == AgentLifecycle::Running {
577 return false;
578 }
579 card.status = AgentLifecycle::Running;
580 }
581 MailboxMessage::Progress { status, .. } => {
582 let low_signal = is_low_signal_progress(status);
583 if low_signal && card.status == AgentLifecycle::Running {
584 return false;
585 }
586 card.status = AgentLifecycle::Running;
587 if !low_signal {
588 card.push_action(status);
589 }
590 }
591 MailboxMessage::ToolCallStarted { tool_name, .. } => {
592 card.push_action(format!("{tool_name} running"));
593 }
594 MailboxMessage::ToolCallCompleted { tool_name, ok, .. } => {
595 card.push_action(format!("{tool_name} {}", if *ok { "ok" } else { "failed" }));
596 }
597 MailboxMessage::Completed { summary, .. } => {
598 card.status = AgentLifecycle::Completed;
599 card.summary = Some(summary.clone());
600 }
601 MailboxMessage::Failed { error, .. } => {
602 card.status = AgentLifecycle::Failed;
603 card.summary = Some(error.clone());
604 }
605 MailboxMessage::Interrupted { reason, .. } => {
606 card.status = AgentLifecycle::Interrupted;
607 card.summary = Some(reason.clone());
608 }
609 MailboxMessage::Cancelled { .. } => {
610 card.status = AgentLifecycle::Cancelled;
611 }
612 MailboxMessage::WorkState { todo, .. } => {
613 // agent_id already matched above, so this is this child's own
614 // list. Publishing live work is evidence that a pending child
615 // has started, while terminal cards keep both their terminal
616 // status and the last snapshot the child published.
617 let status_changed = if card.status == AgentLifecycle::Pending {
618 card.status = AgentLifecycle::Running;
619 true
620 } else {
621 false
622 };
623 return card.set_todo(todo.clone()) || status_changed;
624 }
625 MailboxMessage::ChildSpawned { .. } => {
626 // Delegate cards represent a single agent; child spawns belong
627 // to a sibling fanout card, not this one.
628 return false;
629 }
630 MailboxMessage::TokenUsage { .. } => {
631 // Cost accumulation happens in handle_subagent_mailbox (ui.rs)
632 // before this apply function is called; TokenUsage never reaches
633 // this arm in practice.
634 return false;
635 }
636 }
637 if !was_terminal && card.status.is_terminal() {
638 card.finished_at = Some(Instant::now());
639 }
640 true
641 }
642
643 /// Known limitation: this still matches on message text, while the footer
644 /// path keys off the structured `routine_wait` flag (#6290). The mailbox is
645 /// a stable cross-crate (`protocol`) surface whose payloads may come from
646 /// another binary, so this arm cannot assume the flag exists — and no
647 /// in-crate producer sends routine waits here anyway (only "queued" and
648 /// "running" texts), so threading the flag would change nothing. If a future
649 /// producer sends routine waits over the mailbox, give `Progress` the flag
650 /// and match on it here instead of extending this list.
651 fn is_low_signal_progress(status: &str) -> bool {
652 let status = status.trim().to_ascii_lowercase();
653 status.contains("requesting model response")
654 || status.starts_with("started (")
655 || (status.starts_with("step ") && status.contains(": complete"))
656 }
657
658 /// Apply a mailbox envelope to a `FanoutCard`. Updates per-worker state
659 /// based on which child the envelope is about. Returns `true` on change.
660 pub fn apply_to_fanout(card: &mut FanoutCard, msg: &MailboxMessage) -> bool {
661 let id = msg.agent_id();
662 match msg {
663 MailboxMessage::Started { .. } => card.claim_pending_worker(id, AgentLifecycle::Running),
664 MailboxMessage::Progress { .. } => card.claim_pending_worker(id, AgentLifecycle::Running),
665 MailboxMessage::ToolCallStarted { .. } => {
666 card.claim_pending_worker(id, AgentLifecycle::Running)
667 }
668 MailboxMessage::ToolCallCompleted { .. } => true,
669 MailboxMessage::Completed { .. } => card.upsert_worker(id, AgentLifecycle::Completed),
670 MailboxMessage::Failed { .. } => card.upsert_worker(id, AgentLifecycle::Failed),
671 MailboxMessage::Interrupted { .. } => card.upsert_worker(id, AgentLifecycle::Interrupted),
672 MailboxMessage::Cancelled { .. } => card.upsert_worker(id, AgentLifecycle::Cancelled),
673 MailboxMessage::ChildSpawned { child_id, .. } => {
674 card.upsert_worker(child_id, AgentLifecycle::Pending)
675 }
676 // A fanout card is a dot grid of many workers with no per-worker row
677 // to hang a list on. Rather than merge N children's lists into one
678 // card — which would be exactly the cross-agent leak this surface must
679 // not have — it shows none of them. WorkState is intentionally
680 // unavailable on this fanout surface; an individually spawned child
681 // may show its own To-do when it has a separate delegate card.
682 MailboxMessage::WorkState { .. } => false,
683 MailboxMessage::TokenUsage { .. } => {
684 // Cost accumulation happens in handle_subagent_mailbox (ui.rs)
685 // before this apply function is called; TokenUsage never reaches
686 // this arm in practice.
687 true
688 }
689 }
690 }
691
692 #[cfg(test)]
693 mod tests {
694 use super::*;
695 use unicode_width::UnicodeWidthStr;
696
697 fn render_to_strings(lines: &[Line<'static>]) -> Vec<String> {
698 lines
699 .iter()
700 .map(|line| {
701 line.spans
702 .iter()
703 .map(|span| span.content.as_ref())
704 .collect::<String>()
705 })
706 .collect()
707 }
708
709 #[test]
710 fn delegate_card_header_does_not_duplicate_verb_as_role() {
711 let card = DelegateCard::new("agent_1", "explore");
712 let rendered =
713 render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
714 assert!(
715 !rendered.contains("delegate"),
716 "delegate must not be visible in the card: {rendered:?}"
717 );
718 assert!(!rendered.contains("[running]"), "{rendered:?}");
719 let explore = DelegateCard::new("agent_2", "scout");
720 let explore_rendered =
721 render_to_strings(&explore.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
722 assert!(explore_rendered.contains("explore"), "{explore_rendered:?}");
723 }
724
725 #[test]
726 fn delegate_card_cjk_text_respects_render_width() {
727 let mut card = DelegateCard::new("agent_e0b2dcf1", "implementer");
728 card.status = AgentLifecycle::Running;
729 card.summary = Some(
730 "抹香鲸 agent_e0b2dcf1 running 10+ 124838ms role: implementer git: branch codex/issue-3439-zhipu-glm-fixture @ issue-3439".into(),
731 );
732 card.push_action("objective: QUESTION: Add Zhipu GLM as a first-class provider-scoped route for 中文输出".to_string());
733
734 let rendered = render_to_strings(&card.render_lines(40, &codewhale_palette::UI_THEME));
735
736 assert!(rendered[0].contains("implement"), "{rendered:?}");
737 for line in rendered {
738 let width = UnicodeWidthStr::width(line.as_str());
739 assert!(width <= 40, "line width {width} exceeds 40: {line:?}");
740 }
741 }
742
743 #[test]
744 fn delegate_card_truncates_to_last_three_actions_with_ellipsis() {
745 let mut card = DelegateCard::new("agent_001", "general");
746 card.push_action("read README.md");
747 card.push_action("grep TODO");
748 card.push_action("edit src/lib.rs");
749 // Up to the limit — no truncation yet.
750 assert!(!card.truncated());
751 assert_eq!(card.action_count(), DELEGATE_MAX_ACTIONS);
752
753 card.push_action("write tests");
754 card.push_action("run cargo test");
755 assert!(card.truncated(), "truncation flag flips on overflow");
756 assert_eq!(
757 card.action_count(),
758 DELEGATE_MAX_ACTIONS,
759 "stable steady-state size"
760 );
761
762 let rendered = render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME));
763 assert!(
764 rendered.iter().any(|line| line.contains('\u{2026}')),
765 "ellipsis indicator must render: got {rendered:?}"
766 );
767 // The oldest two actions ("read README.md", "grep TODO") were dropped.
768 assert!(
769 !rendered.iter().any(|line| line.contains("read README.md")),
770 "oldest action evicted: got {rendered:?}"
771 );
772 assert!(
773 rendered.iter().any(|line| line.contains("run cargo test")),
774 "newest action retained: got {rendered:?}"
775 );
776 assert!(
777 rendered.iter().any(|line| line.contains("write tests")),
778 "second-newest retained: got {rendered:?}"
779 );
780 assert!(
781 rendered.iter().any(|line| line.contains("edit src/lib.rs")),
782 "third-newest retained: got {rendered:?}"
783 );
784 }
785
786 #[test]
787 fn delegate_card_terminal_status_renders_summary_row() {
788 let mut card = DelegateCard::new("agent_002", "explore");
789 card.push_action("listing files");
790 let msg = MailboxMessage::Completed {
791 agent_id: "agent_002".into(),
792 summary: "scanned 42 files, no TODOs found".into(),
793 };
794 assert!(apply_to_delegate(&mut card, &msg));
795 assert_eq!(card.status, AgentLifecycle::Completed);
796 let rendered = render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME));
797 assert!(
798 rendered.iter().any(|line| line.contains("╰ done")),
799 "terminal status row renders done: got {rendered:?}"
800 );
801 assert!(
802 rendered
803 .iter()
804 .any(|line| line.contains("scanned 42 files")),
805 "summary row renders on terminal status: got {rendered:?}"
806 );
807 }
808
809 #[test]
810 fn delegate_card_ignores_low_signal_scheduler_progress() {
811 let mut card = DelegateCard::new("agent_003", "general");
812 let msg = MailboxMessage::progress("agent_003", "step 1/100: requesting model response");
813
814 assert!(apply_to_delegate(&mut card, &msg));
815 assert_eq!(card.status, AgentLifecycle::Running);
816 assert_eq!(
817 card.action_count(),
818 0,
819 "scheduler progress should not become a stale transcript row"
820 );
821
822 let rendered =
823 render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
824 assert!(!rendered.contains("step 1/100"), "{rendered}");
825 assert!(
826 !rendered.contains("requesting model response"),
827 "{rendered}"
828 );
829 assert!(
830 !apply_to_delegate(&mut card, &msg),
831 "repeated low-signal progress should not redraw the card"
832 );
833 }
834
835 #[test]
836 fn delegate_tool_rows_omit_internal_step_numbers() {
837 let mut card = DelegateCard::new("agent_004", "general");
838
839 assert!(apply_to_delegate(
840 &mut card,
841 &MailboxMessage::ToolCallStarted {
842 agent_id: "agent_004".into(),
843 tool_name: "read_file".into(),
844 step: 7,
845 }
846 ));
847 assert!(apply_to_delegate(
848 &mut card,
849 &MailboxMessage::ToolCallCompleted {
850 agent_id: "agent_004".into(),
851 tool_name: "read_file".into(),
852 step: 7,
853 ok: true,
854 }
855 ));
856
857 let rendered =
858 render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
859 assert!(rendered.contains("read_file"), "{rendered}");
860 assert!(
861 !rendered.contains("[7]"),
862 "internal loop step numbers are not useful in the live card: {rendered}"
863 );
864 }
865
866 #[test]
867 fn delegate_card_ignores_envelopes_for_other_agents() {
868 let mut card = DelegateCard::new("agent_a", "general");
869 let other = MailboxMessage::progress("agent_b", "noise");
870 assert!(!apply_to_delegate(&mut card, &other));
871 assert_eq!(card.action_count(), 0);
872 }
873
874 #[test]
875 fn fanout_card_dot_grid_renders_stateful_worker_slots() {
876 let mut card = FanoutCard::new("fanout")
877 .with_workers(["w_1", "w_2", "w_3", "w_4", "w_5", "w_6", "w_7"]);
878 card.upsert_worker("w_1", AgentLifecycle::Completed);
879 card.upsert_worker("w_2", AgentLifecycle::Completed);
880 card.upsert_worker("w_3", AgentLifecycle::Running);
881 card.upsert_worker("w_4", AgentLifecycle::Failed);
882 // 5/6/7 stay Pending.
883
884 // Completed fills; running and failed are distinct; pending stays open.
885 assert_eq!(
886 card.dot_grid(),
887 "\u{25CF}\u{25CF}\u{25D0}\u{00D7}\u{25CB}\u{25CB}\u{25CB}"
888 );
889 }
890
891 #[test]
892 fn fanout_card_header_and_dot_grid_surface_aggregate_state() {
893 let mut card = FanoutCard::new("rlm").with_workers(["w_1", "w_2", "w_3", "w_4"]);
894 card.upsert_worker("w_1", AgentLifecycle::Completed);
895 card.upsert_worker("w_2", AgentLifecycle::Completed);
896 card.upsert_worker("w_3", AgentLifecycle::Completed);
897 card.upsert_worker("w_4", AgentLifecycle::Failed);
898 let rendered =
899 render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
900 assert!(
901 rendered.contains("4 agents"),
902 "header should show count: {rendered}"
903 );
904 assert!(
905 rendered.starts_with("⋮⋮ 4 agents"),
906 "header should omit the fanout kind: {rendered}"
907 );
908 assert!(
909 rendered.contains("\u{25CF}\u{25CF}\u{25CF}\u{00D7}"),
910 "dot grid should mirror worker states: {rendered}"
911 );
912 assert!(
913 !rendered.contains(" pending"),
914 "redundant counts line should stay omitted: {rendered}"
915 );
916 }
917
918 #[test]
919 fn fanout_apply_inserts_unknown_worker_via_child_spawned() {
920 let mut card = FanoutCard::new("fanout");
921 let msg = MailboxMessage::ChildSpawned {
922 parent_id: "root".into(),
923 child_id: "agent_late".into(),
924 };
925 assert!(apply_to_fanout(&mut card, &msg));
926 assert_eq!(card.worker_count(), 1);
927 assert_eq!(card.workers[0].agent_id, "agent_late");
928 assert_eq!(card.workers[0].status, AgentLifecycle::Pending);
929 }
930
931 #[test]
932 fn fanout_started_claims_seeded_pending_slot_without_growing_grid() {
933 let mut card = FanoutCard::new("fanout").with_workers(["task:a", "task:b"]);
934 let started =
935 MailboxMessage::started("agent_live", crate::tools::subagent::FleetRole::Worker);
936
937 assert!(apply_to_fanout(&mut card, &started));
938
939 assert_eq!(card.worker_count(), 2);
940 assert_eq!(card.workers[0].agent_id, "agent_live");
941 assert_eq!(card.workers[0].status, AgentLifecycle::Running);
942 assert_eq!(card.workers[1].agent_id, "task:b");
943 assert_eq!(card.workers[1].status, AgentLifecycle::Pending);
944 let progress =
945 MailboxMessage::progress("agent_live", "step 1/100: requesting model response");
946 assert!(
947 !apply_to_fanout(&mut card, &progress),
948 "repeated progress for a running worker should not redraw"
949 );
950 }
951
952 #[test]
953 fn fanout_apply_transitions_worker_through_lifecycle() {
954 let mut card = FanoutCard::new("fanout").with_workers(["w_1"]);
955 let started = MailboxMessage::started("w_1", crate::tools::subagent::FleetRole::Worker);
956 apply_to_fanout(&mut card, &started);
957 assert_eq!(card.workers[0].status, AgentLifecycle::Running);
958
959 let done = MailboxMessage::Completed {
960 agent_id: "w_1".into(),
961 summary: "ok".into(),
962 };
963 apply_to_fanout(&mut card, &done);
964 assert_eq!(card.workers[0].status, AgentLifecycle::Completed);
965 }
966
967 #[test]
968 fn fanout_dot_grid_arithmetic_for_various_n() {
969 // Spot-check several fanout sizes with a mix of states; this is the
970 // arithmetic snapshot the issue acceptance calls out.
971 let cases: &[(usize, usize, &str)] = &[
972 (1, 0, "\u{25CB}"),
973 (1, 1, "\u{25CF}"),
974 (3, 2, "\u{25CF}\u{25CF}\u{25CB}"),
975 (
976 7,
977 3,
978 "\u{25CF}\u{25CF}\u{25CF}\u{25CB}\u{25CB}\u{25CB}\u{25CB}",
979 ),
980 ];
981 for (total, done, expected) in cases {
982 let ids: Vec<String> = (0..*total).map(|i| format!("w_{i}")).collect();
983 let mut card = FanoutCard::new("fanout").with_workers(ids.iter().cloned());
984 for id in ids.iter().take(*done) {
985 card.upsert_worker(id, AgentLifecycle::Completed);
986 }
987 assert_eq!(
988 card.dot_grid(),
989 *expected,
990 "fanout dot-grid for total={total} done={done}",
991 );
992 }
993 }
994
995 #[test]
996 fn delegate_interrupted_leaves_running_and_renders_reason() {
997 let mut card = DelegateCard::new("agent_int", "general");
998 apply_to_delegate(
999 &mut card,
1000 &MailboxMessage::started("agent_int", crate::tools::subagent::FleetRole::Worker),
1001 );
1002 assert_eq!(card.status, AgentLifecycle::Running);
1003
1004 let msg = MailboxMessage::Interrupted {
1005 agent_id: "agent_int".into(),
1006 reason: "API call timed out after 120000ms; checkpoint preserved for continuation"
1007 .into(),
1008 };
1009 assert!(apply_to_delegate(&mut card, &msg));
1010 assert_eq!(card.status, AgentLifecycle::Interrupted);
1011
1012 let rendered =
1013 render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
1014 assert!(rendered.contains("╰ interrupted"), "{rendered}");
1015 assert!(rendered.contains("API call timed out"), "{rendered}");
1016 }
1017
1018 #[test]
1019 fn fanout_interrupted_worker_leaves_running_counts() {
1020 let mut card = FanoutCard::new("fanout").with_workers(["w_1", "w_2"]);
1021 apply_to_fanout(
1022 &mut card,
1023 &MailboxMessage::started("w_1", crate::tools::subagent::FleetRole::Worker),
1024 );
1025 apply_to_fanout(
1026 &mut card,
1027 &MailboxMessage::started("w_2", crate::tools::subagent::FleetRole::Worker),
1028 );
1029
1030 let msg = MailboxMessage::Interrupted {
1031 agent_id: "w_1".into(),
1032 reason: "API call timed out".into(),
1033 };
1034 assert!(apply_to_fanout(&mut card, &msg));
1035 assert_eq!(card.workers[0].status, AgentLifecycle::Interrupted);
1036 assert_eq!(card.workers[1].status, AgentLifecycle::Running);
1037
1038 // Copy dedupe (Wave 5c #4): the counts line is gone — the header and
1039 // dot grid carry the aggregate state instead.
1040 let rendered =
1041 render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
1042 assert!(rendered.contains("2 agents"), "{rendered}");
1043 assert!(
1044 rendered.contains('\u{25D0}'),
1045 "dot grid should keep the running worker glyph: {rendered}"
1046 );
1047 assert!(
1048 rendered.contains('\u{25CC}'),
1049 "dot grid should mark the interrupted worker: {rendered}"
1050 );
1051
1052 let msg = MailboxMessage::Interrupted {
1053 agent_id: "w_2".into(),
1054 reason: "API call timed out".into(),
1055 };
1056 assert!(apply_to_fanout(&mut card, &msg));
1057 let rendered =
1058 render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
1059 assert!(rendered.contains("2 agents"), "{rendered}");
1060 }
1061
1062 #[test]
1063 fn fanout_card_omits_redundant_counts_line_when_header_and_grid_present() {
1064 let ids: Vec<String> = (0..16).map(|i| format!("w_{i}")).collect();
1065 let mut card = FanoutCard::new("fanout").with_workers(ids.iter().cloned());
1066 for id in ids.iter().take(12) {
1067 card.upsert_worker(id, AgentLifecycle::Completed);
1068 }
1069 card.upsert_worker("w_12", AgentLifecycle::Running);
1070
1071 let rendered = render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME));
1072 assert!(
1073 rendered.iter().any(|line| line.contains('\u{25CF}')),
1074 "dot grid should remain: {rendered:?}"
1075 );
1076 assert!(
1077 !rendered.iter().any(|line| line.contains('·')),
1078 "counts line should be dropped: {rendered:?}"
1079 );
1080 }
1081
1082 // === #4810: a child's own To-do on its own card ===
1083
1084 use crate::tools::todo::{TodoItem, TodoStatus};
1085
1086 fn todo(items: &[(u32, &str, TodoStatus)], in_progress_id: Option<u32>) -> TodoListSnapshot {
1087 let items: Vec<TodoItem> = items
1088 .iter()
1089 .map(|(id, content, status)| TodoItem {
1090 id: *id,
1091 content: (*content).to_string(),
1092 status: *status,
1093 })
1094 .collect();
1095 let settled = items.iter().filter(|item| item.status.is_settled()).count();
1096 let completion_pct = if items.is_empty() {
1097 0
1098 } else {
1099 ((settled * 100) / items.len()) as u8
1100 };
1101 TodoListSnapshot {
1102 items,
1103 completion_pct,
1104 in_progress_id,
1105 }
1106 }
1107
1108 fn work_state(agent_id: &str, snapshot: TodoListSnapshot) -> MailboxMessage {
1109 MailboxMessage::WorkState {
1110 agent_id: agent_id.to_string(),
1111 todo: snapshot,
1112 }
1113 }
1114
1115 #[test]
1116 fn delegate_card_renders_the_childs_own_todo_under_its_row() {
1117 let mut card = DelegateCard::new("agent_child", "implementer");
1118 apply_to_delegate(
1119 &mut card,
1120 &MailboxMessage::started("agent_child", crate::tools::subagent::FleetRole::Worker),
1121 );
1122 assert!(apply_to_delegate(
1123 &mut card,
1124 &work_state(
1125 "agent_child",
1126 todo(
1127 &[
1128 (1, "read the runtime seam", TodoStatus::Completed),
1129 (2, "write the projection", TodoStatus::InProgress),
1130 ],
1131 Some(2),
1132 ),
1133 ),
1134 ));
1135
1136 let rendered =
1137 render_to_strings(&card.render_lines(100, &codewhale_palette::UI_THEME)).join("\n");
1138 assert!(rendered.contains("To-do 1/2"), "{rendered}");
1139 assert!(rendered.contains("50% settled"), "{rendered}");
1140 assert!(
1141 rendered.contains("[~] #2 write the projection"),
1142 "{rendered}"
1143 );
1144 assert!(
1145 rendered.contains("[x] #1 read the runtime seam"),
1146 "{rendered}"
1147 );
1148 // Role, model-facing lifecycle label, and identity stay exactly as the
1149 // row already reported them.
1150 assert!(rendered.contains("implement"), "{rendered}");
1151 }
1152
1153 #[test]
1154 fn delegate_card_ignores_work_state_addressed_to_another_agent() {
1155 let mut card = DelegateCard::new("agent_a", "general");
1156 assert!(!apply_to_delegate(
1157 &mut card,
1158 &work_state(
1159 "agent_b",
1160 todo(&[(1, "sibling only work", TodoStatus::InProgress)], Some(1)),
1161 ),
1162 ));
1163 assert!(card.todo_projection().is_none());
1164 let rendered =
1165 render_to_strings(&card.render_lines(100, &codewhale_palette::UI_THEME)).join("\n");
1166 assert!(!rendered.contains("sibling only work"), "{rendered}");
1167 assert!(!rendered.contains("To-do"), "{rendered}");
1168 }
1169
1170 #[test]
1171 fn delegate_card_shows_a_same_turn_update_without_waiting_for_completion() {
1172 let mut card = DelegateCard::new("agent_child", "general");
1173 apply_to_delegate(
1174 &mut card,
1175 &work_state(
1176 "agent_child",
1177 todo(&[(1, "draft the fix", TodoStatus::InProgress)], Some(1)),
1178 ),
1179 );
1180
1181 // Same step: the child calls work_update and immediately republishes.
1182 apply_to_delegate(
1183 &mut card,
1184 &MailboxMessage::ToolCallCompleted {
1185 agent_id: "agent_child".to_string(),
1186 tool_name: "work_update".to_string(),
1187 step: 1,
1188 ok: true,
1189 },
1190 );
1191 assert!(
1192 apply_to_delegate(
1193 &mut card,
1194 &work_state(
1195 "agent_child",
1196 todo(
1197 &[
1198 (1, "draft the fix", TodoStatus::Completed),
1199 (2, "add the regression", TodoStatus::InProgress),
1200 ],
1201 Some(2),
1202 ),
1203 ),
1204 ),
1205 "a changed list must redraw the card"
1206 );
1207
1208 let rendered =
1209 render_to_strings(&card.render_lines(100, &codewhale_palette::UI_THEME)).join("\n");
1210 assert_eq!(card.status, AgentLifecycle::Running, "still mid-turn");
1211 assert!(rendered.contains("[~] #2 add the regression"), "{rendered}");
1212 assert!(rendered.contains("[x] #1 draft the fix"), "{rendered}");
1213 assert!(rendered.contains("To-do 1/2"), "{rendered}");
1214
1215 // Republishing the identical snapshot is not a visible change.
1216 assert!(!apply_to_delegate(
1217 &mut card,
1218 &work_state(
1219 "agent_child",
1220 todo(
1221 &[
1222 (1, "draft the fix", TodoStatus::Completed),
1223 (2, "add the regression", TodoStatus::InProgress),
1224 ],
1225 Some(2),
1226 ),
1227 ),
1228 ));
1229 }
1230
1231 #[test]
1232 fn delegate_card_empty_child_todo_renders_no_item_at_all() {
1233 let mut card = DelegateCard::new("agent_child", "general");
1234 card.push_action("read_file ok");
1235 apply_to_delegate(
1236 &mut card,
1237 &work_state("agent_child", TodoListSnapshot::default()),
1238 );
1239
1240 assert!(card.todo_projection().is_none());
1241 let rendered =
1242 render_to_strings(&card.render_lines(100, &codewhale_palette::UI_THEME)).join("\n");
1243 assert!(
1244 !rendered.contains("To-do"),
1245 "an empty list states nothing: {rendered}"
1246 );
1247 assert!(
1248 !rendered.contains('#'),
1249 "no synthesized item may appear: {rendered}"
1250 );
1251 assert!(rendered.contains("read_file ok"), "{rendered}");
1252 }
1253
1254 #[test]
1255 fn delegate_card_todo_is_bounded_and_marks_what_it_elided() {
1256 let items: Vec<(u32, String, TodoStatus)> = (1..=9)
1257 .map(|id| {
1258 (
1259 id,
1260 format!("item {id} ").repeat(30),
1261 if id == 8 {
1262 TodoStatus::InProgress
1263 } else {
1264 TodoStatus::Pending
1265 },
1266 )
1267 })
1268 .collect();
1269 let refs: Vec<(u32, &str, TodoStatus)> = items
1270 .iter()
1271 .map(|(id, content, status)| (*id, content.as_str(), *status))
1272 .collect();
1273 let mut card = DelegateCard::new("agent_child", "general");
1274 apply_to_delegate(&mut card, &work_state("agent_child", todo(&refs, Some(8))));
1275
1276 let projection = card.todo_projection().expect("projection");
1277 assert_eq!(
1278 projection.items.len(),
1279 crate::todo_snapshot::MAX_CARD_ITEM_LINES
1280 );
1281 assert_eq!(
1282 projection.omitted,
1283 9 - crate::todo_snapshot::MAX_CARD_ITEM_LINES
1284 );
1285 assert!(
1286 projection
1287 .items
1288 .iter()
1289 .any(|line| line.starts_with("[~] #8")),
1290 "the active item is never the one dropped: {projection:?}"
1291 );
1292
1293 let rendered = render_to_strings(&card.render_lines(60, &codewhale_palette::UI_THEME));
1294 assert!(
1295 rendered.iter().any(|line| line.contains("+6 more")),
1296 "elision must be stated: {rendered:?}"
1297 );
1298 for line in &rendered {
1299 assert!(
1300 UnicodeWidthStr::width(line.as_str()) <= 60,
1301 "line exceeds the card width: {line:?}"
1302 );
1303 }
1304 }
1305
1306 #[test]
1307 fn terminal_delegate_cards_keep_the_last_child_todo() {
1308 for terminal in [
1309 MailboxMessage::Completed {
1310 agent_id: "agent_child".to_string(),
1311 summary: "done".to_string(),
1312 },
1313 MailboxMessage::Failed {
1314 agent_id: "agent_child".to_string(),
1315 error: "boom".to_string(),
1316 },
1317 MailboxMessage::Cancelled {
1318 agent_id: "agent_child".to_string(),
1319 },
1320 ] {
1321 let mut card = DelegateCard::new("agent_child", "general");
1322 apply_to_delegate(
1323 &mut card,
1324 &work_state(
1325 "agent_child",
1326 todo(
1327 &[
1328 (1, "land the fix", TodoStatus::Completed),
1329 (2, "run the suite", TodoStatus::InProgress),
1330 ],
1331 Some(2),
1332 ),
1333 ),
1334 );
1335 apply_to_delegate(&mut card, &terminal);
1336
1337 let rendered =
1338 render_to_strings(&card.render_lines(100, &codewhale_palette::UI_THEME)).join("\n");
1339 assert!(card.status.is_terminal(), "{:?}", card.status);
1340 assert!(
1341 rendered.contains("[~] #2 run the suite"),
1342 "terminal card keeps the last truthful list ({:?}): {rendered}",
1343 card.status
1344 );
1345 assert!(rendered.contains("To-do 1/2"), "{rendered}");
1346 }
1347 }
1348
1349 #[test]
1350 fn fanout_card_does_not_project_any_workers_todo() {
1351 let mut card = FanoutCard::new("fanout").with_workers(["w_1", "w_2"]);
1352 assert!(!apply_to_fanout(
1353 &mut card,
1354 &work_state(
1355 "w_1",
1356 todo(&[(1, "worker one work", TodoStatus::InProgress)], Some(1)),
1357 ),
1358 ));
1359 let rendered =
1360 render_to_strings(&card.render_lines(100, &codewhale_palette::UI_THEME)).join("\n");
1361 assert!(!rendered.contains("worker one work"), "{rendered}");
1362 assert!(!rendered.contains("To-do"), "{rendered}");
1363 }
1364
1365 #[test]
1366 fn direct_subagent_projects_onto_shared_workflow_history_card() {
1367 use crate::tui::widgets::workflow_panel::WorkflowHistoryExtras;
1368
1369 let mut card = DelegateCard::new("agent_xyz", "explore");
1370 card.status = AgentLifecycle::Completed;
1371 card.summary = Some("mapped 4 call sites".to_string());
1372 let panel = card.as_workflow_history_panel(1_000, Some(5_000));
1373 let compact = panel.render_history_card(100, false, &WorkflowHistoryExtras::default());
1374 let joined = render_to_strings(&compact).join("\n");
1375 assert!(
1376 joined.contains("success") || joined.contains("explore"),
1377 "shared compact lifecycle: {joined}"
1378 );
1379 assert!(
1380 joined.contains("1 child") || joined.contains("children"),
1381 "shared child count: {joined}"
1382 );
1383 let expanded = panel.render_history_card(
1384 100,
1385 true,
1386 &WorkflowHistoryExtras {
1387 result_summary: Some("mapped 4 call sites".to_string()),
1388 ..WorkflowHistoryExtras::default()
1389 },
1390 );
1391 let joined = render_to_strings(&expanded).join("\n");
1392 assert!(joined.contains("result:"), "{joined}");
1393 assert!(joined.contains("mapped 4 call sites"), "{joined}");
1394 }
1395 }
1396
1396 lines RUST