返回 CodeWhale
subagent_routing.rs
根目录 / crates / tui / src / tui / subagent_routing.rs
1 //! Sub-agent and background-task routing helpers for the TUI loop.
2
3 use std::time::{Duration, Instant};
4
5 use crate::task_manager::{TaskRecord, TaskStatus, TaskSummary};
6 use crate::tools::subagent::{
7 AgentWorkerStatus, MailboxMessage, SubAgentResult, SubAgentStatus,
8 subagent_progress_tool_display_name,
9 };
10 use crate::tui::app::{
11 AgentCurrentActivity, AgentCurrentActivityStatus, AgentProgressMeta, AgentRecentAction, App,
12 MAX_AGENT_RECENT_ACTIONS, TaskPanelEntry, TaskPanelEntryKind, bound_agent_activity_text,
13 };
14 use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output};
15 use crate::tui::pager::PagerView;
16 use crate::tui::tool_routing::refreshes_workspace_context_on_completion;
17 use crate::tui::widgets::agent_card::{
18 AgentLifecycle, DelegateCard, FanoutCard, apply_to_delegate, apply_to_fanout,
19 };
20 use crate::tui::workspace_context;
21 use codewhale_config::AppMode;
22
23 /// Keep settled cards visible briefly, then archive them from the compact
24 /// live projection. Their transcript card and persisted agent record remain
25 /// reachable through the Agents register.
26 const SUBAGENT_TERMINAL_CARD_TTL: Duration = Duration::from_secs(45);
27 const SUBAGENT_TERMINAL_CARD_MAX_RETAINED: usize = 24;
28
29 pub(super) fn running_agent_count(app: &App) -> usize {
30 let mut ids: std::collections::HashSet<&str> =
31 app.agent_progress.keys().map(String::as_str).collect();
32 for agent in app
33 .subagent_cache
34 .iter()
35 .filter(|agent| matches!(agent.status, SubAgentStatus::Running))
36 {
37 ids.insert(agent.agent_id.as_str());
38 }
39 ids.len()
40 }
41
42 /// Describe detached workers that deliberately survive a parent-turn stop.
43 ///
44 /// `agent` starts are detached from the turn cancellation token, so a plain
45 /// "Request cancelled" receipt is incomplete whenever live workers remain.
46 /// Use stable UI labels where available and raw ids as a lossless fallback;
47 /// sorting keeps the receipt deterministic across HashMap iteration order.
48 pub(super) fn parent_stop_status(app: &App, base: &str) -> String {
49 let mut ids = std::collections::BTreeSet::new();
50 ids.extend(app.agent_progress.keys().cloned());
51 ids.extend(
52 app.subagent_cache
53 .iter()
54 .filter(|agent| matches!(agent.status, SubAgentStatus::Running))
55 .map(|agent| agent.agent_id.clone()),
56 );
57 if ids.is_empty() {
58 return base.to_string();
59 }
60
61 let labels = ids
62 .into_iter()
63 .map(|id| {
64 app.agent_label_map
65 .get(&id)
66 .filter(|label| !label.trim().is_empty())
67 .cloned()
68 .unwrap_or(id)
69 })
70 .collect::<Vec<_>>();
71 format!(
72 "{base}; detached workers continue (none canceled): {}",
73 labels.join(", ")
74 )
75 }
76
77 pub(super) fn active_fanout_counts(app: &App) -> Option<(usize, usize)> {
78 // Read running count from the canonical slot states on the active
79 // FanoutCard, if one exists. Used by `rlm` and any future multi-child
80 // dispatch the parent agent makes via repeated `agent`.
81 if let Some(idx) = app.last_fanout_card_index
82 && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get(idx)
83 {
84 let running = card
85 .workers
86 .iter()
87 .filter(|slot| matches!(slot.status, AgentLifecycle::Running))
88 .count();
89 return Some((running, card.worker_count()));
90 }
91 None
92 }
93
94 /// True when this child settled by being *parked* at the parent's turn end
95 /// rather than by asking anyone anything (#5906).
96 ///
97 /// The runtime parks a turn-owned child with a `needs_input` note that reads
98 /// like a question ("Resume this parked child with ..."), so every surface
99 /// that keys off `needs_input` used to render a parked husk with the same
100 /// "waiting for input" label as a child a user can actually answer. The
101 /// checkpoint records which of the two it is; this is the one place the UI
102 /// asks, so no surface has to sniff the reason string.
103 pub(crate) fn subagent_is_parked(agent: &SubAgentResult) -> bool {
104 agent
105 .checkpoint
106 .as_ref()
107 .is_some_and(|checkpoint| checkpoint.parked_at_turn_end)
108 }
109
110 /// The one-line recovery a parked row shows instead of a pending question:
111 /// nobody will answer it, so it names the two ways out that actually exist.
112 pub(crate) fn parked_recovery_detail(app: &App) -> String {
113 codewhale_localization::tr(
114 app.ui_locale,
115 codewhale_localization::MessageId::AgentStatusParkedRecovery,
116 )
117 .into_owned()
118 }
119
120 pub(super) fn reconcile_subagent_activity_state(app: &mut App) {
121 reconcile_subagent_activity_state_at(app, Instant::now());
122 }
123
124 pub(super) fn apply_subagent_terminal_projection(
125 app: &mut App,
126 agent_id: &str,
127 status: SubAgentStatus,
128 result: Option<String>,
129 ) -> bool {
130 app.agent_progress.remove(agent_id);
131
132 let worker_status = worker_status_for_terminal_projection(&status);
133 let safe_result = result.map(|result| bound_agent_activity_text(&result));
134 let meta = app
135 .agent_progress_meta
136 .entry(agent_id.to_string())
137 .or_default();
138 // A parked child projects as `Interrupted` here. Interrupted-over-Waiting
139 // and interrupted-over-Parked are both "the settled state the surface
140 // already established, restated" — do not downgrade either (#5906).
141 let sticky = meta
142 .current_activity
143 .as_ref()
144 .map(|activity| activity.status)
145 .filter(|status| {
146 matches!(
147 status,
148 AgentCurrentActivityStatus::Waiting | AgentCurrentActivityStatus::Parked
149 )
150 });
151 let activity_status = match sticky {
152 Some(status) if worker_status == AgentWorkerStatus::Interrupted => status,
153 _ => worker_status.into(),
154 };
155 let step = meta
156 .current_activity
157 .as_ref()
158 .and_then(|activity| activity.step);
159 meta.current_activity = Some(AgentCurrentActivity::bounded(
160 activity_status,
161 safe_result.clone(),
162 None,
163 step,
164 ));
165 meta.current_tool = None;
166
167 let Some(agent) = app
168 .subagent_cache
169 .iter_mut()
170 .find(|agent| agent.agent_id == agent_id)
171 else {
172 reconcile_subagent_activity_state(app);
173 return false;
174 };
175
176 agent.worker_status = Some(worker_status);
177 agent.status = status;
178 if let Some(result) = safe_result {
179 agent.result = Some(result);
180 }
181 reconcile_subagent_activity_state(app);
182 true
183 }
184
185 fn worker_status_for_terminal_projection(status: &SubAgentStatus) -> AgentWorkerStatus {
186 match status {
187 SubAgentStatus::Running => AgentWorkerStatus::Running,
188 SubAgentStatus::Completed => AgentWorkerStatus::Completed,
189 SubAgentStatus::Interrupted(_) => AgentWorkerStatus::Interrupted,
190 SubAgentStatus::Failed(_) | SubAgentStatus::BudgetExhausted => AgentWorkerStatus::Failed,
191 SubAgentStatus::Cancelled => AgentWorkerStatus::Cancelled,
192 }
193 }
194
195 pub(super) fn reconcile_subagent_activity_state_at(app: &mut App, now: Instant) {
196 reconcile_terminal_subagent_card_retention(app, now);
197
198 let cached_agents = app.subagent_cache.clone();
199 let running_agents: Vec<(String, String)> = cached_agents
200 .iter()
201 .filter(|agent| matches!(agent.status, SubAgentStatus::Running))
202 .map(|agent| {
203 (
204 agent.agent_id.clone(),
205 summarize_tool_output(&agent.assignment.objective),
206 )
207 })
208 .collect();
209
210 let running_ids: std::collections::HashSet<String> =
211 running_agents.iter().map(|(id, _)| id.clone()).collect();
212 // Evict a progress row only when the authoritative cache actually knows
213 // the agent and reports it non-running. A progress-only entry — an agent
214 // whose AgentSpawned/AgentList delivery was dropped under channel
215 // pressure so the cache has never seen it — must survive until the cache
216 // supersedes it, or spawned agents flicker in and out of the sidebar.
217 let cached_ids: std::collections::HashSet<String> = cached_agents
218 .iter()
219 .map(|agent| agent.agent_id.clone())
220 .collect();
221 app.agent_progress
222 .retain(|id, _| running_ids.contains(id) || !cached_ids.contains(id));
223 let progress_ids: std::collections::HashSet<String> =
224 app.agent_progress.keys().cloned().collect();
225 app.agent_progress_meta
226 .retain(|id, _| cached_ids.contains(id) || progress_ids.contains(id));
227
228 for (id, objective) in &running_agents {
229 app.agent_progress
230 .entry(id.clone())
231 .or_insert_with(|| objective.clone());
232 }
233
234 let recovery_detail = parked_recovery_detail(app);
235 for agent in &cached_agents {
236 let meta = app
237 .agent_progress_meta
238 .entry(agent.agent_id.clone())
239 .or_insert_with(|| AgentProgressMeta {
240 parent_run_id: agent.parent_run_id.clone(),
241 spawn_depth: agent.spawn_depth,
242 ..AgentProgressMeta::default()
243 });
244 meta.parent_run_id = agent.parent_run_id.clone();
245 meta.spawn_depth = agent.spawn_depth;
246
247 let existing = meta.current_activity.clone();
248 let parked = subagent_is_parked(agent);
249 // Parked outranks `needs_input`: the park note *is* phrased as a
250 // question, and reading it as one is exactly the bug (#5906).
251 let mut structured_status = if parked {
252 AgentCurrentActivityStatus::Parked
253 } else if agent.needs_input.is_some() {
254 AgentCurrentActivityStatus::Waiting
255 } else if let Some(worker_status) = agent.worker_status {
256 worker_status.into()
257 } else if matches!(agent.status, SubAgentStatus::Running) {
258 existing
259 .as_ref()
260 .map(|activity| activity.status)
261 .unwrap_or(AgentCurrentActivityStatus::Running)
262 } else {
263 worker_status_for_terminal_projection(&agent.status).into()
264 };
265 if structured_status == AgentCurrentActivityStatus::Interrupted
266 && existing
267 .as_ref()
268 .is_some_and(|activity| activity.status == AgentCurrentActivityStatus::Waiting)
269 {
270 structured_status = AgentCurrentActivityStatus::Waiting;
271 }
272
273 let detail = parked
274 .then(|| recovery_detail.clone())
275 .or_else(|| {
276 agent
277 .needs_input
278 .as_ref()
279 .map(|needs_input| needs_input.question.clone())
280 })
281 .or_else(|| {
282 existing
283 .as_ref()
284 .filter(|activity| activity.status == structured_status)
285 .and_then(|activity| activity.detail.clone())
286 })
287 .or_else(|| agent.result.clone());
288 let current_tool = existing
289 .as_ref()
290 .filter(|_| structured_status == AgentCurrentActivityStatus::RunningTool)
291 .and_then(|activity| activity.current_tool.clone());
292 let step = (agent.steps_taken > 0)
293 .then_some(agent.steps_taken)
294 .or_else(|| existing.as_ref().and_then(|activity| activity.step));
295 meta.current_activity = Some(AgentCurrentActivity::bounded(
296 structured_status,
297 detail,
298 current_tool.clone(),
299 step,
300 ));
301 meta.current_tool = current_tool;
302 }
303
304 if running_ids.is_empty() {
305 app.agent_activity_started_at = None;
306 } else if app.agent_activity_started_at.is_none() {
307 app.agent_activity_started_at = Some(Instant::now());
308 }
309
310 reconcile_cards_with_snapshots(app);
311 }
312
313 fn reconcile_terminal_subagent_card_retention(app: &mut App, now: Instant) {
314 let current_ids: std::collections::HashSet<String> = app
315 .subagent_cache
316 .iter()
317 .map(|agent| agent.agent_id.clone())
318 .collect();
319 app.subagent_terminal_seen_at
320 .retain(|id, _| current_ids.contains(id));
321
322 for agent in &app.subagent_cache {
323 if matches!(agent.status, SubAgentStatus::Running) {
324 app.subagent_terminal_seen_at.remove(&agent.agent_id);
325 } else {
326 app.subagent_terminal_seen_at
327 .entry(agent.agent_id.clone())
328 .or_insert(now);
329 }
330 }
331
332 app.subagent_cache.retain(|agent| {
333 if matches!(agent.status, SubAgentStatus::Running) {
334 return true;
335 }
336 app.subagent_terminal_seen_at
337 .get(&agent.agent_id)
338 .and_then(|seen_at| now.checked_duration_since(*seen_at))
339 .is_none_or(|age| age <= SUBAGENT_TERMINAL_CARD_TTL)
340 });
341
342 let mut terminal_seen: Vec<(String, Instant)> = app
343 .subagent_cache
344 .iter()
345 .filter(|agent| !matches!(agent.status, SubAgentStatus::Running))
346 .filter_map(|agent| {
347 app.subagent_terminal_seen_at
348 .get(&agent.agent_id)
349 .map(|seen_at| (agent.agent_id.clone(), *seen_at))
350 })
351 .collect();
352 terminal_seen.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
353 let keep_terminal_ids: std::collections::HashSet<String> = terminal_seen
354 .into_iter()
355 .take(SUBAGENT_TERMINAL_CARD_MAX_RETAINED)
356 .map(|(id, _)| id)
357 .collect();
358 app.subagent_cache.retain(|agent| {
359 matches!(agent.status, SubAgentStatus::Running)
360 || keep_terminal_ids.contains(agent.agent_id.as_str())
361 });
362
363 let kept_ids: std::collections::HashSet<String> = app
364 .subagent_cache
365 .iter()
366 .map(|agent| agent.agent_id.clone())
367 .collect();
368 app.subagent_terminal_seen_at
369 .retain(|id, _| kept_ids.contains(id));
370 }
371
372 /// Sync in-transcript card slots that still render as running against the
373 /// canonical manager snapshot statuses. A card can miss its terminal mailbox
374 /// envelope (e.g. API-timeout interruption observed only via `AgentList`),
375 /// which would otherwise leave the fanout/delegate UI counting the agent as
376 /// running indefinitely.
377 fn reconcile_cards_with_snapshots(app: &mut App) {
378 let non_running: Vec<(String, AgentLifecycle)> = app
379 .subagent_cache
380 .iter()
381 .filter_map(|agent| {
382 let lifecycle = match &agent.status {
383 SubAgentStatus::Running => return None,
384 SubAgentStatus::Interrupted(_) => AgentLifecycle::Interrupted,
385 SubAgentStatus::Completed => AgentLifecycle::Completed,
386 SubAgentStatus::Failed(_) => AgentLifecycle::Failed,
387 SubAgentStatus::Cancelled => AgentLifecycle::Cancelled,
388 SubAgentStatus::BudgetExhausted => AgentLifecycle::Failed,
389 };
390 Some((agent.agent_id.clone(), lifecycle))
391 })
392 .collect();
393 for (agent_id, lifecycle) in non_running {
394 let Some(&idx) = app.subagent_card_index.get(&agent_id) else {
395 continue;
396 };
397 let updated = match app.history.get_mut(idx) {
398 Some(HistoryCell::SubAgent(SubAgentCell::Delegate(card)))
399 if card.agent_id == agent_id
400 && matches!(
401 card.status,
402 AgentLifecycle::Pending | AgentLifecycle::Running
403 ) =>
404 {
405 card.status = lifecycle;
406 true
407 }
408 Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) => {
409 match card.workers.iter_mut().find(|slot| {
410 slot.agent_id == agent_id
411 && matches!(
412 slot.status,
413 AgentLifecycle::Pending | AgentLifecycle::Running
414 )
415 }) {
416 Some(slot) => {
417 slot.status = lifecycle;
418 true
419 }
420 None => false,
421 }
422 }
423 _ => false,
424 };
425 if updated {
426 app.bump_history_cell(idx);
427 }
428 }
429 }
430
431 fn subagent_status_rank(status: &SubAgentStatus) -> u8 {
432 match status {
433 SubAgentStatus::Running => 0,
434 SubAgentStatus::Interrupted(_) => 1,
435 SubAgentStatus::Failed(_) => 2,
436 SubAgentStatus::Completed => 3,
437 SubAgentStatus::Cancelled => 4,
438 SubAgentStatus::BudgetExhausted => 2,
439 }
440 }
441
442 pub(super) fn sort_subagents_in_place(agents: &mut [SubAgentResult]) {
443 agents.sort_by(|a, b| {
444 subagent_status_rank(&a.status)
445 .cmp(&subagent_status_rank(&b.status))
446 .then_with(|| a.agent_type.as_str().cmp(b.agent_type.as_str()))
447 .then_with(|| a.agent_id.cmp(&b.agent_id))
448 });
449 }
450
451 pub(super) fn subagent_message_refreshes_workspace_context(message: &MailboxMessage) -> bool {
452 matches!(
453 message,
454 MailboxMessage::ToolCallCompleted { tool_name, .. }
455 if refreshes_workspace_context_on_completion(tool_name)
456 )
457 }
458
459 /// Route a `MailboxMessage` envelope to the matching in-transcript card,
460 /// allocating a `DelegateCard` or `FanoutCard` on first sight (issue #128).
461 pub(super) fn handle_subagent_mailbox_for_turn(
462 app: &mut App,
463 turn_id: &str,
464 seq: u64,
465 message: &MailboxMessage,
466 ) -> bool {
467 // Accumulate sub-agent token costs for the real-time footer counter (#166).
468 if let MailboxMessage::TokenUsage {
469 agent_id: _,
470 source_id,
471 route,
472 usage,
473 } = message
474 {
475 // The child's own route truth always wins and is never guessed from
476 // provider identity: `route` is the immutable envelope its client was
477 // frozen with at construction, so its billing mode, billing surface
478 // and endpoint fingerprint are the child's dispatch receipt. A child
479 // whose route froze as Unknown stays Unknown.
480 //
481 // Sub-agent spend joins the parent total, so it also joins the
482 // completeness counters `/cost` reports against that total.
483 let stable_source = if source_id.trim().is_empty() {
484 format!("mailbox:{turn_id}:{seq}")
485 } else {
486 source_id.clone()
487 };
488 let source_fingerprint = crate::cost_status::usage_source_fingerprint(&stable_source);
489 if crate::cost_status::usage_source_seen(&stable_source) {
490 // The runtime sink runs synchronously before mailbox publication.
491 // Fold its identity and money into App together, before a following
492 // TurnComplete can snapshot either half. A post-seal response has
493 // no mailbox event and is swept by the event loop instead.
494 let first_live_delivery = !app
495 .session
496 .subagent_usage_sources
497 .contains(&source_fingerprint);
498 let pending_runtime_cost = crate::cost_status::drain();
499 if !pending_runtime_cost.is_empty() {
500 app.absorb_pending_background_cost(&pending_runtime_cost);
501 }
502 if first_live_delivery
503 && app
504 .session
505 .subagent_usage_sources
506 .contains(&source_fingerprint)
507 {
508 record_agent_current_activity(app, message);
509 }
510 return false;
511 }
512 if app
513 .session
514 .subagent_usage_sources
515 .insert(source_fingerprint)
516 {
517 // Update the Work-row receipt under the same replay guard as
518 // cost. A mailbox replay must not make either total grow twice.
519 // Usage confirms or replaces the child's frozen spawn model; the
520 // parent's configured/default route is never child evidence.
521 record_agent_current_activity(app, message);
522 let audit = route.audit(usage);
523 app.record_turn_cost_audit(&audit);
524 app.record_turn_cost_route_receipt(route.receipt(&audit));
525 if let Some(cost) = audit.estimate {
526 app.accrue_subagent_cost_estimate(cost);
527 }
528 }
529 // No transcript card changed. The event-loop redraw still repaints
530 // the Work row and footer from the updated receipt state.
531 return false;
532 }
533
534 // Resolve (or allocate) the target cell for this envelope. ChildSpawned
535 // is special — it always belongs to the active fanout card if one
536 // exists; otherwise it seeds a new one.
537 let display_message = bounded_mailbox_message(message);
538 let agent_id = display_message.agent_id().to_string();
539 record_agent_current_activity(app, message);
540 if subagent_message_refreshes_workspace_context(message) {
541 workspace_context::refresh_now(app, Instant::now());
542 }
543
544 if matches!(message, MailboxMessage::ChildSpawned { .. })
545 && let Some(idx) = app.last_fanout_card_index
546 && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx)
547 {
548 let updated = apply_to_fanout(card, &display_message);
549 app.subagent_card_index.insert(agent_id, idx);
550 if updated {
551 app.bump_history_cell(idx);
552 }
553 return updated;
554 }
555
556 // Existing card for this agent_id? Mutate in place.
557 if let Some(&idx) = app.subagent_card_index.get(&agent_id) {
558 let updated = match app.history.get_mut(idx) {
559 Some(HistoryCell::SubAgent(SubAgentCell::Delegate(card))) => {
560 apply_to_delegate(card, &display_message)
561 }
562 Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) => {
563 apply_to_fanout(card, &display_message)
564 }
565 _ => false,
566 };
567 if updated {
568 // idx is already in scope from the outer
569 // `if let Some(&idx) = app.subagent_card_index.get(&agent_id)`.
570 app.bump_history_cell(idx);
571 }
572 return updated;
573 }
574
575 // No existing card — only `Started` reasonably opens one. Anything else
576 // for an unknown agent_id is dropped (likely arrived after the cell was
577 // cleared, e.g. session-resume edge cases).
578 let agent_type = match &display_message {
579 MailboxMessage::Started { agent_type, .. } => agent_type.clone(),
580 MailboxMessage::Completed { .. }
581 | MailboxMessage::Failed { .. }
582 | MailboxMessage::Interrupted { .. }
583 | MailboxMessage::Cancelled { .. } => "unknown".to_string(),
584 _ => return false,
585 };
586
587 let dispatch_kind = app.pending_subagent_dispatch.as_deref();
588 let is_fanout = matches!(dispatch_kind, Some("rlm_open" | "rlm_eval" | "rlm"));
589
590 if is_fanout {
591 // Reuse the active fanout card for sibling spawns; otherwise create
592 // one anchored at this position so subsequent siblings join it.
593 if let Some(idx) = app.last_fanout_card_index
594 && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) =
595 app.history.get_mut(idx)
596 {
597 let updated = card.claim_pending_worker(&agent_id, AgentLifecycle::Running);
598 app.subagent_card_index.insert(agent_id, idx);
599 if updated {
600 app.bump_history_cell(idx);
601 }
602 updated
603 } else {
604 let mut card = FanoutCard::new(dispatch_kind.unwrap_or("rlm_eval").to_string());
605 card.upsert_worker(&agent_id, AgentLifecycle::Running);
606 app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card)));
607 let idx = app.history.len().saturating_sub(1);
608 app.last_fanout_card_index = Some(idx);
609 app.subagent_card_index.insert(agent_id, idx);
610 app.bump_history_cell(idx);
611 true
612 }
613 } else {
614 let mut card = DelegateCard::new(agent_id.clone(), agent_type.clone());
615 apply_to_delegate(&mut card, &display_message);
616 app.add_message(HistoryCell::SubAgent(SubAgentCell::Delegate(card)));
617 let idx = app.history.len().saturating_sub(1);
618 app.subagent_card_index.insert(agent_id.clone(), idx);
619 // Single delegate consumes the pending dispatch label so a follow-on
620 // tool call doesn't accidentally inherit it.
621 app.pending_subagent_dispatch = None;
622 // idx was just inserted on the line above — no need to re-query.
623 app.bump_history_cell(idx);
624 true
625 }
626 }
627
628 #[cfg(test)]
629 pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &MailboxMessage) -> bool {
630 handle_subagent_mailbox_for_turn(app, "test-turn", seq, message)
631 }
632
633 fn bounded_mailbox_message(message: &MailboxMessage) -> MailboxMessage {
634 match message {
635 MailboxMessage::Progress { agent_id, status } => MailboxMessage::Progress {
636 agent_id: agent_id.clone(),
637 status: bound_agent_activity_text(status),
638 },
639 MailboxMessage::ToolCallStarted {
640 agent_id,
641 tool_name,
642 step,
643 } => MailboxMessage::ToolCallStarted {
644 agent_id: agent_id.clone(),
645 tool_name: bound_agent_activity_text(subagent_progress_tool_display_name(tool_name)),
646 step: *step,
647 },
648 MailboxMessage::ToolCallCompleted {
649 agent_id,
650 tool_name,
651 step,
652 ok,
653 } => MailboxMessage::ToolCallCompleted {
654 agent_id: agent_id.clone(),
655 tool_name: bound_agent_activity_text(subagent_progress_tool_display_name(tool_name)),
656 step: *step,
657 ok: *ok,
658 },
659 MailboxMessage::Completed { agent_id, summary } => MailboxMessage::Completed {
660 agent_id: agent_id.clone(),
661 summary: bound_agent_activity_text(summary),
662 },
663 MailboxMessage::Failed { agent_id, error } => MailboxMessage::Failed {
664 agent_id: agent_id.clone(),
665 error: bound_agent_activity_text(error),
666 },
667 MailboxMessage::Interrupted { agent_id, reason } => MailboxMessage::Interrupted {
668 agent_id: agent_id.clone(),
669 reason: bound_agent_activity_text(reason),
670 },
671 // Item text is model-authored and reaches the transcript, so it gets
672 // the same redaction/bounding every other displayed child string gets.
673 // Ids, statuses, and counts are preserved exactly — bounding must not
674 // change what the ledger says.
675 MailboxMessage::WorkState { agent_id, todo } => MailboxMessage::WorkState {
676 agent_id: agent_id.clone(),
677 todo: crate::tools::todo::TodoListSnapshot {
678 items: todo
679 .items
680 .iter()
681 .map(|item| crate::tools::todo::TodoItem {
682 content: bound_agent_activity_text(&item.content),
683 ..item.clone()
684 })
685 .collect(),
686 ..todo.clone()
687 },
688 },
689 _ => message.clone(),
690 }
691 }
692
693 fn record_agent_current_activity(app: &mut App, message: &MailboxMessage) {
694 let agent_id = message.agent_id().to_string();
695 let meta = app.agent_progress_meta.entry(agent_id).or_default();
696 if let MailboxMessage::TokenUsage { route, usage, .. } = message {
697 // The child's own used-token tally (input + output), matching the
698 // worker budget's `usage_total_tokens`. Counting only completions made
699 // the strip look "stuck" on tiny numbers while the child was burning
700 // context. Absent until a real envelope lands, so an agent with no
701 // reported usage shows no number instead of a zero.
702 let turn_total =
703 u64::from(usage.input_tokens).saturating_add(u64::from(usage.output_tokens));
704 meta.received_tokens = Some(meta.received_tokens.unwrap_or(0).saturating_add(turn_total));
705 meta.resolved_provider = Some(route.provider.as_str().to_string());
706 meta.resolved_model = Some(bound_agent_activity_text(
707 &crate::cost_status::sanitize_persisted_route_label(&route.model),
708 ))
709 .filter(|model| !model.trim().is_empty());
710 return;
711 }
712 if let MailboxMessage::WorkState { todo, .. } = message {
713 // Work state is a separate fact from what the agent is doing right
714 // now — publishing a ledger update must not invent an activity
715 // transition. It does update the remaining-to-do chip for the strip.
716 if todo.is_empty() {
717 meta.todos_remaining = None;
718 } else {
719 let remaining = todo
720 .items
721 .iter()
722 .filter(|item| !item.status.is_settled())
723 .count();
724 meta.todos_remaining = Some(u32::try_from(remaining).unwrap_or(u32::MAX));
725 }
726 return;
727 }
728 if let MailboxMessage::ToolCallCompleted {
729 tool_name,
730 step,
731 ok,
732 ..
733 } = message
734 {
735 if meta.recent_actions.len() == MAX_AGENT_RECENT_ACTIONS {
736 meta.recent_actions.pop_front();
737 }
738 meta.recent_actions.push_back(AgentRecentAction::bounded(
739 subagent_progress_tool_display_name(tool_name),
740 *step,
741 *ok,
742 ));
743 }
744 let previous = meta.current_activity.clone();
745
746 let (status, detail, current_tool, step) = match message {
747 MailboxMessage::Started { agent_type, .. } => (
748 AgentCurrentActivityStatus::Running,
749 Some(format!("started {agent_type}")),
750 None,
751 None,
752 ),
753 MailboxMessage::Progress { status, .. } => (
754 previous
755 .as_ref()
756 .map(|activity| activity.status)
757 .unwrap_or(AgentCurrentActivityStatus::Running),
758 Some(status.clone()),
759 previous
760 .as_ref()
761 .and_then(|activity| activity.current_tool.clone()),
762 previous.as_ref().and_then(|activity| activity.step),
763 ),
764 MailboxMessage::ToolCallStarted {
765 tool_name, step, ..
766 } => (
767 AgentCurrentActivityStatus::RunningTool,
768 None,
769 Some(subagent_progress_tool_display_name(tool_name).to_string()),
770 Some(*step),
771 ),
772 MailboxMessage::ToolCallCompleted {
773 tool_name,
774 step,
775 ok,
776 ..
777 } => (
778 AgentCurrentActivityStatus::Running,
779 Some(format!(
780 "{} {}",
781 subagent_progress_tool_display_name(tool_name),
782 if *ok { "completed" } else { "failed" }
783 )),
784 None,
785 Some(*step),
786 ),
787 MailboxMessage::ChildSpawned { parent_id, .. } => (
788 AgentCurrentActivityStatus::Starting,
789 Some(format!("spawned by {parent_id}")),
790 None,
791 None,
792 ),
793 MailboxMessage::Completed { summary, .. } => (
794 AgentCurrentActivityStatus::Done,
795 Some(summary.clone()),
796 None,
797 previous.as_ref().and_then(|activity| activity.step),
798 ),
799 MailboxMessage::Failed { error, .. } => (
800 AgentCurrentActivityStatus::Failed,
801 Some(error.clone()),
802 None,
803 previous.as_ref().and_then(|activity| activity.step),
804 ),
805 MailboxMessage::Interrupted { reason, .. } => (
806 AgentCurrentActivityStatus::Waiting,
807 Some(reason.clone()),
808 None,
809 previous.as_ref().and_then(|activity| activity.step),
810 ),
811 MailboxMessage::Cancelled { .. } => (
812 AgentCurrentActivityStatus::Canceled,
813 None,
814 None,
815 previous.as_ref().and_then(|activity| activity.step),
816 ),
817 MailboxMessage::TokenUsage { .. } => unreachable!("token usage handled above"),
818 MailboxMessage::WorkState { .. } => unreachable!("work state handled above"),
819 };
820
821 meta.current_activity = Some(AgentCurrentActivity::bounded(
822 status,
823 detail,
824 current_tool.clone(),
825 step,
826 ));
827 meta.current_tool = current_tool;
828 if let MailboxMessage::ToolCallCompleted {
829 tool_name,
830 ok: true,
831 ..
832 } = message
833 && is_file_mutation_tool(tool_name)
834 {
835 meta.files_touched = meta.files_touched.saturating_add(1);
836 }
837 }
838
839 fn is_file_mutation_tool(name: &str) -> bool {
840 matches!(
841 name,
842 "write_file" | "edit_file" | "apply_patch" | "fim_edit" | "Write" | "Edit"
843 )
844 }
845
846 pub(super) fn task_mode_label(mode: AppMode) -> &'static str {
847 mode.as_setting()
848 }
849
850 pub(super) fn task_summary_to_panel_entry(summary: TaskSummary) -> TaskPanelEntry {
851 TaskPanelEntry {
852 id: summary.id,
853 status: task_status_label(summary.status).to_string(),
854 prompt_summary: summary.prompt_summary,
855 duration_ms: summary.duration_ms,
856 kind: TaskPanelEntryKind::Background,
857 stale: false,
858 elapsed_since_output_ms: None,
859 owner_agent_id: None,
860 owner_agent_name: None,
861 current_tool: None,
862 role: None,
863 files_touched: 0,
864 }
865 }
866
867 fn task_status_label(status: TaskStatus) -> &'static str {
868 match status {
869 TaskStatus::Queued => "queued",
870 TaskStatus::Running => "running",
871 TaskStatus::Completed => "completed",
872 TaskStatus::Failed => "failed",
873 TaskStatus::Canceled => "canceled",
874 }
875 }
876
877 pub(super) fn format_task_list(tasks: &[TaskSummary]) -> String {
878 if tasks.is_empty() {
879 return "No tasks found.".to_string();
880 }
881
882 let show_session = tasks.iter().any(|task| task.owner_session_id.is_some());
883 let mut lines = vec![format!("Tasks ({})", tasks.len())];
884 // Build headers with the same format strings as the rows so the ID
885 // column (21-char `task_` ids) can never drift out of alignment again.
886 if show_session {
887 lines.push(format!(
888 "{:<21} {:<9} {:<12} {:>8} {}",
889 "ID", "Status", "Session", "Time", "Title"
890 ));
891 } else {
892 lines.push(format!(
893 "{:<21} {:<9} {:>8} {}",
894 "ID", "Status", "Time", "Title"
895 ));
896 }
897 lines.push("------------------------------------------------------------".to_string());
898 for task in tasks {
899 let duration = task
900 .duration_ms
901 .map(crate::elapsed::format_elapsed_ms)
902 .unwrap_or_else(|| "-".to_string());
903 let owner_session = task.owner_session_id.as_deref().unwrap_or("-");
904 let owner_session = if owner_session.chars().count() > 12 {
905 format!("{}…", owner_session.chars().take(11).collect::<String>())
906 } else {
907 owner_session.to_string()
908 };
909 if show_session {
910 lines.push(format!(
911 "{:<21} {:<9} {:<12} {:>8} {}",
912 task.id,
913 task_status_label(task.status),
914 owner_session,
915 duration,
916 task.prompt_summary
917 ));
918 } else {
919 lines.push(format!(
920 "{:<21} {:<9} {:>8} {}",
921 task.id,
922 task_status_label(task.status),
923 duration,
924 task.prompt_summary
925 ));
926 }
927 }
928 lines.push("Use /task show <id> for timeline details.".to_string());
929 lines.join("\n")
930 }
931
932 pub(super) fn open_task_pager(app: &mut App, task: &TaskRecord) {
933 let width = app
934 .viewport
935 .last_transcript_area
936 .map(|area| area.width)
937 .unwrap_or(100)
938 .saturating_sub(4);
939 app.view_stack.push(PagerView::from_text(
940 format!("Task {}", task.id),
941 &format_task_detail(task),
942 width.max(60),
943 ));
944 }
945
946 fn format_task_detail(task: &TaskRecord) -> String {
947 let mut lines = Vec::new();
948 lines.push(format!("Task: {}", task.id));
949 lines.push(format!("Status: {}", task_status_label(task.status)));
950 lines.push(format!("Mode: {}", task.mode));
951 lines.push(format!("Model: {}", task.model));
952 lines.push(format!(
953 "Workspace: {}",
954 crate::utils::display_path(&task.workspace)
955 ));
956 if let Some(owner_session_id) = task.owner_session_id.as_deref() {
957 lines.push(format!("Owning Session: {owner_session_id}"));
958 }
959 if let Some(thread_id) = task.thread_id.as_ref() {
960 lines.push(format!("Runtime Thread: {thread_id}"));
961 }
962 if let Some(turn_id) = task.turn_id.as_ref() {
963 lines.push(format!("Runtime Turn: {turn_id}"));
964 }
965 if task.runtime_event_count > 0 {
966 lines.push(format!("Runtime Events: {}", task.runtime_event_count));
967 }
968 lines.push(format!("Created: {}", task.created_at));
969 if let Some(started_at) = task.started_at {
970 lines.push(format!("Started: {started_at}"));
971 }
972 if let Some(ended_at) = task.ended_at {
973 lines.push(format!("Ended: {ended_at}"));
974 }
975 if let Some(duration) = task.duration_ms {
976 lines.push(format!(
977 "Duration: {}",
978 crate::elapsed::format_elapsed_ms(duration)
979 ));
980 }
981 lines.push(String::new());
982 lines.push("Prompt:".to_string());
983 lines.push(task.prompt.clone());
984
985 if let Some(summary) = task.result_summary.as_ref() {
986 lines.push(String::new());
987 lines.push("Result Summary:".to_string());
988 lines.push(summary.clone());
989 }
990 if let Some(path) = task.result_detail_path.as_ref() {
991 lines.push(format!("Result Artifact: {}", path.display()));
992 }
993 if let Some(error) = task.error.as_ref() {
994 lines.push(String::new());
995 lines.push(format!("Error: {error}"));
996 }
997
998 lines.push(String::new());
999 lines.push("Tool Calls:".to_string());
1000 if task.tool_calls.is_empty() {
1001 lines.push("- (none)".to_string());
1002 } else {
1003 for tool in &task.tool_calls {
1004 let status = match tool.status {
1005 crate::task_manager::TaskToolStatus::Running => "running",
1006 crate::task_manager::TaskToolStatus::Success => "success",
1007 crate::task_manager::TaskToolStatus::Failed => "failed",
1008 crate::task_manager::TaskToolStatus::Canceled => "canceled",
1009 };
1010 let mut line = format!(
1011 "- {} [{}] {}",
1012 tool.name,
1013 status,
1014 tool.output_summary.as_deref().unwrap_or("(no summary)")
1015 );
1016 if let Some(duration) = tool.duration_ms {
1017 line.push_str(&format!(" ({:.2}s)", duration as f64 / 1000.0));
1018 }
1019 lines.push(line);
1020 if let Some(path) = tool.detail_path.as_ref() {
1021 lines.push(format!(" detail: {}", path.display()));
1022 }
1023 if let Some(path) = tool.patch_ref.as_ref() {
1024 lines.push(format!(" patch: {}", path.display()));
1025 }
1026 }
1027 }
1028
1029 lines.push(String::new());
1030 lines.push("Timeline:".to_string());
1031 if task.timeline.is_empty() {
1032 lines.push("- (none)".to_string());
1033 } else {
1034 for entry in &task.timeline {
1035 lines.push(format!(
1036 "- [{}] {}: {}",
1037 entry.timestamp, entry.kind, entry.summary
1038 ));
1039 if let Some(path) = entry.detail_path.as_ref() {
1040 lines.push(format!(" detail: {}", path.display()));
1041 }
1042 }
1043 }
1044
1045 lines.join("\n")
1046 }
1047
1048 #[cfg(test)]
1049 mod tests {
1050 use super::*;
1051 use crate::config::Config;
1052 use crate::task_manager::{TaskStatus, TaskSummary};
1053 use crate::tools::subagent::{FleetRole, SubAgentAssignment};
1054 use crate::tui::app::{InitialInput, TuiOptions};
1055 use crate::tui::widgets::agent_card::AgentLifecycle;
1056 use chrono::Utc;
1057 use std::path::PathBuf;
1058
1059 fn test_options() -> TuiOptions {
1060 TuiOptions {
1061 model: "test-model".to_string(),
1062 allow_shell: true,
1063 max_subagents: 4,
1064 start_in_agent_mode: true,
1065 initial_input: None::<InitialInput>,
1066 startup_notice: None,
1067 ..crate::test_support::test_tui_options(PathBuf::from("."))
1068 }
1069 }
1070
1071 fn test_route(
1072 provider: crate::config::ApiProvider,
1073 model: &str,
1074 ) -> crate::cost_status::EffectiveRouteEnvelope {
1075 crate::cost_status::EffectiveRouteEnvelope::capture(
1076 None,
1077 provider,
1078 provider.as_str(),
1079 model,
1080 Some(provider.default_base_url()),
1081 Utc::now(),
1082 )
1083 }
1084
1085 fn task_summary(id: &str, status: TaskStatus, duration_ms: Option<u64>) -> TaskSummary {
1086 TaskSummary {
1087 execution_binding_known: true,
1088 id: id.to_string(),
1089 status,
1090 prompt_summary: "Fix task list output".to_string(),
1091 name: None,
1092 model: "deepseek-v4-pro".to_string(),
1093 model_provider: None,
1094 model_provider_id: None,
1095 mode: "agent".to_string(),
1096 workspace: PathBuf::from("/tmp"),
1097 created_at: Utc::now(),
1098 started_at: None,
1099 ended_at: None,
1100 duration_ms,
1101 lifecycle_seq: 1,
1102 error: None,
1103 terminal_reason: None,
1104 thread_id: None,
1105 turn_id: None,
1106 owner_session_id: None,
1107 }
1108 }
1109
1110 fn subagent_result(id: &str, status: SubAgentStatus) -> SubAgentResult {
1111 SubAgentResult {
1112 usage: None,
1113 name: id.to_string(),
1114 agent_id: id.to_string(),
1115 context_mode: "fresh".to_string(),
1116 fork_context: false,
1117 workspace: None,
1118 git_branch: None,
1119 agent_type: FleetRole::Worker,
1120 assignment: SubAgentAssignment {
1121 objective: format!("objective-{id}"),
1122 role: Some("worker".to_string()),
1123 },
1124 model: "deepseek-v4-flash".to_string(),
1125 nickname: None,
1126 status,
1127 worker_status: None,
1128 runtime_permissions: None,
1129 parent_run_id: None,
1130 spawn_depth: 0,
1131 child_route: None,
1132 result: None,
1133 steps_taken: 0,
1134 checkpoint: None,
1135 needs_input: None,
1136 duration_ms: 0,
1137 started_at: None,
1138 from_prior_session: false,
1139 }
1140 }
1141
1142 #[test]
1143 fn task_list_includes_title_header_and_time_column() {
1144 let output = format_task_list(&[
1145 task_summary("task_12345678", TaskStatus::Running, None),
1146 task_summary("task_abcdef12", TaskStatus::Completed, Some(1234)),
1147 ]);
1148
1149 assert!(output.contains(&format!(
1150 "{:<21} {:<9} {:>8} {}",
1151 "ID", "Status", "Time", "Title"
1152 )));
1153 assert!(output.contains(&format!(
1154 "{:<21} {:<9} {:>8} {}",
1155 "task_12345678", "running", "-", "Fix task list output"
1156 )));
1157 assert!(output.contains(&format!(
1158 "{:<21} {:<9} {:>8} {}",
1159 "task_abcdef12", "completed", "1s", "Fix task list output"
1160 )));
1161 }
1162
1163 #[test]
1164 fn task_list_shows_owner_session_when_present() {
1165 let mut task = task_summary("task_owned", TaskStatus::Running, None);
1166 task.owner_session_id = Some("session-123456".to_string());
1167
1168 let output = format_task_list(&[task]);
1169
1170 assert!(output.contains(&format!(
1171 "{:<21} {:<9} {:<12} {:>8} {}",
1172 "ID", "Status", "Session", "Time", "Title"
1173 )));
1174 // Owner ids are truncated to 11 chars + '…' so the rendered value
1175 // stays inside the 12-wide Session column and cannot drift the
1176 // remaining columns out of alignment.
1177 assert!(output.contains("session-123…"), "{output}");
1178 }
1179
1180 #[test]
1181 fn mailbox_progress_reports_transcript_change_only_for_visible_card_updates() {
1182 let mut app = App::new(test_options(), &Config::default());
1183 let started = MailboxMessage::started("agent_live", FleetRole::Worker);
1184 assert!(
1185 handle_subagent_mailbox(&mut app, 1, &started),
1186 "first started envelope creates a visible card"
1187 );
1188
1189 let progress =
1190 MailboxMessage::progress("agent_live", "step 1/100: requesting model response");
1191 assert!(
1192 !handle_subagent_mailbox(&mut app, 2, &progress),
1193 "low-signal progress for an already-running card is a no-op"
1194 );
1195
1196 let tool = MailboxMessage::ToolCallStarted {
1197 agent_id: "agent_live".to_string(),
1198 tool_name: "read_file".to_string(),
1199 step: 1,
1200 };
1201 assert!(
1202 handle_subagent_mailbox(&mut app, 3, &tool),
1203 "tool progress still updates the visible transcript card"
1204 );
1205 assert_eq!(
1206 app.agent_progress_meta["agent_live"]
1207 .current_tool
1208 .as_deref(),
1209 Some("read_file")
1210 );
1211
1212 let completed = MailboxMessage::ToolCallCompleted {
1213 agent_id: "agent_live".to_string(),
1214 tool_name: "read_file".to_string(),
1215 step: 1,
1216 ok: true,
1217 };
1218 assert!(handle_subagent_mailbox(&mut app, 4, &completed));
1219 assert_eq!(app.agent_progress_meta["agent_live"].current_tool, None);
1220
1221 let wrote = MailboxMessage::ToolCallCompleted {
1222 agent_id: "agent_live".to_string(),
1223 tool_name: "apply_patch".to_string(),
1224 step: 2,
1225 ok: true,
1226 };
1227 assert!(handle_subagent_mailbox(&mut app, 5, &wrote));
1228 assert_eq!(app.agent_progress_meta["agent_live"].files_touched, 1);
1229 }
1230
1231 #[test]
1232 fn canonical_child_file_activity_counts_only_successful_mutations() {
1233 let mut app = App::new(test_options(), &Config::default());
1234
1235 for (step, tool_name) in ["read_file", "list_dir", "file_search", "grep_files"]
1236 .into_iter()
1237 .enumerate()
1238 {
1239 record_agent_current_activity(
1240 &mut app,
1241 &MailboxMessage::ToolCallCompleted {
1242 agent_id: "agent_files".to_string(),
1243 tool_name: tool_name.to_string(),
1244 step: step as u32,
1245 ok: true,
1246 },
1247 );
1248 }
1249 assert_eq!(app.agent_progress_meta["agent_files"].files_touched, 0);
1250
1251 for (step, tool_name) in ["write_file", "edit_file", "apply_patch"]
1252 .into_iter()
1253 .enumerate()
1254 {
1255 record_agent_current_activity(
1256 &mut app,
1257 &MailboxMessage::ToolCallCompleted {
1258 agent_id: "agent_files".to_string(),
1259 tool_name: tool_name.to_string(),
1260 step: (step + 10) as u32,
1261 ok: true,
1262 },
1263 );
1264 }
1265 assert_eq!(app.agent_progress_meta["agent_files"].files_touched, 3);
1266
1267 record_agent_current_activity(
1268 &mut app,
1269 &MailboxMessage::ToolCallCompleted {
1270 agent_id: "agent_files".to_string(),
1271 tool_name: "write_file".to_string(),
1272 step: 20,
1273 ok: false,
1274 },
1275 );
1276 assert_eq!(app.agent_progress_meta["agent_files"].files_touched, 3);
1277 }
1278
1279 #[test]
1280 fn recent_actions_are_three_bounded_structured_tool_outcomes() {
1281 let mut app = App::new(test_options(), &Config::default());
1282 let agent_id = "agent_recent";
1283 for step in 1..=5 {
1284 record_agent_current_activity(
1285 &mut app,
1286 &MailboxMessage::ToolCallCompleted {
1287 agent_id: agent_id.to_string(),
1288 tool_name: format!("\u{1b}[31mtool_{step}\u{1b}[0m"),
1289 step,
1290 ok: step != 4,
1291 },
1292 );
1293 }
1294 record_agent_current_activity(
1295 &mut app,
1296 &MailboxMessage::Progress {
1297 agent_id: agent_id.to_string(),
1298 status: "tool_99 completed".to_string(),
1299 },
1300 );
1301
1302 let actions = &app.agent_progress_meta[agent_id].recent_actions;
1303 assert_eq!(actions.len(), MAX_AGENT_RECENT_ACTIONS);
1304 assert_eq!(
1305 actions.iter().map(|action| action.step).collect::<Vec<_>>(),
1306 vec![3, 4, 5]
1307 );
1308 assert!(!actions.iter().any(|action| action.step == 99));
1309 assert!(actions.iter().all(|action| !action.tool.contains('\u{1b}')));
1310 assert!(!actions[1].ok);
1311 }
1312
1313 #[test]
1314 fn token_usage_records_only_the_effective_child_route_facts() {
1315 let mut app = App::new(test_options(), &Config::default());
1316 let changed = handle_subagent_mailbox(
1317 &mut app,
1318 91,
1319 &MailboxMessage::TokenUsage {
1320 agent_id: "agent_route".to_string(),
1321 source_id: "response-route".to_string(),
1322 route: Box::new(test_route(
1323 crate::config::ApiProvider::Openrouter,
1324 "vendor/model-real",
1325 )),
1326 usage: codewhale_models::Usage::default(),
1327 },
1328 );
1329
1330 assert!(!changed, "route facts do not allocate a transcript card");
1331 let meta = &app.agent_progress_meta["agent_route"];
1332 assert_eq!(meta.resolved_provider.as_deref(), Some("openrouter"));
1333 assert_eq!(meta.resolved_model.as_deref(), Some("vendor/model-real"));
1334 assert!(meta.current_activity.is_none());
1335 }
1336
1337 #[test]
1338 fn token_usage_accumulates_input_plus_output_across_child_turns() {
1339 let mut app = App::new(test_options(), &Config::default());
1340 let route = test_route(crate::config::ApiProvider::Deepseek, "deepseek-v4-flash");
1341 handle_subagent_mailbox(
1342 &mut app,
1343 1,
1344 &MailboxMessage::TokenUsage {
1345 agent_id: "agent_spend".to_string(),
1346 source_id: "response-1".to_string(),
1347 route: Box::new(route.clone()),
1348 usage: codewhale_models::Usage {
1349 input_tokens: 1_000,
1350 output_tokens: 40,
1351 ..Default::default()
1352 },
1353 },
1354 );
1355 handle_subagent_mailbox(
1356 &mut app,
1357 2,
1358 &MailboxMessage::TokenUsage {
1359 agent_id: "agent_spend".to_string(),
1360 source_id: "response-2".to_string(),
1361 route: Box::new(route),
1362 usage: codewhale_models::Usage {
1363 input_tokens: 2_000,
1364 output_tokens: 60,
1365 ..Default::default()
1366 },
1367 },
1368 );
1369
1370 assert_eq!(
1371 app.agent_progress_meta["agent_spend"].received_tokens,
1372 Some(3_100),
1373 "work-bar tally must match worker budget total (input+output)"
1374 );
1375 }
1376
1377 #[test]
1378 fn typed_mailbox_lifecycle_projects_running_waiting_failed_and_done() {
1379 let mut app = App::new(test_options(), &Config::default());
1380
1381 assert!(handle_subagent_mailbox(
1382 &mut app,
1383 1,
1384 &MailboxMessage::started("agent_running", FleetRole::Worker),
1385 ));
1386 assert_eq!(
1387 app.agent_progress_meta["agent_running"]
1388 .current_activity
1389 .as_ref()
1390 .map(|activity| activity.status),
1391 Some(AgentCurrentActivityStatus::Running)
1392 );
1393
1394 assert!(handle_subagent_mailbox(
1395 &mut app,
1396 2,
1397 &MailboxMessage::ToolCallStarted {
1398 agent_id: "agent_running".to_string(),
1399 tool_name: "read_file".to_string(),
1400 step: 3,
1401 },
1402 ));
1403 let running = app.agent_progress_meta["agent_running"]
1404 .current_activity
1405 .as_ref()
1406 .expect("running tool projection");
1407 assert_eq!(running.status, AgentCurrentActivityStatus::RunningTool);
1408 assert_eq!(running.current_tool.as_deref(), Some("read_file"));
1409 assert_eq!(running.step, Some(3));
1410
1411 assert!(handle_subagent_mailbox(
1412 &mut app,
1413 3,
1414 &MailboxMessage::Interrupted {
1415 agent_id: "agent_running".to_string(),
1416 reason: "approval needed".to_string(),
1417 },
1418 ));
1419 let waiting = app.agent_progress_meta["agent_running"]
1420 .current_activity
1421 .as_ref()
1422 .expect("waiting projection");
1423 assert_eq!(waiting.status, AgentCurrentActivityStatus::Waiting);
1424 assert_eq!(waiting.detail.as_deref(), Some("approval needed"));
1425
1426 for (seq, agent_id, terminal, expected) in [
1427 (
1428 4,
1429 "agent_failed",
1430 MailboxMessage::Failed {
1431 agent_id: "agent_failed".to_string(),
1432 error: "verification failed".to_string(),
1433 },
1434 AgentCurrentActivityStatus::Failed,
1435 ),
1436 (
1437 5,
1438 "agent_done",
1439 MailboxMessage::Completed {
1440 agent_id: "agent_done".to_string(),
1441 summary: "verification complete".to_string(),
1442 },
1443 AgentCurrentActivityStatus::Done,
1444 ),
1445 ] {
1446 assert!(handle_subagent_mailbox(&mut app, seq, &terminal));
1447 assert_eq!(
1448 app.agent_progress_meta[agent_id]
1449 .current_activity
1450 .as_ref()
1451 .map(|activity| activity.status),
1452 Some(expected)
1453 );
1454 }
1455 }
1456
1457 #[test]
1458 fn reconcile_projects_typed_status_when_activity_detail_is_missing() {
1459 let mut app = App::new(test_options(), &Config::default());
1460 let mut agent = subagent_result("agent_model_wait", SubAgentStatus::Running);
1461 agent.worker_status = Some(AgentWorkerStatus::ModelWait);
1462 app.subagent_cache.push(agent);
1463
1464 reconcile_subagent_activity_state_at(&mut app, Instant::now());
1465
1466 let activity = app.agent_progress_meta["agent_model_wait"]
1467 .current_activity
1468 .as_ref()
1469 .expect("typed activity fallback");
1470 assert_eq!(activity.status, AgentCurrentActivityStatus::ModelWait);
1471 assert_eq!(activity.detail, None);
1472 }
1473
1474 #[test]
1475 fn mailbox_compact_projection_redacts_secrets_and_control_sequences() {
1476 let mut app = App::new(test_options(), &Config::default());
1477 let agent_id = "agent_safe_projection";
1478 assert!(handle_subagent_mailbox(
1479 &mut app,
1480 1,
1481 &MailboxMessage::started(agent_id, FleetRole::Worker),
1482 ));
1483 let secret = "sk-mailbox-secret-1234567890";
1484 let raw = format!(
1485 "\u{1b}[31mrunning\u{1b}[0m\napi_key={secret}\n\u{1b}]8;;https://example.invalid\u{7}details\u{1b}]8;;\u{7}\u{1}"
1486 );
1487 assert!(handle_subagent_mailbox(
1488 &mut app,
1489 2,
1490 &MailboxMessage::progress(agent_id, raw.clone()),
1491 ));
1492
1493 let activity = app.agent_progress_meta[agent_id]
1494 .current_activity
1495 .as_ref()
1496 .expect("safe activity projection");
1497 let detail = activity.detail.as_deref().expect("safe detail");
1498 assert!(detail.contains("[redacted]"), "{detail:?}");
1499 assert!(!detail.contains(secret), "{detail:?}");
1500 assert!(!detail.contains('\u{1b}'), "{detail:?}");
1501 assert!(!detail.contains("example.invalid"), "{detail:?}");
1502
1503 let card_index = app.subagent_card_index[agent_id];
1504 let HistoryCell::SubAgent(SubAgentCell::Delegate(card)) = &app.history[card_index] else {
1505 panic!("expected delegate card");
1506 };
1507 let rendered = card
1508 .render_lines(120, &codewhale_palette::UI_THEME)
1509 .into_iter()
1510 .flat_map(|line| line.spans.into_iter().map(|span| span.content.into_owned()))
1511 .collect::<String>();
1512 assert!(rendered.contains("[redacted]"), "{rendered:?}");
1513 assert!(!rendered.contains(secret), "{rendered:?}");
1514 assert!(!rendered.contains('\u{1b}'), "{rendered:?}");
1515 assert!(!rendered.contains("example.invalid"), "{rendered:?}");
1516 assert!(
1517 raw.contains(secret),
1518 "source mailbox payload stays untouched"
1519 );
1520 assert!(
1521 raw.contains('\u{1b}'),
1522 "source mailbox payload stays untouched"
1523 );
1524 }
1525
1526 #[test]
1527 fn reconcile_keeps_progress_only_rows_until_cache_knows_the_agent() {
1528 let mut app = App::new(test_options(), &Config::default());
1529
1530 // A progress-first agent: its AgentSpawned/AgentList delivery was
1531 // dropped under channel pressure, so the authoritative cache has
1532 // never seen it. Its sidebar row must survive reconciliation.
1533 app.agent_progress
1534 .insert("agent_orphan".to_string(), "step 2/10".to_string());
1535 app.agent_progress_meta.insert(
1536 "agent_orphan".to_string(),
1537 AgentProgressMeta {
1538 parent_run_id: None,
1539 spawn_depth: 0,
1540 ..AgentProgressMeta::default()
1541 },
1542 );
1543
1544 // A terminal agent the cache DOES know about: its stale progress row
1545 // must still be evicted.
1546 app.subagent_cache
1547 .push(subagent_result("agent_done", SubAgentStatus::Completed));
1548 app.agent_progress
1549 .insert("agent_done".to_string(), "step 9/10".to_string());
1550 app.agent_progress_meta.insert(
1551 "agent_done".to_string(),
1552 AgentProgressMeta {
1553 parent_run_id: None,
1554 spawn_depth: 0,
1555 ..AgentProgressMeta::default()
1556 },
1557 );
1558
1559 reconcile_subagent_activity_state_at(&mut app, Instant::now());
1560
1561 assert!(
1562 app.agent_progress.contains_key("agent_orphan"),
1563 "progress-only agent unknown to the cache must survive reconcile"
1564 );
1565 assert!(
1566 app.agent_progress_meta.contains_key("agent_orphan"),
1567 "progress-only meta unknown to the cache must survive reconcile"
1568 );
1569 assert!(
1570 !app.agent_progress.contains_key("agent_done"),
1571 "cache-known terminal agent progress must still be evicted"
1572 );
1573 assert_eq!(
1574 app.agent_progress_meta["agent_done"]
1575 .current_activity
1576 .as_ref()
1577 .map(|activity| activity.status),
1578 Some(AgentCurrentActivityStatus::Done),
1579 "cache-known terminal agents retain a bounded terminal projection"
1580 );
1581
1582 // Once the authoritative cache reports the orphan as terminal, the
1583 // normal eviction applies and the row is released.
1584 app.subagent_cache
1585 .push(subagent_result("agent_orphan", SubAgentStatus::Completed));
1586 reconcile_subagent_activity_state_at(&mut app, Instant::now());
1587 assert!(
1588 !app.agent_progress.contains_key("agent_orphan"),
1589 "cache supersedes the progress-only row once it knows the agent"
1590 );
1591 assert_eq!(
1592 app.agent_progress_meta["agent_orphan"]
1593 .current_activity
1594 .as_ref()
1595 .map(|activity| activity.status),
1596 Some(AgentCurrentActivityStatus::Done)
1597 );
1598 }
1599
1600 #[test]
1601 fn terminal_cards_archive_after_forty_five_seconds_without_losing_history() {
1602 let mut app = App::new(test_options(), &Config::default());
1603 let agent_id = "agent_archive";
1604 assert!(handle_subagent_mailbox(
1605 &mut app,
1606 1,
1607 &MailboxMessage::started(agent_id, FleetRole::Worker),
1608 ));
1609 app.subagent_cache
1610 .push(subagent_result(agent_id, SubAgentStatus::Completed));
1611
1612 let observed = Instant::now();
1613 reconcile_subagent_activity_state_at(&mut app, observed);
1614 reconcile_subagent_activity_state_at(&mut app, observed + SUBAGENT_TERMINAL_CARD_TTL);
1615 assert!(
1616 app.subagent_cache
1617 .iter()
1618 .any(|agent| agent.agent_id == agent_id),
1619 "the terminal card stays visible through its full 45-second grace period"
1620 );
1621
1622 reconcile_subagent_activity_state_at(
1623 &mut app,
1624 observed + SUBAGENT_TERMINAL_CARD_TTL + Duration::from_millis(1),
1625 );
1626 assert!(
1627 !app.subagent_cache
1628 .iter()
1629 .any(|agent| agent.agent_id == agent_id),
1630 "the compact live cache must archive the settled card after the grace period"
1631 );
1632 assert!(
1633 app.subagent_card_index.contains_key(agent_id),
1634 "archiving a compact card must not delete its transcript record"
1635 );
1636 }
1637
1638 #[test]
1639 fn apply_subagent_terminal_projection_clears_live_progress_and_card_state() {
1640 let mut app = App::new(test_options(), &Config::default());
1641 let started = MailboxMessage::started("agent_done", FleetRole::Worker);
1642 assert!(handle_subagent_mailbox(&mut app, 1, &started));
1643 let card_idx = app.subagent_card_index["agent_done"];
1644 let initial_revision = app.history_revisions[card_idx];
1645
1646 app.subagent_cache
1647 .push(subagent_result("agent_done", SubAgentStatus::Running));
1648 app.agent_progress
1649 .insert("agent_done".to_string(), "step 4/10".to_string());
1650 app.agent_progress_meta.insert(
1651 "agent_done".to_string(),
1652 AgentProgressMeta {
1653 parent_run_id: None,
1654 spawn_depth: 0,
1655 ..AgentProgressMeta::default()
1656 },
1657 );
1658
1659 assert!(apply_subagent_terminal_projection(
1660 &mut app,
1661 "agent_done",
1662 SubAgentStatus::Cancelled,
1663 Some("cancelled by user".to_string())
1664 ));
1665
1666 assert!(!app.agent_progress.contains_key("agent_done"));
1667 assert_eq!(
1668 app.agent_progress_meta["agent_done"]
1669 .current_activity
1670 .as_ref()
1671 .map(|activity| activity.status),
1672 Some(AgentCurrentActivityStatus::Canceled)
1673 );
1674 let agent = app
1675 .subagent_cache
1676 .iter()
1677 .find(|agent| agent.agent_id == "agent_done")
1678 .expect("projected agent remains cached");
1679 assert_eq!(agent.status, SubAgentStatus::Cancelled);
1680 assert_eq!(agent.worker_status, Some(AgentWorkerStatus::Cancelled));
1681 assert_eq!(agent.result.as_deref(), Some("cancelled by user"));
1682 assert_eq!(running_agent_count(&app), 0);
1683 assert_ne!(
1684 app.history_revisions[card_idx], initial_revision,
1685 "terminal projection should invalidate the stale running card"
1686 );
1687 match &app.history[card_idx] {
1688 HistoryCell::SubAgent(SubAgentCell::Delegate(card)) => {
1689 assert_eq!(card.status, AgentLifecycle::Cancelled);
1690 }
1691 cell => panic!("expected delegate card, got {cell:?}"),
1692 }
1693 }
1694
1695 #[test]
1696 fn parent_stop_status_names_only_workers_that_continue_detached() {
1697 let mut app = App::new(test_options(), &Config::default());
1698 app.subagent_cache
1699 .push(subagent_result("agent_b", SubAgentStatus::Running));
1700 app.subagent_cache
1701 .push(subagent_result("agent_done", SubAgentStatus::Completed));
1702 app.agent_progress
1703 .insert("agent_a".to_string(), "running tool".to_string());
1704 app.agent_label_map
1705 .insert("agent_a".to_string(), "Agent 1".to_string());
1706 app.agent_label_map
1707 .insert("agent_b".to_string(), "Southern Right".to_string());
1708 app.agent_label_map
1709 .insert("agent_done".to_string(), "Finished worker".to_string());
1710
1711 let status = parent_stop_status(&app, "Request cancelled");
1712 assert_eq!(
1713 status,
1714 "Request cancelled; detached workers continue (none canceled): Agent 1, Southern Right"
1715 );
1716 assert!(!status.contains("Finished worker"));
1717 }
1718
1719 #[test]
1720 fn parent_stop_status_is_unchanged_without_detached_workers() {
1721 let app = App::new(test_options(), &Config::default());
1722 assert_eq!(
1723 parent_stop_status(&app, "Request cancelled"),
1724 "Request cancelled"
1725 );
1726 }
1727
1728 #[test]
1729 fn completion_before_started_allocates_recovery_delegate_card() {
1730 let mut app = App::new(test_options(), &Config::default());
1731 let completed = MailboxMessage::Completed {
1732 agent_id: "agent_early".to_string(),
1733 summary: "recovered after early completion".to_string(),
1734 };
1735 assert!(
1736 handle_subagent_mailbox(&mut app, 1, &completed),
1737 "completion-first delivery must still open a card"
1738 );
1739 assert!(app.subagent_card_index.contains_key("agent_early"));
1740
1741 let started = MailboxMessage::started("agent_early", FleetRole::Worker);
1742 assert!(handle_subagent_mailbox(&mut app, 2, &started));
1743 match app.history.last() {
1744 Some(HistoryCell::SubAgent(SubAgentCell::Delegate(card))) => {
1745 assert_eq!(card.agent_id, "agent_early");
1746 assert_ne!(card.agent_type, "…");
1747 }
1748 other => panic!("expected delegate card, got {other:?}"),
1749 }
1750 }
1751
1752 /// #4810: each child card carries that child's own ledger — never the
1753 /// parent's, never a sibling's, and never as transcript messages.
1754 #[test]
1755 fn sibling_child_cards_render_disjoint_todo_lists() {
1756 use crate::tools::todo::{TodoItem, TodoListSnapshot, TodoStatus};
1757
1758 fn snapshot(id: u32, content: &str) -> TodoListSnapshot {
1759 TodoListSnapshot {
1760 items: vec![TodoItem {
1761 id,
1762 content: content.to_string(),
1763 status: TodoStatus::InProgress,
1764 }],
1765 completion_pct: 0,
1766 in_progress_id: Some(id),
1767 }
1768 }
1769
1770 fn card_text(app: &App, agent_id: &str) -> String {
1771 let idx = app.subagent_card_index[agent_id];
1772 let HistoryCell::SubAgent(SubAgentCell::Delegate(card)) = &app.history[idx] else {
1773 panic!("expected a delegate card for {agent_id}");
1774 };
1775 assert_eq!(card.agent_id, agent_id);
1776 card.render_lines(120, &app.ui_theme)
1777 .into_iter()
1778 .flat_map(|line| line.spans.into_iter().map(|span| span.content.into_owned()))
1779 .collect()
1780 }
1781
1782 let mut app = App::new(test_options(), &Config::default());
1783 // The parent's own ledger exists and must never surface on a child.
1784 let parent_item = "PARENT ONLY: ship the release";
1785
1786 for (seq, id) in ["agent_left", "agent_right"].into_iter().enumerate() {
1787 assert!(handle_subagent_mailbox(
1788 &mut app,
1789 seq as u64 + 1,
1790 &MailboxMessage::started(id, FleetRole::Worker),
1791 ));
1792 }
1793 assert!(handle_subagent_mailbox(
1794 &mut app,
1795 3,
1796 &MailboxMessage::WorkState {
1797 agent_id: "agent_left".to_string(),
1798 todo: snapshot(1, "LEFT: map the call sites"),
1799 },
1800 ));
1801 assert!(handle_subagent_mailbox(
1802 &mut app,
1803 4,
1804 &MailboxMessage::WorkState {
1805 agent_id: "agent_right".to_string(),
1806 todo: snapshot(1, "RIGHT: write the migration"),
1807 },
1808 ));
1809
1810 let left = card_text(&app, "agent_left");
1811 let right = card_text(&app, "agent_right");
1812 assert!(left.contains("LEFT: map the call sites"), "{left}");
1813 assert!(!left.contains("RIGHT:"), "sibling leak: {left}");
1814 assert!(!left.contains(parent_item), "parent leak: {left}");
1815 assert!(right.contains("RIGHT: write the migration"), "{right}");
1816 assert!(!right.contains("LEFT:"), "sibling leak: {right}");
1817 assert!(!right.contains(parent_item), "parent leak: {right}");
1818
1819 // A nested child of agent_left gets its own card and its own ledger;
1820 // neither its parent's card nor its uncle's card absorbs it.
1821 assert!(handle_subagent_mailbox(
1822 &mut app,
1823 5,
1824 &MailboxMessage::started("agent_nested", FleetRole::Worker),
1825 ));
1826 assert!(handle_subagent_mailbox(
1827 &mut app,
1828 6,
1829 &MailboxMessage::WorkState {
1830 agent_id: "agent_nested".to_string(),
1831 todo: snapshot(1, "NESTED: read one file"),
1832 },
1833 ));
1834 assert!(card_text(&app, "agent_nested").contains("NESTED: read one file"));
1835 assert!(!card_text(&app, "agent_left").contains("NESTED:"));
1836 assert!(!card_text(&app, "agent_right").contains("NESTED:"));
1837
1838 // Nothing entered the transcript as an ordinary message: every To-do
1839 // row lives inside a sub-agent card.
1840 assert!(
1841 !app.history.iter().any(|cell| {
1842 !matches!(cell, HistoryCell::SubAgent(_))
1843 && format!("{cell:?}").contains("map the call sites")
1844 }),
1845 "child To-do must not become an ordinary transcript message"
1846 );
1847 }
1848
1849 /// Work state is a ledger fact, not an activity transition: it must not
1850 /// invent or overwrite what the agent is currently doing.
1851 #[test]
1852 fn work_state_updates_todos_remaining_without_rewriting_activity() {
1853 use crate::tools::todo::{TodoItem, TodoListSnapshot, TodoStatus};
1854
1855 let mut app = App::new(test_options(), &Config::default());
1856 assert!(handle_subagent_mailbox(
1857 &mut app,
1858 1,
1859 &MailboxMessage::started("agent_todos", FleetRole::Worker),
1860 ));
1861 assert!(handle_subagent_mailbox(
1862 &mut app,
1863 2,
1864 &MailboxMessage::ToolCallStarted {
1865 agent_id: "agent_todos".to_string(),
1866 tool_name: "read_file".to_string(),
1867 step: 1,
1868 },
1869 ));
1870 assert!(handle_subagent_mailbox(
1871 &mut app,
1872 3,
1873 &MailboxMessage::WorkState {
1874 agent_id: "agent_todos".to_string(),
1875 todo: TodoListSnapshot {
1876 items: vec![
1877 TodoItem {
1878 id: 1,
1879 content: "done already".to_string(),
1880 status: TodoStatus::Completed,
1881 },
1882 TodoItem {
1883 id: 2,
1884 content: "still cooking".to_string(),
1885 status: TodoStatus::InProgress,
1886 },
1887 TodoItem {
1888 id: 3,
1889 content: "not yet".to_string(),
1890 status: TodoStatus::Pending,
1891 },
1892 ],
1893 completion_pct: 33,
1894 in_progress_id: Some(2),
1895 },
1896 },
1897 ));
1898
1899 let meta = &app.agent_progress_meta["agent_todos"];
1900 assert_eq!(meta.todos_remaining, Some(2));
1901 let activity = meta.current_activity.as_ref().expect("activity");
1902 assert_eq!(activity.status, AgentCurrentActivityStatus::RunningTool);
1903 assert_eq!(activity.current_tool.as_deref(), Some("read_file"));
1904
1905 // Empty publish clears the chip source (no list → no figure).
1906 assert!(handle_subagent_mailbox(
1907 &mut app,
1908 4,
1909 &MailboxMessage::WorkState {
1910 agent_id: "agent_todos".to_string(),
1911 todo: TodoListSnapshot::default(),
1912 },
1913 ));
1914 assert_eq!(app.agent_progress_meta["agent_todos"].todos_remaining, None);
1915 }
1916
1917 #[test]
1918 fn work_state_envelope_does_not_rewrite_current_activity() {
1919 let mut app = App::new(test_options(), &Config::default());
1920 assert!(handle_subagent_mailbox(
1921 &mut app,
1922 1,
1923 &MailboxMessage::started("agent_x", FleetRole::Worker),
1924 ));
1925 assert!(handle_subagent_mailbox(
1926 &mut app,
1927 2,
1928 &MailboxMessage::ToolCallStarted {
1929 agent_id: "agent_x".to_string(),
1930 tool_name: "read_file".to_string(),
1931 step: 2,
1932 },
1933 ));
1934 assert!(handle_subagent_mailbox(
1935 &mut app,
1936 3,
1937 &MailboxMessage::WorkState {
1938 agent_id: "agent_x".to_string(),
1939 todo: crate::tools::todo::TodoListSnapshot {
1940 items: vec![crate::tools::todo::TodoItem {
1941 id: 1,
1942 content: "keep reading".to_string(),
1943 status: crate::tools::todo::TodoStatus::InProgress,
1944 }],
1945 completion_pct: 0,
1946 in_progress_id: Some(1),
1947 },
1948 },
1949 ));
1950
1951 let activity = app.agent_progress_meta["agent_x"]
1952 .current_activity
1953 .as_ref()
1954 .expect("activity");
1955 assert_eq!(activity.status, AgentCurrentActivityStatus::RunningTool);
1956 assert_eq!(activity.current_tool.as_deref(), Some("read_file"));
1957 }
1958
1959 /// Displayed child ledger text goes through the same redaction the rest of
1960 /// the child's displayed strings do.
1961 #[test]
1962 fn work_state_item_text_is_redacted_before_it_reaches_the_card() {
1963 let mut app = App::new(test_options(), &Config::default());
1964 assert!(handle_subagent_mailbox(
1965 &mut app,
1966 1,
1967 &MailboxMessage::started("agent_secret", FleetRole::Worker),
1968 ));
1969 let secret = "sk-mailbox-secret-1234567890";
1970 assert!(handle_subagent_mailbox(
1971 &mut app,
1972 2,
1973 &MailboxMessage::WorkState {
1974 agent_id: "agent_secret".to_string(),
1975 todo: crate::tools::todo::TodoListSnapshot {
1976 items: vec![crate::tools::todo::TodoItem {
1977 id: 4,
1978 content: format!("rotate api_key={secret}"),
1979 status: crate::tools::todo::TodoStatus::InProgress,
1980 }],
1981 completion_pct: 0,
1982 in_progress_id: Some(4),
1983 },
1984 },
1985 ));
1986
1987 let idx = app.subagent_card_index["agent_secret"];
1988 let HistoryCell::SubAgent(SubAgentCell::Delegate(card)) = &app.history[idx] else {
1989 panic!("expected delegate card");
1990 };
1991 let rendered: String = card
1992 .render_lines(120, &codewhale_palette::UI_THEME)
1993 .into_iter()
1994 .flat_map(|line| line.spans.into_iter().map(|span| span.content.into_owned()))
1995 .collect();
1996 assert!(!rendered.contains(secret), "{rendered}");
1997 assert!(rendered.contains("[redacted]"), "{rendered}");
1998 assert!(
1999 rendered.contains("#4"),
2000 "item identity is preserved: {rendered}"
2001 );
2002 }
2003
2004 #[test]
2005 fn fanout_completion_burst_preserves_started_to_done_ordering() {
2006 let mut app = App::new(test_options(), &Config::default());
2007 app.pending_subagent_dispatch = Some("rlm_eval".to_string());
2008 for (seq, id) in ["agent_a", "agent_b"].into_iter().enumerate() {
2009 assert!(handle_subagent_mailbox(
2010 &mut app,
2011 seq as u64 + 1,
2012 &MailboxMessage::started(id, FleetRole::Scout),
2013 ));
2014 }
2015 assert!(handle_subagent_mailbox(
2016 &mut app,
2017 3,
2018 &MailboxMessage::Completed {
2019 agent_id: "agent_a".to_string(),
2020 summary: "a done".to_string(),
2021 },
2022 ));
2023 let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.last() else {
2024 panic!("expected fanout card");
2025 };
2026 assert_eq!(card.workers.len(), 2);
2027 assert_eq!(card.workers[0].status, AgentLifecycle::Completed);
2028 assert_eq!(card.workers[1].status, AgentLifecycle::Running);
2029 }
2030 }
2031
2031 lines RUST