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