返回 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. A settled worker is final: a
369 /// delayed envelope for it never reopens or re-settles the slot.
370 pub fn upsert_worker(&mut self, agent_id: &str, status: AgentLifecycle) -> bool {
371 if let Some(slot) = self
372 .workers
373 .iter_mut()
374 .find(|s| s.agent_id == agent_id || s.worker_id == agent_id)
375 {
376 if slot.status.is_terminal() || (slot.agent_id == agent_id && slot.status == status) {
377 return false;
378 }
379 slot.agent_id = agent_id.to_string();
380 slot.status = status;
381 true
382 } else {
383 self.workers.push(WorkerSlot::new(agent_id, status));
384 true
385 }
386 }
387
388 /// Attach a real agent id to the first pending placeholder slot. Fanout
389 /// cards are seeded from task ids before child agents exist; when a child
390 /// starts, this keeps the dot count stable instead of appending a second
391 /// circle for the same unit of work.
392 pub fn claim_pending_worker(&mut self, agent_id: &str, status: AgentLifecycle) -> bool {
393 if let Some(slot) = self.workers.iter_mut().find(|s| s.agent_id == agent_id) {
394 if slot.status.is_terminal() || slot.status == status {
395 return false;
396 }
397 slot.status = status;
398 return true;
399 }
400 if let Some(slot) = self
401 .workers
402 .iter_mut()
403 .find(|s| matches!(s.status, AgentLifecycle::Pending))
404 {
405 slot.agent_id = agent_id.to_string();
406 slot.status = status;
407 return true;
408 }
409 self.upsert_worker(agent_id, status)
410 }
411
412 fn counts(&self) -> (usize, usize, usize, usize) {
413 let mut done = 0usize;
414 let mut running = 0usize;
415 let mut failed = 0usize;
416 let mut pending = 0usize;
417 for slot in &self.workers {
418 match slot.status {
419 AgentLifecycle::Completed => done += 1,
420 AgentLifecycle::Running => running += 1,
421 AgentLifecycle::Failed
422 | AgentLifecycle::Cancelled
423 | AgentLifecycle::Interrupted => failed += 1,
424 AgentLifecycle::Pending => pending += 1,
425 }
426 }
427 (done, running, failed, pending)
428 }
429
430 #[must_use]
431 pub fn dot_grid(&self) -> String {
432 let mut s = String::with_capacity(self.workers.len());
433 for slot in &self.workers {
434 let glyph = match slot.status {
435 AgentLifecycle::Completed => '\u{25CF}', // ●
436 AgentLifecycle::Running => '\u{25D0}', // ◐
437 AgentLifecycle::Failed => '\u{00D7}', // ×
438 AgentLifecycle::Cancelled => '\u{2298}', // ⊘
439 AgentLifecycle::Pending => '\u{25CB}', // ○
440 AgentLifecycle::Interrupted => '\u{25CC}', // ◌
441 };
442 s.push(glyph);
443 }
444 s
445 }
446
447 /// Header plus dot grid, one glyph per worker. The grid never runs past
448 /// `width`: when it does not fit beside the header it wraps onto its own
449 /// rows, so no worker's state is clipped off the transcript edge.
450 #[must_use]
451 pub fn render_lines(&self, width: u16, theme: &palette::UiTheme) -> Vec<Line<'static>> {
452 let header_status = self.aggregate_status();
453 let count = self.workers.len();
454 let count_label = if count == 1 { "agent" } else { "agents" };
455 let glyph = family_glyph(ToolFamily::Fanout);
456 let count_text = format!("{count} {count_label}");
457 let grid_style = Style::default()
458 .fg(codewhale_palette::grammar::ChromeInk::Metadata.color(theme))
459 .add_modifier(Modifier::BOLD);
460 // Every span fits the painted width, down to zero columns (U05-m6):
461 // the glyph only when it fits, the count only with room after it.
462 let width = usize::from(width);
463 let glyph_width = UnicodeWidthStr::width(glyph);
464 let mut header = Vec::new();
465 if width >= glyph_width {
466 header.push(Span::styled(
467 glyph,
468 Style::default()
469 .fg(header_status.color(theme))
470 .add_modifier(Modifier::BOLD),
471 ));
472 }
473 if width > glyph_width + 1 {
474 header.push(Span::raw(" "));
475 header.push(Span::styled(
476 truncate_action(&count_text, width - glyph_width - 1),
477 Style::default()
478 .fg(codewhale_palette::grammar::ChromeInk::Identity.color(theme))
479 .add_modifier(Modifier::BOLD),
480 ));
481 }
482 if width == 0 {
483 return vec![Line::from(header)];
484 }
485 let grid: Vec<char> = self.dot_grid().chars().collect();
486 let header_width = glyph_width + 1 + count_text.width() + 1;
487 if header_width + grid.len() <= width {
488 header.push(Span::raw(" "));
489 header.push(Span::styled(grid.iter().collect::<String>(), grid_style));
490 return vec![Line::from(header)];
491 }
492 let mut lines = vec![Line::from(header)];
493 lines.extend(
494 grid.chunks(width)
495 .map(|row| Line::from(Span::styled(row.iter().collect::<String>(), grid_style))),
496 );
497 lines
498 }
499
500 fn aggregate_status(&self) -> AgentLifecycle {
501 self.aggregate_status_public()
502 }
503
504 /// Public aggregate lifecycle for the activity shelf and other projectors.
505 ///
506 /// A settled fanout reports its worst outcome: one failed worker among
507 /// finished ones is a failed fanout, not a completed one; an
508 /// interrupted or cancelled worker likewise outranks the completions.
509 #[must_use]
510 pub fn aggregate_status_public(&self) -> AgentLifecycle {
511 let (done, running, _, pending) = self.counts();
512 let any = |status: AgentLifecycle| self.workers.iter().any(|slot| slot.status == status);
513 if running > 0 {
514 AgentLifecycle::Running
515 } else if pending > 0 {
516 // Pending workers wait — amber attention, not "running" teal.
517 AgentLifecycle::Pending
518 } else if any(AgentLifecycle::Failed) {
519 AgentLifecycle::Failed
520 } else if any(AgentLifecycle::Interrupted) {
521 AgentLifecycle::Interrupted
522 } else if any(AgentLifecycle::Cancelled) {
523 AgentLifecycle::Cancelled
524 } else if done > 0 {
525 AgentLifecycle::Completed
526 } else {
527 AgentLifecycle::Pending
528 }
529 }
530
531 /// Worker count (slots seeded or observed via mailbox).
532 #[must_use]
533 pub fn worker_count(&self) -> usize {
534 self.workers.len()
535 }
536 }
537
538 fn delegate_header(
539 status: AgentLifecycle,
540 role: &str,
541 detail: &str,
542 width: usize,
543 theme: &palette::UiTheme,
544 ) -> Line<'static> {
545 let glyph = format!("{} ", family_glyph(ToolFamily::Delegate));
546 let fixed_parts: Vec<&str> = vec![glyph.as_str(), role, " "];
547 let fixed_width = fixed_parts
548 .iter()
549 .map(|text| UnicodeWidthStr::width(*text))
550 .sum::<usize>();
551 let detail = truncate_action(detail, width.saturating_sub(fixed_width));
552 let spans = vec![
553 Span::styled(
554 glyph,
555 Style::default()
556 .fg(status.color(theme))
557 .add_modifier(Modifier::BOLD),
558 ),
559 Span::styled(
560 role.to_string(),
561 Style::default()
562 .fg(codewhale_palette::grammar::ChromeInk::Identity.color(theme))
563 .add_modifier(Modifier::BOLD),
564 ),
565 Span::raw(" "),
566 Span::styled(
567 detail,
568 Style::default().fg(codewhale_palette::grammar::ChromeInk::Metadata.color(theme)),
569 ),
570 ];
571 Line::from(spans)
572 }
573
574 fn line_detail_width(line_width: usize, prefix: &str) -> usize {
575 line_width.saturating_sub(UnicodeWidthStr::width(prefix))
576 }
577
578 fn truncate_action(text: &str, max: usize) -> String {
579 truncate_line_to_width(text.trim(), max)
580 }
581
582 /// Apply a mailbox envelope to a `DelegateCard`. Returns `true` if the
583 /// state changed (UI may want to redraw); `false` if the envelope was for
584 /// a different `agent_id`.
585 pub fn apply_to_delegate(card: &mut DelegateCard, msg: &MailboxMessage) -> bool {
586 if msg.agent_id() != card.agent_id {
587 return false;
588 }
589 let was_terminal = card.status.is_terminal();
590 // A settled agent is final. Mailbox producers are concurrent (a tool
591 // task can outlive its agent), so a Started/Progress/tool envelope may
592 // land after the terminal one; it must not reopen the card, stretch its
593 // elapsed time, or rewrite its outcome. Only the child's own Work
594 // snapshot still applies (it keeps the terminal status), and a late
595 // `Started` may still name the role of a card that a completion-first
596 // delivery opened with the `unknown` placeholder — identity, not state.
597 if was_terminal {
598 if let MailboxMessage::Started { agent_type, .. } = msg
599 && card.agent_type == "unknown"
600 && agent_type != "unknown"
601 {
602 card.agent_type = agent_type.clone();
603 return true;
604 }
605 if !matches!(msg, MailboxMessage::WorkState { .. }) {
606 return false;
607 }
608 }
609 if card.started_at.is_none()
610 && matches!(
611 msg,
612 MailboxMessage::Started { .. }
613 | MailboxMessage::Progress { .. }
614 | MailboxMessage::ToolCallStarted { .. }
615 | MailboxMessage::ToolCallCompleted { .. }
616 | MailboxMessage::WorkState { .. }
617 )
618 {
619 card.started_at = Some(Instant::now());
620 }
621 match msg {
622 MailboxMessage::Started { .. } => {
623 if card.status == AgentLifecycle::Running {
624 return false;
625 }
626 card.status = AgentLifecycle::Running;
627 }
628 MailboxMessage::Progress { status, .. } => {
629 let low_signal = is_low_signal_progress(status);
630 if low_signal && card.status == AgentLifecycle::Running {
631 return false;
632 }
633 card.status = AgentLifecycle::Running;
634 if !low_signal {
635 card.push_action(status);
636 }
637 }
638 MailboxMessage::ToolCallStarted { tool_name, .. } => {
639 card.push_action(format!("{tool_name} running"));
640 }
641 MailboxMessage::ToolCallCompleted { tool_name, ok, .. } => {
642 card.push_action(format!("{tool_name} {}", if *ok { "ok" } else { "failed" }));
643 }
644 MailboxMessage::Completed { summary, .. } => {
645 card.status = AgentLifecycle::Completed;
646 card.summary = Some(summary.clone());
647 }
648 MailboxMessage::Failed { error, .. } => {
649 card.status = AgentLifecycle::Failed;
650 card.summary = Some(error.clone());
651 }
652 MailboxMessage::Interrupted { reason, .. } => {
653 card.status = AgentLifecycle::Interrupted;
654 card.summary = Some(reason.clone());
655 }
656 MailboxMessage::Cancelled { .. } => {
657 card.status = AgentLifecycle::Cancelled;
658 }
659 MailboxMessage::WorkState { todo, .. } => {
660 // agent_id already matched above, so this is this child's own
661 // list. Publishing live work is evidence that a pending child
662 // has started, while terminal cards keep both their terminal
663 // status and the last snapshot the child published.
664 let status_changed = if card.status == AgentLifecycle::Pending {
665 card.status = AgentLifecycle::Running;
666 true
667 } else {
668 false
669 };
670 return card.set_todo(todo.clone()) || status_changed;
671 }
672 MailboxMessage::ChildSpawned { .. } => {
673 // Delegate cards represent a single agent; child spawns belong
674 // to a sibling fanout card, not this one.
675 return false;
676 }
677 MailboxMessage::TokenUsage { .. } => {
678 // Cost accumulation happens in handle_subagent_mailbox (ui.rs)
679 // before this apply function is called; TokenUsage never reaches
680 // this arm in practice.
681 return false;
682 }
683 }
684 if !was_terminal && card.status.is_terminal() {
685 card.finished_at = Some(Instant::now());
686 }
687 true
688 }
689
690 /// Known limitation: this still matches on message text, while the footer
691 /// path keys off the structured `routine_wait` flag (#6290). The mailbox is
692 /// a stable cross-crate (`protocol`) surface whose payloads may come from
693 /// another binary, so this arm cannot assume the flag exists — and no
694 /// in-crate producer sends routine waits here anyway (only "queued" and
695 /// "running" texts), so threading the flag would change nothing. If a future
696 /// producer sends routine waits over the mailbox, give `Progress` the flag
697 /// and match on it here instead of extending this list.
698 fn is_low_signal_progress(status: &str) -> bool {
699 let status = status.trim().to_ascii_lowercase();
700 status.contains("requesting model response")
701 || status.starts_with("started (")
702 || (status.starts_with("step ") && status.contains(": complete"))
703 }
704
705 /// Apply a mailbox envelope to a `FanoutCard`. Updates per-worker state
706 /// based on which child the envelope is about. Returns `true` on change.
707 pub fn apply_to_fanout(card: &mut FanoutCard, msg: &MailboxMessage) -> bool {
708 let id = msg.agent_id();
709 match msg {
710 MailboxMessage::Started { .. } => card.claim_pending_worker(id, AgentLifecycle::Running),
711 MailboxMessage::Progress { .. } => card.claim_pending_worker(id, AgentLifecycle::Running),
712 MailboxMessage::ToolCallStarted { .. } => {
713 card.claim_pending_worker(id, AgentLifecycle::Running)
714 }
715 MailboxMessage::ToolCallCompleted { .. } => true,
716 MailboxMessage::Completed { .. } => card.upsert_worker(id, AgentLifecycle::Completed),
717 MailboxMessage::Failed { .. } => card.upsert_worker(id, AgentLifecycle::Failed),
718 MailboxMessage::Interrupted { .. } => card.upsert_worker(id, AgentLifecycle::Interrupted),
719 MailboxMessage::Cancelled { .. } => card.upsert_worker(id, AgentLifecycle::Cancelled),
720 MailboxMessage::ChildSpawned { child_id, .. } => {
721 card.upsert_worker(child_id, AgentLifecycle::Pending)
722 }
723 // A fanout card is a dot grid of many workers with no per-worker row
724 // to hang a list on. Rather than merge N children's lists into one
725 // card — which would be exactly the cross-agent leak this surface must
726 // not have — it shows none of them. WorkState is intentionally
727 // unavailable on this fanout surface; an individually spawned child
728 // may show its own To-do when it has a separate delegate card.
729 MailboxMessage::WorkState { .. } => false,
730 MailboxMessage::TokenUsage { .. } => {
731 // Cost accumulation happens in handle_subagent_mailbox (ui.rs)
732 // before this apply function is called; TokenUsage never reaches
733 // this arm in practice.
734 true
735 }
736 }
737 }
738
739 #[cfg(test)]
740 mod tests {
741 use super::*;
742 use unicode_width::UnicodeWidthStr;
743
744 fn render_to_strings(lines: &[Line<'static>]) -> Vec<String> {
745 lines
746 .iter()
747 .map(|line| {
748 line.spans
749 .iter()
750 .map(|span| span.content.as_ref())
751 .collect::<String>()
752 })
753 .collect()
754 }
755
756 #[test]
757 fn delegate_card_header_does_not_duplicate_verb_as_role() {
758 let card = DelegateCard::new("agent_1", "explore");
759 let rendered =
760 render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
761 assert!(
762 !rendered.contains("delegate"),
763 "delegate must not be visible in the card: {rendered:?}"
764 );
765 assert!(!rendered.contains("[running]"), "{rendered:?}");
766 let explore = DelegateCard::new("agent_2", "scout");
767 let explore_rendered =
768 render_to_strings(&explore.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
769 assert!(explore_rendered.contains("explore"), "{explore_rendered:?}");
770 }
771
772 #[test]
773 fn delegate_card_cjk_text_respects_render_width() {
774 let mut card = DelegateCard::new("agent_e0b2dcf1", "implementer");
775 card.status = AgentLifecycle::Running;
776 card.summary = Some(
777 "抹香鲸 agent_e0b2dcf1 running 10+ 124838ms role: implementer git: branch codex/issue-3439-zhipu-glm-fixture @ issue-3439".into(),
778 );
779 card.push_action("objective: QUESTION: Add Zhipu GLM as a first-class provider-scoped route for 中文输出".to_string());
780
781 let rendered = render_to_strings(&card.render_lines(40, &codewhale_palette::UI_THEME));
782
783 assert!(rendered[0].contains("implement"), "{rendered:?}");
784 for line in rendered {
785 let width = UnicodeWidthStr::width(line.as_str());
786 assert!(width <= 40, "line width {width} exceeds 40: {line:?}");
787 }
788 }
789
790 #[test]
791 fn delegate_card_truncates_to_last_three_actions_with_ellipsis() {
792 let mut card = DelegateCard::new("agent_001", "general");
793 card.push_action("read README.md");
794 card.push_action("grep TODO");
795 card.push_action("edit src/lib.rs");
796 // Up to the limit — no truncation yet.
797 assert!(!card.truncated());
798 assert_eq!(card.action_count(), DELEGATE_MAX_ACTIONS);
799
800 card.push_action("write tests");
801 card.push_action("run cargo test");
802 assert!(card.truncated(), "truncation flag flips on overflow");
803 assert_eq!(
804 card.action_count(),
805 DELEGATE_MAX_ACTIONS,
806 "stable steady-state size"
807 );
808
809 let rendered = render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME));
810 assert!(
811 rendered.iter().any(|line| line.contains('\u{2026}')),
812 "ellipsis indicator must render: got {rendered:?}"
813 );
814 // The oldest two actions ("read README.md", "grep TODO") were dropped.
815 assert!(
816 !rendered.iter().any(|line| line.contains("read README.md")),
817 "oldest action evicted: got {rendered:?}"
818 );
819 assert!(
820 rendered.iter().any(|line| line.contains("run cargo test")),
821 "newest action retained: got {rendered:?}"
822 );
823 assert!(
824 rendered.iter().any(|line| line.contains("write tests")),
825 "second-newest retained: got {rendered:?}"
826 );
827 assert!(
828 rendered.iter().any(|line| line.contains("edit src/lib.rs")),
829 "third-newest retained: got {rendered:?}"
830 );
831 }
832
833 #[test]
834 fn delegate_card_terminal_status_renders_summary_row() {
835 let mut card = DelegateCard::new("agent_002", "explore");
836 card.push_action("listing files");
837 let msg = MailboxMessage::Completed {
838 agent_id: "agent_002".into(),
839 summary: "scanned 42 files, no TODOs found".into(),
840 };
841 assert!(apply_to_delegate(&mut card, &msg));
842 assert_eq!(card.status, AgentLifecycle::Completed);
843 let rendered = render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME));
844 assert!(
845 rendered.iter().any(|line| line.contains("╰ done")),
846 "terminal status row renders done: got {rendered:?}"
847 );
848 assert!(
849 rendered
850 .iter()
851 .any(|line| line.contains("scanned 42 files")),
852 "summary row renders on terminal status: got {rendered:?}"
853 );
854 }
855
856 #[test]
857 fn delegate_card_ignores_low_signal_scheduler_progress() {
858 let mut card = DelegateCard::new("agent_003", "general");
859 let msg = MailboxMessage::progress("agent_003", "step 1/100: requesting model response");
860
861 assert!(apply_to_delegate(&mut card, &msg));
862 assert_eq!(card.status, AgentLifecycle::Running);
863 assert_eq!(
864 card.action_count(),
865 0,
866 "scheduler progress should not become a stale transcript row"
867 );
868
869 let rendered =
870 render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
871 assert!(!rendered.contains("step 1/100"), "{rendered}");
872 assert!(
873 !rendered.contains("requesting model response"),
874 "{rendered}"
875 );
876 assert!(
877 !apply_to_delegate(&mut card, &msg),
878 "repeated low-signal progress should not redraw the card"
879 );
880 }
881
882 #[test]
883 fn delegate_tool_rows_omit_internal_step_numbers() {
884 let mut card = DelegateCard::new("agent_004", "general");
885
886 assert!(apply_to_delegate(
887 &mut card,
888 &MailboxMessage::ToolCallStarted {
889 agent_id: "agent_004".into(),
890 tool_name: "read_file".into(),
891 step: 7,
892 }
893 ));
894 assert!(apply_to_delegate(
895 &mut card,
896 &MailboxMessage::ToolCallCompleted {
897 agent_id: "agent_004".into(),
898 tool_name: "read_file".into(),
899 step: 7,
900 ok: true,
901 }
902 ));
903
904 let rendered =
905 render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
906 assert!(rendered.contains("read_file"), "{rendered}");
907 assert!(
908 !rendered.contains("[7]"),
909 "internal loop step numbers are not useful in the live card: {rendered}"
910 );
911 }
912
913 #[test]
914 fn delegate_card_ignores_envelopes_for_other_agents() {
915 let mut card = DelegateCard::new("agent_a", "general");
916 let other = MailboxMessage::progress("agent_b", "noise");
917 assert!(!apply_to_delegate(&mut card, &other));
918 assert_eq!(card.action_count(), 0);
919 }
920
921 #[test]
922 fn fanout_card_dot_grid_renders_stateful_worker_slots() {
923 let mut card = FanoutCard::new("fanout")
924 .with_workers(["w_1", "w_2", "w_3", "w_4", "w_5", "w_6", "w_7"]);
925 card.upsert_worker("w_1", AgentLifecycle::Completed);
926 card.upsert_worker("w_2", AgentLifecycle::Completed);
927 card.upsert_worker("w_3", AgentLifecycle::Running);
928 card.upsert_worker("w_4", AgentLifecycle::Failed);
929 // 5/6/7 stay Pending.
930
931 // Completed fills; running and failed are distinct; pending stays open.
932 assert_eq!(
933 card.dot_grid(),
934 "\u{25CF}\u{25CF}\u{25D0}\u{00D7}\u{25CB}\u{25CB}\u{25CB}"
935 );
936 }
937
938 #[test]
939 fn fanout_card_header_and_dot_grid_surface_aggregate_state() {
940 let mut card = FanoutCard::new("rlm").with_workers(["w_1", "w_2", "w_3", "w_4"]);
941 card.upsert_worker("w_1", AgentLifecycle::Completed);
942 card.upsert_worker("w_2", AgentLifecycle::Completed);
943 card.upsert_worker("w_3", AgentLifecycle::Completed);
944 card.upsert_worker("w_4", AgentLifecycle::Failed);
945 let rendered =
946 render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
947 assert!(
948 rendered.contains("4 agents"),
949 "header should show count: {rendered}"
950 );
951 assert!(
952 rendered.starts_with("⋮⋮ 4 agents"),
953 "header should omit the fanout kind: {rendered}"
954 );
955 assert!(
956 rendered.contains("\u{25CF}\u{25CF}\u{25CF}\u{00D7}"),
957 "dot grid should mirror worker states: {rendered}"
958 );
959 assert!(
960 !rendered.contains(" pending"),
961 "redundant counts line should stay omitted: {rendered}"
962 );
963 }
964
965 #[test]
966 fn fanout_apply_inserts_unknown_worker_via_child_spawned() {
967 let mut card = FanoutCard::new("fanout");
968 let msg = MailboxMessage::ChildSpawned {
969 parent_id: "root".into(),
970 child_id: "agent_late".into(),
971 };
972 assert!(apply_to_fanout(&mut card, &msg));
973 assert_eq!(card.worker_count(), 1);
974 assert_eq!(card.workers[0].agent_id, "agent_late");
975 assert_eq!(card.workers[0].status, AgentLifecycle::Pending);
976 }
977
978 #[test]
979 fn fanout_started_claims_seeded_pending_slot_without_growing_grid() {
980 let mut card = FanoutCard::new("fanout").with_workers(["task:a", "task:b"]);
981 let started =
982 MailboxMessage::started("agent_live", crate::tools::subagent::FleetRole::Worker);
983
984 assert!(apply_to_fanout(&mut card, &started));
985
986 assert_eq!(card.worker_count(), 2);
987 assert_eq!(card.workers[0].agent_id, "agent_live");
988 assert_eq!(card.workers[0].status, AgentLifecycle::Running);
989 assert_eq!(card.workers[1].agent_id, "task:b");
990 assert_eq!(card.workers[1].status, AgentLifecycle::Pending);
991 let progress =
992 MailboxMessage::progress("agent_live", "step 1/100: requesting model response");
993 assert!(
994 !apply_to_fanout(&mut card, &progress),
995 "repeated progress for a running worker should not redraw"
996 );
997 }
998
999 #[test]
1000 fn fanout_apply_transitions_worker_through_lifecycle() {
1001 let mut card = FanoutCard::new("fanout").with_workers(["w_1"]);
1002 let started = MailboxMessage::started("w_1", crate::tools::subagent::FleetRole::Worker);
1003 apply_to_fanout(&mut card, &started);
1004 assert_eq!(card.workers[0].status, AgentLifecycle::Running);
1005
1006 let done = MailboxMessage::Completed {
1007 agent_id: "w_1".into(),
1008 summary: "ok".into(),
1009 };
1010 apply_to_fanout(&mut card, &done);
1011 assert_eq!(card.workers[0].status, AgentLifecycle::Completed);
1012 }
1013
1014 #[test]
1015 fn fanout_dot_grid_arithmetic_for_various_n() {
1016 // Spot-check several fanout sizes with a mix of states; this is the
1017 // arithmetic snapshot the issue acceptance calls out.
1018 let cases: &[(usize, usize, &str)] = &[
1019 (1, 0, "\u{25CB}"),
1020 (1, 1, "\u{25CF}"),
1021 (3, 2, "\u{25CF}\u{25CF}\u{25CB}"),
1022 (
1023 7,
1024 3,
1025 "\u{25CF}\u{25CF}\u{25CF}\u{25CB}\u{25CB}\u{25CB}\u{25CB}",
1026 ),
1027 ];
1028 for (total, done, expected) in cases {
1029 let ids: Vec<String> = (0..*total).map(|i| format!("w_{i}")).collect();
1030 let mut card = FanoutCard::new("fanout").with_workers(ids.iter().cloned());
1031 for id in ids.iter().take(*done) {
1032 card.upsert_worker(id, AgentLifecycle::Completed);
1033 }
1034 assert_eq!(
1035 card.dot_grid(),
1036 *expected,
1037 "fanout dot-grid for total={total} done={done}",
1038 );
1039 }
1040 }
1041
1042 #[test]
1043 fn delegate_interrupted_leaves_running_and_renders_reason() {
1044 let mut card = DelegateCard::new("agent_int", "general");
1045 apply_to_delegate(
1046 &mut card,
1047 &MailboxMessage::started("agent_int", crate::tools::subagent::FleetRole::Worker),
1048 );
1049 assert_eq!(card.status, AgentLifecycle::Running);
1050
1051 let msg = MailboxMessage::Interrupted {
1052 agent_id: "agent_int".into(),
1053 reason: "API call timed out after 120000ms; checkpoint preserved for continuation"
1054 .into(),
1055 };
1056 assert!(apply_to_delegate(&mut card, &msg));
1057 assert_eq!(card.status, AgentLifecycle::Interrupted);
1058
1059 let rendered =
1060 render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
1061 assert!(rendered.contains("╰ interrupted"), "{rendered}");
1062 assert!(rendered.contains("API call timed out"), "{rendered}");
1063 }
1064
1065 #[test]
1066 fn fanout_interrupted_worker_leaves_running_counts() {
1067 let mut card = FanoutCard::new("fanout").with_workers(["w_1", "w_2"]);
1068 apply_to_fanout(
1069 &mut card,
1070 &MailboxMessage::started("w_1", crate::tools::subagent::FleetRole::Worker),
1071 );
1072 apply_to_fanout(
1073 &mut card,
1074 &MailboxMessage::started("w_2", crate::tools::subagent::FleetRole::Worker),
1075 );
1076
1077 let msg = MailboxMessage::Interrupted {
1078 agent_id: "w_1".into(),
1079 reason: "API call timed out".into(),
1080 };
1081 assert!(apply_to_fanout(&mut card, &msg));
1082 assert_eq!(card.workers[0].status, AgentLifecycle::Interrupted);
1083 assert_eq!(card.workers[1].status, AgentLifecycle::Running);
1084
1085 // Copy dedupe (Wave 5c #4): the counts line is gone — the header and
1086 // dot grid carry the aggregate state instead.
1087 let rendered =
1088 render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
1089 assert!(rendered.contains("2 agents"), "{rendered}");
1090 assert!(
1091 rendered.contains('\u{25D0}'),
1092 "dot grid should keep the running worker glyph: {rendered}"
1093 );
1094 assert!(
1095 rendered.contains('\u{25CC}'),
1096 "dot grid should mark the interrupted worker: {rendered}"
1097 );
1098
1099 let msg = MailboxMessage::Interrupted {
1100 agent_id: "w_2".into(),
1101 reason: "API call timed out".into(),
1102 };
1103 assert!(apply_to_fanout(&mut card, &msg));
1104 let rendered =
1105 render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME)).join("\n");
1106 assert!(rendered.contains("2 agents"), "{rendered}");
1107 }
1108
1109 #[test]
1110 fn fanout_card_omits_redundant_counts_line_when_header_and_grid_present() {
1111 let ids: Vec<String> = (0..16).map(|i| format!("w_{i}")).collect();
1112 let mut card = FanoutCard::new("fanout").with_workers(ids.iter().cloned());
1113 for id in ids.iter().take(12) {
1114 card.upsert_worker(id, AgentLifecycle::Completed);
1115 }
1116 card.upsert_worker("w_12", AgentLifecycle::Running);
1117
1118 let rendered = render_to_strings(&card.render_lines(80, &codewhale_palette::UI_THEME));
1119 assert!(
1120 rendered.iter().any(|line| line.contains('\u{25CF}')),
1121 "dot grid should remain: {rendered:?}"
1122 );
1123 assert!(
1124 !rendered.iter().any(|line| line.contains('·')),
1125 "counts line should be dropped: {rendered:?}"
1126 );
1127 }
1128
1129 // === #4810: a child's own To-do on its own card ===
1130
1131 use crate::tools::todo::{TodoItem, TodoStatus};
1132
1133 fn todo(items: &[(u32, &str, TodoStatus)], in_progress_id: Option<u32>) -> TodoListSnapshot {
1134 let items: Vec<TodoItem> = items
1135 .iter()
1136 .map(|(id, content, status)| TodoItem {
1137 id: *id,
1138 content: (*content).to_string(),
1139 status: *status,
1140 })
1141 .collect();
1142 let settled = items.iter().filter(|item| item.status.is_settled()).count();
1143 let completion_pct = if items.is_empty() {
1144 0
1145 } else {
1146 ((settled * 100) / items.len()) as u8
1147 };
1148 TodoListSnapshot {
1149 items,
1150 completion_pct,
1151 in_progress_id,
1152 }
1153 }
1154
1155 fn work_state(agent_id: &str, snapshot: TodoListSnapshot) -> MailboxMessage {
1156 MailboxMessage::WorkState {
1157 agent_id: agent_id.to_string(),
1158 todo: snapshot,
1159 }
1160 }
1161
1162 #[test]
1163 fn delegate_card_renders_the_childs_own_todo_under_its_row() {
1164 let mut card = DelegateCard::new("agent_child", "implementer");
1165 apply_to_delegate(
1166 &mut card,
1167 &MailboxMessage::started("agent_child", crate::tools::subagent::FleetRole::Worker),
1168 );
1169 assert!(apply_to_delegate(
1170 &mut card,
1171 &work_state(
1172 "agent_child",
1173 todo(
1174 &[
1175 (1, "read the runtime seam", TodoStatus::Completed),
1176 (2, "write the projection", TodoStatus::InProgress),
1177 ],
1178 Some(2),
1179 ),
1180 ),
1181 ));
1182
1183 let rendered =
1184 render_to_strings(&card.render_lines(100, &codewhale_palette::UI_THEME)).join("\n");
1185 assert!(rendered.contains("To-do 1/2"), "{rendered}");
1186 assert!(rendered.contains("50% settled"), "{rendered}");
1187 assert!(
1188 rendered.contains("[~] #2 write the projection"),
1189 "{rendered}"
1190 );
1191 assert!(
1192 rendered.contains("[x] #1 read the runtime seam"),
1193 "{rendered}"
1194 );
1195 // Role, model-facing lifecycle label, and identity stay exactly as the
1196 // row already reported them.
1197 assert!(rendered.contains("implement"), "{rendered}");
1198 }
1199
1200 #[test]
1201 fn delegate_card_ignores_work_state_addressed_to_another_agent() {
1202 let mut card = DelegateCard::new("agent_a", "general");
1203 assert!(!apply_to_delegate(
1204 &mut card,
1205 &work_state(
1206 "agent_b",
1207 todo(&[(1, "sibling only work", TodoStatus::InProgress)], Some(1)),
1208 ),
1209 ));
1210 assert!(card.todo_projection().is_none());
1211 let rendered =
1212 render_to_strings(&card.render_lines(100, &codewhale_palette::UI_THEME)).join("\n");
1213 assert!(!rendered.contains("sibling only work"), "{rendered}");
1214 assert!(!rendered.contains("To-do"), "{rendered}");
1215 }
1216
1217 #[test]
1218 fn delegate_card_shows_a_same_turn_update_without_waiting_for_completion() {
1219 let mut card = DelegateCard::new("agent_child", "general");
1220 apply_to_delegate(
1221 &mut card,
1222 &work_state(
1223 "agent_child",
1224 todo(&[(1, "draft the fix", TodoStatus::InProgress)], Some(1)),
1225 ),
1226 );
1227
1228 // Same step: the child calls work_update and immediately republishes.
1229 apply_to_delegate(
1230 &mut card,
1231 &MailboxMessage::ToolCallCompleted {
1232 agent_id: "agent_child".to_string(),
1233 tool_name: "work_update".to_string(),
1234 step: 1,
1235 ok: true,
1236 },
1237 );
1238 assert!(
1239 apply_to_delegate(
1240 &mut card,
1241 &work_state(
1242 "agent_child",
1243 todo(
1244 &[
1245 (1, "draft the fix", TodoStatus::Completed),
1246 (2, "add the regression", TodoStatus::InProgress),
1247 ],
1248 Some(2),
1249 ),
1250 ),
1251 ),
1252 "a changed list must redraw the card"
1253 );
1254
1255 let rendered =
1256 render_to_strings(&card.render_lines(100, &codewhale_palette::UI_THEME)).join("\n");
1257 assert_eq!(card.status, AgentLifecycle::Running, "still mid-turn");
1258 assert!(rendered.contains("[~] #2 add the regression"), "{rendered}");
1259 assert!(rendered.contains("[x] #1 draft the fix"), "{rendered}");
1260 assert!(rendered.contains("To-do 1/2"), "{rendered}");
1261
1262 // Republishing the identical snapshot is not a visible change.
1263 assert!(!apply_to_delegate(
1264 &mut card,
1265 &work_state(
1266 "agent_child",
1267 todo(
1268 &[
1269 (1, "draft the fix", TodoStatus::Completed),
1270 (2, "add the regression", TodoStatus::InProgress),
1271 ],
1272 Some(2),
1273 ),
1274 ),
1275 ));
1276 }
1277
1278 #[test]
1279 fn delegate_card_empty_child_todo_renders_no_item_at_all() {
1280 let mut card = DelegateCard::new("agent_child", "general");
1281 card.push_action("read_file ok");
1282 apply_to_delegate(
1283 &mut card,
1284 &work_state("agent_child", TodoListSnapshot::default()),
1285 );
1286
1287 assert!(card.todo_projection().is_none());
1288 let rendered =
1289 render_to_strings(&card.render_lines(100, &codewhale_palette::UI_THEME)).join("\n");
1290 assert!(
1291 !rendered.contains("To-do"),
1292 "an empty list states nothing: {rendered}"
1293 );
1294 assert!(
1295 !rendered.contains('#'),
1296 "no synthesized item may appear: {rendered}"
1297 );
1298 assert!(rendered.contains("read_file ok"), "{rendered}");
1299 }
1300
1301 #[test]
1302 fn delegate_card_todo_is_bounded_and_marks_what_it_elided() {
1303 let items: Vec<(u32, String, TodoStatus)> = (1..=9)
1304 .map(|id| {
1305 (
1306 id,
1307 format!("item {id} ").repeat(30),
1308 if id == 8 {
1309 TodoStatus::InProgress
1310 } else {
1311 TodoStatus::Pending
1312 },
1313 )
1314 })
1315 .collect();
1316 let refs: Vec<(u32, &str, TodoStatus)> = items
1317 .iter()
1318 .map(|(id, content, status)| (*id, content.as_str(), *status))
1319 .collect();
1320 let mut card = DelegateCard::new("agent_child", "general");
1321 apply_to_delegate(&mut card, &work_state("agent_child", todo(&refs, Some(8))));
1322
1323 let projection = card.todo_projection().expect("projection");
1324 assert_eq!(
1325 projection.items.len(),
1326 crate::todo_snapshot::MAX_CARD_ITEM_LINES
1327 );
1328 assert_eq!(
1329 projection.omitted,
1330 9 - crate::todo_snapshot::MAX_CARD_ITEM_LINES
1331 );
1332 assert!(
1333 projection
1334 .items
1335 .iter()
1336 .any(|line| line.starts_with("[~] #8")),
1337 "the active item is never the one dropped: {projection:?}"
1338 );
1339
1340 let rendered = render_to_strings(&card.render_lines(60, &codewhale_palette::UI_THEME));
1341 assert!(
1342 rendered.iter().any(|line| line.contains("+6 more")),
1343 "elision must be stated: {rendered:?}"
1344 );
1345 for line in &rendered {
1346 assert!(
1347 UnicodeWidthStr::width(line.as_str()) <= 60,
1348 "line exceeds the card width: {line:?}"
1349 );
1350 }
1351 }
1352
1353 #[test]
1354 fn terminal_delegate_cards_keep_the_last_child_todo() {
1355 for terminal in [
1356 MailboxMessage::Completed {
1357 agent_id: "agent_child".to_string(),
1358 summary: "done".to_string(),
1359 },
1360 MailboxMessage::Failed {
1361 agent_id: "agent_child".to_string(),
1362 error: "boom".to_string(),
1363 },
1364 MailboxMessage::Cancelled {
1365 agent_id: "agent_child".to_string(),
1366 },
1367 ] {
1368 let mut card = DelegateCard::new("agent_child", "general");
1369 apply_to_delegate(
1370 &mut card,
1371 &work_state(
1372 "agent_child",
1373 todo(
1374 &[
1375 (1, "land the fix", TodoStatus::Completed),
1376 (2, "run the suite", TodoStatus::InProgress),
1377 ],
1378 Some(2),
1379 ),
1380 ),
1381 );
1382 apply_to_delegate(&mut card, &terminal);
1383
1384 let rendered =
1385 render_to_strings(&card.render_lines(100, &codewhale_palette::UI_THEME)).join("\n");
1386 assert!(card.status.is_terminal(), "{:?}", card.status);
1387 assert!(
1388 rendered.contains("[~] #2 run the suite"),
1389 "terminal card keeps the last truthful list ({:?}): {rendered}",
1390 card.status
1391 );
1392 assert!(rendered.contains("To-do 1/2"), "{rendered}");
1393 }
1394 }
1395
1396 #[test]
1397 fn fanout_card_does_not_project_any_workers_todo() {
1398 let mut card = FanoutCard::new("fanout").with_workers(["w_1", "w_2"]);
1399 assert!(!apply_to_fanout(
1400 &mut card,
1401 &work_state(
1402 "w_1",
1403 todo(&[(1, "worker one work", TodoStatus::InProgress)], Some(1)),
1404 ),
1405 ));
1406 let rendered =
1407 render_to_strings(&card.render_lines(100, &codewhale_palette::UI_THEME)).join("\n");
1408 assert!(!rendered.contains("worker one work"), "{rendered}");
1409 assert!(!rendered.contains("To-do"), "{rendered}");
1410 }
1411
1412 #[test]
1413 fn direct_subagent_projects_onto_shared_workflow_history_card() {
1414 use crate::tui::widgets::workflow_panel::WorkflowHistoryExtras;
1415
1416 let mut card = DelegateCard::new("agent_xyz", "explore");
1417 card.status = AgentLifecycle::Completed;
1418 card.summary = Some("mapped 4 call sites".to_string());
1419 let panel = card.as_workflow_history_panel(1_000, Some(5_000));
1420 let compact = panel.render_history_card(100, false, &WorkflowHistoryExtras::default());
1421 let joined = render_to_strings(&compact).join("\n");
1422 assert!(
1423 joined.contains("success") || joined.contains("explore"),
1424 "shared compact lifecycle: {joined}"
1425 );
1426 assert!(
1427 joined.contains("1/1 done"),
1428 "shared settled/total count: {joined}"
1429 );
1430 let expanded = panel.render_history_card(
1431 100,
1432 true,
1433 &WorkflowHistoryExtras {
1434 result_summary: Some("mapped 4 call sites".to_string()),
1435 ..WorkflowHistoryExtras::default()
1436 },
1437 );
1438 let joined = render_to_strings(&expanded).join("\n");
1439 assert!(joined.contains("result:"), "{joined}");
1440 assert!(joined.contains("mapped 4 call sites"), "{joined}");
1441 }
1442
1443 /// U05-04: mailbox producers are concurrent, so a Started/Progress/tool
1444 /// envelope can land after an agent's terminal one. It must not reopen
1445 /// the card or its fanout slot, and must not move a settled count.
1446 #[test]
1447 fn late_envelopes_never_reopen_a_settled_agent() {
1448 let role = || crate::tools::subagent::FleetRole::Worker;
1449 let mut card = DelegateCard::new("agent_late", "general");
1450 apply_to_delegate(&mut card, &MailboxMessage::started("agent_late", role()));
1451 assert!(apply_to_delegate(
1452 &mut card,
1453 &MailboxMessage::Completed {
1454 agent_id: "agent_late".into(),
1455 summary: "done".into(),
1456 },
1457 ));
1458 let finished_at = card.finished_at;
1459 for late in [
1460 MailboxMessage::started("agent_late", role()),
1461 MailboxMessage::progress("agent_late", "step 4: reading files"),
1462 MailboxMessage::ToolCallStarted {
1463 agent_id: "agent_late".into(),
1464 tool_name: "read_file".into(),
1465 step: 4,
1466 },
1467 MailboxMessage::Failed {
1468 agent_id: "agent_late".into(),
1469 error: "late".into(),
1470 },
1471 ] {
1472 assert!(!apply_to_delegate(&mut card, &late), "{late:?}");
1473 }
1474 assert_eq!(card.status, AgentLifecycle::Completed);
1475 assert_eq!(card.summary.as_deref(), Some("done"));
1476 assert_eq!(card.finished_at, finished_at);
1477
1478 // A completion-first recovery card may still learn its role from a
1479 // late `Started`, but it stays settled.
1480 let mut recovered = DelegateCard::new("agent_r", "unknown");
1481 apply_to_delegate(
1482 &mut recovered,
1483 &MailboxMessage::Completed {
1484 agent_id: "agent_r".into(),
1485 summary: "done".into(),
1486 },
1487 );
1488 assert!(apply_to_delegate(
1489 &mut recovered,
1490 &MailboxMessage::started("agent_r", role())
1491 ));
1492 assert_ne!(recovered.agent_type, "unknown");
1493 assert_eq!(recovered.status, AgentLifecycle::Completed);
1494
1495 let mut fanout = FanoutCard::new("rlm");
1496 for id in ["w_1", "w_2"] {
1497 apply_to_fanout(
1498 &mut fanout,
1499 &MailboxMessage::ChildSpawned {
1500 parent_id: "root".into(),
1501 child_id: id.into(),
1502 },
1503 );
1504 apply_to_fanout(&mut fanout, &MailboxMessage::started(id, role()));
1505 apply_to_fanout(
1506 &mut fanout,
1507 &MailboxMessage::Completed {
1508 agent_id: id.into(),
1509 summary: "ok".into(),
1510 },
1511 );
1512 }
1513 assert!(!apply_to_fanout(
1514 &mut fanout,
1515 &MailboxMessage::progress("w_1", "step 9: late")
1516 ));
1517 assert!(!apply_to_fanout(
1518 &mut fanout,
1519 &MailboxMessage::started("w_2", role())
1520 ));
1521 assert_eq!(fanout.worker_count(), 2);
1522 assert_eq!(fanout.counts(), (2, 0, 0, 0));
1523 assert_eq!(fanout.aggregate_status_public(), AgentLifecycle::Completed);
1524 }
1525
1526 /// U05-m2: a settled fanout headlines its worst outcome — one failure
1527 /// among completions is a failed fanout, not a completed one.
1528 #[test]
1529 fn mixed_fanout_outcomes_never_aggregate_to_completed() {
1530 let mut card = FanoutCard::new("rlm").with_workers(["w_1", "w_2", "w_3"]);
1531 card.upsert_worker("w_1", AgentLifecycle::Completed);
1532 card.upsert_worker("w_2", AgentLifecycle::Completed);
1533 card.upsert_worker("w_3", AgentLifecycle::Failed);
1534 assert_eq!(card.aggregate_status_public(), AgentLifecycle::Failed);
1535
1536 let mut card = FanoutCard::new("rlm").with_workers(["w_1", "w_2"]);
1537 card.upsert_worker("w_1", AgentLifecycle::Completed);
1538 card.upsert_worker("w_2", AgentLifecycle::Cancelled);
1539 assert_eq!(card.aggregate_status_public(), AgentLifecycle::Cancelled);
1540
1541 let mut card = FanoutCard::new("rlm").with_workers(["w_1"]);
1542 card.upsert_worker("w_1", AgentLifecycle::Completed);
1543 assert_eq!(card.aggregate_status_public(), AgentLifecycle::Completed);
1544 }
1545
1546 /// U05-m6: a wide fanout wraps its dot grid instead of running past the
1547 /// transcript width, and every worker keeps its glyph.
1548 #[test]
1549 fn fanout_header_fits_zero_and_one_column_renders() {
1550 let ids: Vec<String> = (0..5).map(|i| format!("w_{i}")).collect();
1551 let card = FanoutCard::new("rlm").with_workers(ids.iter().cloned());
1552 // Every width from nothing up past the header, including the ones
1553 // where the count is truncated and the grid wraps.
1554 for width in 0_u16..=16 {
1555 let lines = render_to_strings(&card.render_lines(width, &codewhale_palette::UI_THEME));
1556 assert!(!lines.is_empty(), "{width}");
1557 for line in &lines {
1558 assert!(
1559 UnicodeWidthStr::width(line.as_str()) <= usize::from(width),
1560 "{width}: {line:?}"
1561 );
1562 }
1563 }
1564 }
1565
1566 #[test]
1567 fn fanout_grid_wraps_within_the_render_width() {
1568 let ids: Vec<String> = (0..40).map(|i| format!("w_{i}")).collect();
1569 let card = FanoutCard::new("rlm").with_workers(ids.iter().cloned());
1570 for width in [12_u16, 20, 80] {
1571 let lines = render_to_strings(&card.render_lines(width, &codewhale_palette::UI_THEME));
1572 for line in &lines {
1573 assert!(
1574 UnicodeWidthStr::width(line.as_str()) <= usize::from(width),
1575 "{width}: {line:?}"
1576 );
1577 }
1578 let glyphs = lines
1579 .join("")
1580 .chars()
1581 .filter(|ch| *ch == '\u{25CB}')
1582 .count();
1583 assert_eq!(glyphs, 40, "{width}: {lines:?}");
1584 }
1585 }
1586 }
1587
1587 lines RUST