返回 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 // A completion-first delivery opens the slot settled, as the delegate
592 // path does; recording it as running would leave a finished worker
593 // counted as running with nothing left to settle it (U05-04).
594 let first_status = match &display_message {
595 MailboxMessage::Completed { .. } => AgentLifecycle::Completed,
596 MailboxMessage::Failed { .. } => AgentLifecycle::Failed,
597 MailboxMessage::Interrupted { .. } => AgentLifecycle::Interrupted,
598 MailboxMessage::Cancelled { .. } => AgentLifecycle::Cancelled,
599 _ => AgentLifecycle::Running,
600 };
601 // Reuse the active fanout card for sibling spawns; otherwise create
602 // one anchored at this position so subsequent siblings join it.
603 if let Some(idx) = app.last_fanout_card_index
604 && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) =
605 app.history.get_mut(idx)
606 {
607 let updated = card.claim_pending_worker(&agent_id, first_status);
608 app.subagent_card_index.insert(agent_id, idx);
609 if updated {
610 app.bump_history_cell(idx);
611 }
612 updated
613 } else {
614 let mut card = FanoutCard::new(dispatch_kind.unwrap_or("rlm_eval").to_string());
615 card.upsert_worker(&agent_id, first_status);
616 app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card)));
617 let idx = app.history.len().saturating_sub(1);
618 app.last_fanout_card_index = Some(idx);
619 app.subagent_card_index.insert(agent_id, idx);
620 app.bump_history_cell(idx);
621 true
622 }
623 } else {
624 let mut card = DelegateCard::new(agent_id.clone(), agent_type.clone());
625 apply_to_delegate(&mut card, &display_message);
626 app.add_message(HistoryCell::SubAgent(SubAgentCell::Delegate(card)));
627 let idx = app.history.len().saturating_sub(1);
628 app.subagent_card_index.insert(agent_id.clone(), idx);
629 // Single delegate consumes the pending dispatch label so a follow-on
630 // tool call doesn't accidentally inherit it.
631 app.pending_subagent_dispatch = None;
632 // idx was just inserted on the line above — no need to re-query.
633 app.bump_history_cell(idx);
634 true
635 }
636 }
637
638 #[cfg(test)]
639 pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &MailboxMessage) -> bool {
640 handle_subagent_mailbox_for_turn(app, "test-turn", seq, message)
641 }
642
643 fn bounded_mailbox_message(message: &MailboxMessage) -> MailboxMessage {
644 match message {
645 MailboxMessage::Progress { agent_id, status } => MailboxMessage::Progress {
646 agent_id: agent_id.clone(),
647 status: bound_agent_activity_text(status),
648 },
649 MailboxMessage::ToolCallStarted {
650 agent_id,
651 tool_name,
652 step,
653 } => MailboxMessage::ToolCallStarted {
654 agent_id: agent_id.clone(),
655 tool_name: bound_agent_activity_text(subagent_progress_tool_display_name(tool_name)),
656 step: *step,
657 },
658 MailboxMessage::ToolCallCompleted {
659 agent_id,
660 tool_name,
661 step,
662 ok,
663 } => MailboxMessage::ToolCallCompleted {
664 agent_id: agent_id.clone(),
665 tool_name: bound_agent_activity_text(subagent_progress_tool_display_name(tool_name)),
666 step: *step,
667 ok: *ok,
668 },
669 MailboxMessage::Completed { agent_id, summary } => MailboxMessage::Completed {
670 agent_id: agent_id.clone(),
671 summary: bound_agent_activity_text(summary),
672 },
673 MailboxMessage::Failed { agent_id, error } => MailboxMessage::Failed {
674 agent_id: agent_id.clone(),
675 error: bound_agent_activity_text(error),
676 },
677 MailboxMessage::Interrupted { agent_id, reason } => MailboxMessage::Interrupted {
678 agent_id: agent_id.clone(),
679 reason: bound_agent_activity_text(reason),
680 },
681 // Item text is model-authored and reaches the transcript, so it gets
682 // the same redaction/bounding every other displayed child string gets.
683 // Ids, statuses, and counts are preserved exactly — bounding must not
684 // change what the ledger says.
685 MailboxMessage::WorkState { agent_id, todo } => MailboxMessage::WorkState {
686 agent_id: agent_id.clone(),
687 todo: crate::tools::todo::TodoListSnapshot {
688 items: todo
689 .items
690 .iter()
691 .map(|item| crate::tools::todo::TodoItem {
692 content: bound_agent_activity_text(&item.content),
693 ..item.clone()
694 })
695 .collect(),
696 ..todo.clone()
697 },
698 },
699 _ => message.clone(),
700 }
701 }
702
703 fn record_agent_current_activity(app: &mut App, message: &MailboxMessage) {
704 let agent_id = message.agent_id().to_string();
705 let meta = app.agent_progress_meta.entry(agent_id).or_default();
706 meta.last_progress_at = Some(Instant::now());
707 if let MailboxMessage::TokenUsage { route, usage, .. } = message {
708 // The child's own used-token tally (input + output), matching the
709 // worker budget's `usage_total_tokens`. Counting only completions made
710 // the strip look "stuck" on tiny numbers while the child was burning
711 // context. Absent until a real envelope lands, so an agent with no
712 // reported usage shows no number instead of a zero.
713 let turn_total =
714 u64::from(usage.input_tokens).saturating_add(u64::from(usage.output_tokens));
715 meta.received_tokens = Some(meta.received_tokens.unwrap_or(0).saturating_add(turn_total));
716 meta.resolved_provider = Some(route.provider.as_str().to_string());
717 meta.resolved_model = Some(bound_agent_activity_text(
718 &crate::cost_status::sanitize_persisted_route_label(&route.model),
719 ))
720 .filter(|model| !model.trim().is_empty());
721 return;
722 }
723 if let MailboxMessage::WorkState { todo, .. } = message {
724 // Work state is a separate fact from what the agent is doing right
725 // now — publishing a ledger update must not invent an activity
726 // transition. It does update the remaining-to-do chip for the strip.
727 if todo.is_empty() {
728 meta.todos_remaining = None;
729 } else {
730 let remaining = todo
731 .items
732 .iter()
733 .filter(|item| !item.status.is_settled())
734 .count();
735 meta.todos_remaining = Some(u32::try_from(remaining).unwrap_or(u32::MAX));
736 }
737 return;
738 }
739 if let MailboxMessage::ToolCallCompleted {
740 tool_name,
741 step,
742 ok,
743 ..
744 } = message
745 {
746 if meta.recent_actions.len() == MAX_AGENT_RECENT_ACTIONS {
747 meta.recent_actions.pop_front();
748 }
749 meta.recent_actions.push_back(AgentRecentAction::bounded(
750 subagent_progress_tool_display_name(tool_name),
751 *step,
752 *ok,
753 ));
754 }
755 let previous = meta.current_activity.clone();
756
757 let (status, detail, current_tool, step) = match message {
758 MailboxMessage::Started { agent_type, .. } => (
759 AgentCurrentActivityStatus::Running,
760 Some(format!("started {agent_type}")),
761 None,
762 None,
763 ),
764 MailboxMessage::Progress { status, .. } => (
765 previous
766 .as_ref()
767 .map(|activity| activity.status)
768 .unwrap_or(AgentCurrentActivityStatus::Running),
769 Some(status.clone()),
770 previous
771 .as_ref()
772 .and_then(|activity| activity.current_tool.clone()),
773 previous.as_ref().and_then(|activity| activity.step),
774 ),
775 MailboxMessage::ToolCallStarted {
776 tool_name, step, ..
777 } => (
778 AgentCurrentActivityStatus::RunningTool,
779 None,
780 Some(subagent_progress_tool_display_name(tool_name).to_string()),
781 Some(*step),
782 ),
783 MailboxMessage::ToolCallCompleted {
784 tool_name,
785 step,
786 ok,
787 ..
788 } => (
789 AgentCurrentActivityStatus::Running,
790 Some(format!(
791 "{} {}",
792 subagent_progress_tool_display_name(tool_name),
793 if *ok { "completed" } else { "failed" }
794 )),
795 None,
796 Some(*step),
797 ),
798 MailboxMessage::ChildSpawned { parent_id, .. } => (
799 AgentCurrentActivityStatus::Starting,
800 Some(format!("spawned by {parent_id}")),
801 None,
802 None,
803 ),
804 MailboxMessage::Completed { summary, .. } => (
805 AgentCurrentActivityStatus::Done,
806 Some(summary.clone()),
807 None,
808 previous.as_ref().and_then(|activity| activity.step),
809 ),
810 MailboxMessage::Failed { error, .. } => (
811 AgentCurrentActivityStatus::Failed,
812 Some(error.clone()),
813 None,
814 previous.as_ref().and_then(|activity| activity.step),
815 ),
816 MailboxMessage::Interrupted { reason, .. } => (
817 AgentCurrentActivityStatus::Waiting,
818 Some(reason.clone()),
819 None,
820 previous.as_ref().and_then(|activity| activity.step),
821 ),
822 MailboxMessage::Cancelled { .. } => (
823 AgentCurrentActivityStatus::Canceled,
824 None,
825 None,
826 previous.as_ref().and_then(|activity| activity.step),
827 ),
828 MailboxMessage::TokenUsage { .. } => unreachable!("token usage handled above"),
829 MailboxMessage::WorkState { .. } => unreachable!("work state handled above"),
830 };
831
832 // A settled agent is final, as on its transcript card (U05-04): mailbox
833 // producers are concurrent, so a Started/Progress/tool envelope can land
834 // after the terminal one. It must not move the Work row back to active.
835 // Only settlements whose continuation is a *new* agent latch; `Waiting`
836 // (a question) resumes this same agent. The manager snapshot
837 // (`reconcile_subagent_activity_state`) stays the authority either way.
838 let settled = previous.as_ref().is_some_and(|activity| {
839 matches!(
840 activity.status,
841 AgentCurrentActivityStatus::Done
842 | AgentCurrentActivityStatus::Failed
843 | AgentCurrentActivityStatus::Canceled
844 | AgentCurrentActivityStatus::Interrupted
845 | AgentCurrentActivityStatus::Parked
846 )
847 });
848 if !settled {
849 meta.current_activity = Some(AgentCurrentActivity::bounded(
850 status,
851 detail,
852 current_tool.clone(),
853 step,
854 ));
855 meta.current_tool = current_tool;
856 }
857 if let MailboxMessage::ToolCallCompleted {
858 tool_name,
859 ok: true,
860 ..
861 } = message
862 && is_file_mutation_tool(tool_name)
863 {
864 meta.files_touched = meta.files_touched.saturating_add(1);
865 }
866 }
867
868 fn is_file_mutation_tool(name: &str) -> bool {
869 matches!(
870 name,
871 "write_file" | "edit_file" | "apply_patch" | "fim_edit" | "Write" | "Edit"
872 )
873 }
874
875 pub(super) fn task_mode_label(mode: AppMode) -> &'static str {
876 mode.as_setting()
877 }
878
879 pub(super) fn task_summary_to_panel_entry(summary: TaskSummary) -> TaskPanelEntry {
880 TaskPanelEntry {
881 exit_code: None,
882 id: summary.id,
883 status: task_status_label(summary.status).to_string(),
884 prompt_summary: summary.prompt_summary,
885 duration_ms: summary.duration_ms,
886 kind: TaskPanelEntryKind::Background,
887 stale: false,
888 elapsed_since_output_ms: None,
889 owner_agent_id: None,
890 owner_agent_name: None,
891 current_tool: None,
892 role: None,
893 files_touched: 0,
894 }
895 }
896
897 fn task_status_label(status: TaskStatus) -> &'static str {
898 match status {
899 TaskStatus::Queued => "queued",
900 TaskStatus::Running => "running",
901 TaskStatus::Completed => "completed",
902 TaskStatus::Failed => "failed",
903 TaskStatus::Canceled => "canceled",
904 }
905 }
906
907 pub(super) fn format_task_list(tasks: &[TaskSummary]) -> String {
908 if tasks.is_empty() {
909 return "No tasks found.".to_string();
910 }
911
912 let show_session = tasks.iter().any(|task| task.owner_session_id.is_some());
913 let mut lines = vec![format!("Tasks ({})", tasks.len())];
914 // Build headers with the same format strings as the rows so the ID
915 // column (21-char `task_` ids) can never drift out of alignment again.
916 if show_session {
917 lines.push(format!(
918 "{:<21} {:<9} {:<12} {:>8} {}",
919 "ID", "Status", "Session", "Time", "Title"
920 ));
921 } else {
922 lines.push(format!(
923 "{:<21} {:<9} {:>8} {}",
924 "ID", "Status", "Time", "Title"
925 ));
926 }
927 lines.push("------------------------------------------------------------".to_string());
928 for task in tasks {
929 let duration = task
930 .duration_ms
931 .map(crate::elapsed::format_elapsed_ms)
932 .unwrap_or_else(|| "-".to_string());
933 let owner_session = task.owner_session_id.as_deref().unwrap_or("-");
934 let owner_session = if owner_session.chars().count() > 12 {
935 format!("{}…", owner_session.chars().take(11).collect::<String>())
936 } else {
937 owner_session.to_string()
938 };
939 if show_session {
940 lines.push(format!(
941 "{:<21} {:<9} {:<12} {:>8} {}",
942 task.id,
943 task_status_label(task.status),
944 owner_session,
945 duration,
946 task.prompt_summary
947 ));
948 } else {
949 lines.push(format!(
950 "{:<21} {:<9} {:>8} {}",
951 task.id,
952 task_status_label(task.status),
953 duration,
954 task.prompt_summary
955 ));
956 }
957 }
958 lines.push("Use /task show <id> for timeline details.".to_string());
959 lines.join("\n")
960 }
961
962 pub(super) fn open_task_pager(app: &mut App, task: &TaskRecord) {
963 let width = app
964 .viewport
965 .last_transcript_area
966 .map(|area| area.width)
967 .unwrap_or(100)
968 .saturating_sub(4);
969 app.view_stack.push(PagerView::from_text(
970 format!("Task {}", task.id),
971 &format_task_detail(task),
972 width.max(60),
973 ));
974 }
975
976 fn format_task_detail(task: &TaskRecord) -> String {
977 let mut lines = Vec::new();
978 lines.push(format!("Task: {}", task.id));
979 lines.push(format!("Status: {}", task_status_label(task.status)));
980 lines.push(format!("Mode: {}", task.mode));
981 lines.push(format!("Model: {}", task.model));
982 lines.push(format!(
983 "Workspace: {}",
984 crate::utils::display_path(&task.workspace)
985 ));
986 if let Some(owner_session_id) = task.owner_session_id.as_deref() {
987 lines.push(format!("Owning Session: {owner_session_id}"));
988 }
989 if let Some(thread_id) = task.thread_id.as_ref() {
990 lines.push(format!("Runtime Thread: {thread_id}"));
991 }
992 if let Some(turn_id) = task.turn_id.as_ref() {
993 lines.push(format!("Runtime Turn: {turn_id}"));
994 }
995 if task.runtime_event_count > 0 {
996 lines.push(format!("Runtime Events: {}", task.runtime_event_count));
997 }
998 lines.push(format!("Created: {}", task.created_at));
999 if let Some(started_at) = task.started_at {
1000 lines.push(format!("Started: {started_at}"));
1001 }
1002 if let Some(ended_at) = task.ended_at {
1003 lines.push(format!("Ended: {ended_at}"));
1004 }
1005 if let Some(duration) = task.duration_ms {
1006 lines.push(format!(
1007 "Duration: {}",
1008 crate::elapsed::format_elapsed_ms(duration)
1009 ));
1010 }
1011 lines.push(String::new());
1012 lines.push("Prompt:".to_string());
1013 lines.push(task.prompt.clone());
1014
1015 if let Some(summary) = task.result_summary.as_ref() {
1016 lines.push(String::new());
1017 lines.push("Result Summary:".to_string());
1018 lines.push(summary.clone());
1019 }
1020 if let Some(path) = task.result_detail_path.as_ref() {
1021 lines.push(format!("Result Artifact: {}", path.display()));
1022 }
1023 if let Some(error) = task.error.as_ref() {
1024 lines.push(String::new());
1025 lines.push(format!("Error: {error}"));
1026 }
1027
1028 lines.push(String::new());
1029 lines.push("Tool Calls:".to_string());
1030 if task.tool_calls.is_empty() {
1031 lines.push("- (none)".to_string());
1032 } else {
1033 for tool in &task.tool_calls {
1034 let status = match tool.status {
1035 crate::task_manager::TaskToolStatus::Running => "running",
1036 crate::task_manager::TaskToolStatus::Success => "success",
1037 crate::task_manager::TaskToolStatus::Failed => "failed",
1038 crate::task_manager::TaskToolStatus::Canceled => "canceled",
1039 };
1040 let mut line = format!(
1041 "- {} [{}] {}",
1042 tool.name,
1043 status,
1044 tool.output_summary.as_deref().unwrap_or("(no summary)")
1045 );
1046 if let Some(duration) = tool.duration_ms {
1047 line.push_str(&format!(" ({:.2}s)", duration as f64 / 1000.0));
1048 }
1049 lines.push(line);
1050 if let Some(path) = tool.detail_path.as_ref() {
1051 lines.push(format!(" detail: {}", path.display()));
1052 }
1053 if let Some(path) = tool.patch_ref.as_ref() {
1054 lines.push(format!(" patch: {}", path.display()));
1055 }
1056 }
1057 }
1058
1059 lines.push(String::new());
1060 lines.push("Timeline:".to_string());
1061 if task.timeline.is_empty() {
1062 lines.push("- (none)".to_string());
1063 } else {
1064 for entry in &task.timeline {
1065 lines.push(format!(
1066 "- [{}] {}: {}",
1067 entry.timestamp, entry.kind, entry.summary
1068 ));
1069 if let Some(path) = entry.detail_path.as_ref() {
1070 lines.push(format!(" detail: {}", path.display()));
1071 }
1072 }
1073 }
1074
1075 lines.join("\n")
1076 }
1077
1078 #[cfg(test)]
1079 mod tests {
1080 use super::*;
1081 use crate::config::Config;
1082 use crate::task_manager::{TaskStatus, TaskSummary};
1083 use crate::tools::subagent::{FleetRole, SubAgentAssignment};
1084 use crate::tui::app::{InitialInput, TuiOptions};
1085 use crate::tui::widgets::agent_card::AgentLifecycle;
1086 use chrono::Utc;
1087 use std::path::PathBuf;
1088
1089 fn test_options() -> TuiOptions {
1090 TuiOptions {
1091 model: "test-model".to_string(),
1092 allow_shell: true,
1093 max_subagents: 4,
1094 start_in_agent_mode: true,
1095 initial_input: None::<InitialInput>,
1096 startup_notice: None,
1097 ..crate::test_support::test_tui_options(PathBuf::from("."))
1098 }
1099 }
1100
1101 fn test_route(
1102 provider: crate::config::ProviderKind,
1103 model: &str,
1104 ) -> crate::cost_status::EffectiveRouteEnvelope {
1105 crate::cost_status::EffectiveRouteEnvelope::capture(
1106 None,
1107 provider,
1108 provider.as_str(),
1109 model,
1110 Some(provider.provider().default_base_url()),
1111 Utc::now(),
1112 )
1113 }
1114
1115 fn task_summary(id: &str, status: TaskStatus, duration_ms: Option<u64>) -> TaskSummary {
1116 TaskSummary {
1117 execution_binding_known: true,
1118 id: id.to_string(),
1119 status,
1120 prompt_summary: "Fix task list output".to_string(),
1121 name: None,
1122 model: "deepseek-v4-pro".to_string(),
1123 model_provider: None,
1124 model_provider_id: None,
1125 mode: "agent".to_string(),
1126 workspace: PathBuf::from("/tmp"),
1127 created_at: Utc::now(),
1128 started_at: None,
1129 ended_at: None,
1130 duration_ms,
1131 lifecycle_seq: 1,
1132 error: None,
1133 terminal_reason: None,
1134 thread_id: None,
1135 turn_id: None,
1136 owner_session_id: None,
1137 }
1138 }
1139
1140 fn subagent_result(id: &str, status: SubAgentStatus) -> SubAgentResult {
1141 SubAgentResult {
1142 usage: None,
1143 name: id.to_string(),
1144 agent_id: id.to_string(),
1145 context_mode: "fresh".to_string(),
1146 fork_context: false,
1147 workspace: None,
1148 git_branch: None,
1149 agent_type: FleetRole::Worker,
1150 assignment: SubAgentAssignment {
1151 native_preset: None,
1152 objective: format!("objective-{id}"),
1153 role: Some("worker".to_string()),
1154 },
1155 model: "deepseek-v4-flash".to_string(),
1156 nickname: None,
1157 status,
1158 worker_status: None,
1159 runtime_permissions: None,
1160 parent_run_id: None,
1161 spawn_depth: 0,
1162 child_route: None,
1163 result: None,
1164 steps_taken: 0,
1165 checkpoint: None,
1166 needs_input: None,
1167 duration_ms: 0,
1168 started_at: None,
1169 from_prior_session: false,
1170 idle_ms: None,
1171 heartbeat_timeout_ms: None,
1172 }
1173 }
1174
1175 #[test]
1176 fn task_list_includes_title_header_and_time_column() {
1177 let output = format_task_list(&[
1178 task_summary("task_12345678", TaskStatus::Running, None),
1179 task_summary("task_abcdef12", TaskStatus::Completed, Some(1234)),
1180 ]);
1181
1182 assert!(output.contains(&format!(
1183 "{:<21} {:<9} {:>8} {}",
1184 "ID", "Status", "Time", "Title"
1185 )));
1186 assert!(output.contains(&format!(
1187 "{:<21} {:<9} {:>8} {}",
1188 "task_12345678", "running", "-", "Fix task list output"
1189 )));
1190 assert!(output.contains(&format!(
1191 "{:<21} {:<9} {:>8} {}",
1192 "task_abcdef12", "completed", "1s", "Fix task list output"
1193 )));
1194 }
1195
1196 #[test]
1197 fn task_list_shows_owner_session_when_present() {
1198 let mut task = task_summary("task_owned", TaskStatus::Running, None);
1199 task.owner_session_id = Some("session-123456".to_string());
1200
1201 let output = format_task_list(&[task]);
1202
1203 assert!(output.contains(&format!(
1204 "{:<21} {:<9} {:<12} {:>8} {}",
1205 "ID", "Status", "Session", "Time", "Title"
1206 )));
1207 // Owner ids are truncated to 11 chars + '…' so the rendered value
1208 // stays inside the 12-wide Session column and cannot drift the
1209 // remaining columns out of alignment.
1210 assert!(output.contains("session-123…"), "{output}");
1211 }
1212
1213 #[test]
1214 fn mailbox_progress_reports_transcript_change_only_for_visible_card_updates() {
1215 let mut app = App::new(test_options(), &Config::default());
1216 let started = MailboxMessage::started("agent_live", FleetRole::Worker);
1217 assert!(
1218 handle_subagent_mailbox(&mut app, 1, &started),
1219 "first started envelope creates a visible card"
1220 );
1221
1222 let progress =
1223 MailboxMessage::progress("agent_live", "step 1/100: requesting model response");
1224 assert!(
1225 !handle_subagent_mailbox(&mut app, 2, &progress),
1226 "low-signal progress for an already-running card is a no-op"
1227 );
1228
1229 let tool = MailboxMessage::ToolCallStarted {
1230 agent_id: "agent_live".to_string(),
1231 tool_name: "read_file".to_string(),
1232 step: 1,
1233 };
1234 assert!(
1235 handle_subagent_mailbox(&mut app, 3, &tool),
1236 "tool progress still updates the visible transcript card"
1237 );
1238 assert_eq!(
1239 app.agent_progress_meta["agent_live"]
1240 .current_tool
1241 .as_deref(),
1242 Some("read_file")
1243 );
1244
1245 let completed = MailboxMessage::ToolCallCompleted {
1246 agent_id: "agent_live".to_string(),
1247 tool_name: "read_file".to_string(),
1248 step: 1,
1249 ok: true,
1250 };
1251 assert!(handle_subagent_mailbox(&mut app, 4, &completed));
1252 assert_eq!(app.agent_progress_meta["agent_live"].current_tool, None);
1253
1254 let wrote = MailboxMessage::ToolCallCompleted {
1255 agent_id: "agent_live".to_string(),
1256 tool_name: "apply_patch".to_string(),
1257 step: 2,
1258 ok: true,
1259 };
1260 assert!(handle_subagent_mailbox(&mut app, 5, &wrote));
1261 assert_eq!(app.agent_progress_meta["agent_live"].files_touched, 1);
1262 }
1263
1264 #[test]
1265 fn canonical_child_file_activity_counts_only_successful_mutations() {
1266 let mut app = App::new(test_options(), &Config::default());
1267
1268 for (step, tool_name) in ["read_file", "list_dir", "file_search", "grep_files"]
1269 .into_iter()
1270 .enumerate()
1271 {
1272 record_agent_current_activity(
1273 &mut app,
1274 &MailboxMessage::ToolCallCompleted {
1275 agent_id: "agent_files".to_string(),
1276 tool_name: tool_name.to_string(),
1277 step: step as u32,
1278 ok: true,
1279 },
1280 );
1281 }
1282 assert_eq!(app.agent_progress_meta["agent_files"].files_touched, 0);
1283
1284 for (step, tool_name) in ["write_file", "edit_file", "apply_patch"]
1285 .into_iter()
1286 .enumerate()
1287 {
1288 record_agent_current_activity(
1289 &mut app,
1290 &MailboxMessage::ToolCallCompleted {
1291 agent_id: "agent_files".to_string(),
1292 tool_name: tool_name.to_string(),
1293 step: (step + 10) as u32,
1294 ok: true,
1295 },
1296 );
1297 }
1298 assert_eq!(app.agent_progress_meta["agent_files"].files_touched, 3);
1299
1300 record_agent_current_activity(
1301 &mut app,
1302 &MailboxMessage::ToolCallCompleted {
1303 agent_id: "agent_files".to_string(),
1304 tool_name: "write_file".to_string(),
1305 step: 20,
1306 ok: false,
1307 },
1308 );
1309 assert_eq!(app.agent_progress_meta["agent_files"].files_touched, 3);
1310 }
1311
1312 #[test]
1313 fn recent_actions_are_three_bounded_structured_tool_outcomes() {
1314 let mut app = App::new(test_options(), &Config::default());
1315 let agent_id = "agent_recent";
1316 for step in 1..=5 {
1317 record_agent_current_activity(
1318 &mut app,
1319 &MailboxMessage::ToolCallCompleted {
1320 agent_id: agent_id.to_string(),
1321 tool_name: format!("\u{1b}[31mtool_{step}\u{1b}[0m"),
1322 step,
1323 ok: step != 4,
1324 },
1325 );
1326 }
1327 record_agent_current_activity(
1328 &mut app,
1329 &MailboxMessage::Progress {
1330 agent_id: agent_id.to_string(),
1331 status: "tool_99 completed".to_string(),
1332 },
1333 );
1334
1335 let actions = &app.agent_progress_meta[agent_id].recent_actions;
1336 assert_eq!(actions.len(), MAX_AGENT_RECENT_ACTIONS);
1337 assert_eq!(
1338 actions.iter().map(|action| action.step).collect::<Vec<_>>(),
1339 vec![3, 4, 5]
1340 );
1341 assert!(!actions.iter().any(|action| action.step == 99));
1342 assert!(actions.iter().all(|action| !action.tool.contains('\u{1b}')));
1343 assert!(!actions[1].ok);
1344 }
1345
1346 #[test]
1347 fn token_usage_records_only_the_effective_child_route_facts() {
1348 let mut app = App::new(test_options(), &Config::default());
1349 let changed = handle_subagent_mailbox(
1350 &mut app,
1351 91,
1352 &MailboxMessage::TokenUsage {
1353 agent_id: "agent_route".to_string(),
1354 source_id: "response-route".to_string(),
1355 route: Box::new(test_route(
1356 crate::config::ProviderKind::Openrouter,
1357 "vendor/model-real",
1358 )),
1359 usage: codewhale_models::Usage::default(),
1360 },
1361 );
1362
1363 assert!(!changed, "route facts do not allocate a transcript card");
1364 let meta = &app.agent_progress_meta["agent_route"];
1365 assert_eq!(meta.resolved_provider.as_deref(), Some("openrouter"));
1366 assert_eq!(meta.resolved_model.as_deref(), Some("vendor/model-real"));
1367 assert!(meta.current_activity.is_none());
1368 }
1369
1370 #[test]
1371 fn token_usage_accumulates_input_plus_output_across_child_turns() {
1372 let mut app = App::new(test_options(), &Config::default());
1373 let route = test_route(crate::config::ProviderKind::Deepseek, "deepseek-v4-flash");
1374 handle_subagent_mailbox(
1375 &mut app,
1376 1,
1377 &MailboxMessage::TokenUsage {
1378 agent_id: "agent_spend".to_string(),
1379 source_id: "response-1".to_string(),
1380 route: Box::new(route.clone()),
1381 usage: codewhale_models::Usage {
1382 input_tokens: 1_000,
1383 output_tokens: 40,
1384 ..Default::default()
1385 },
1386 },
1387 );
1388 handle_subagent_mailbox(
1389 &mut app,
1390 2,
1391 &MailboxMessage::TokenUsage {
1392 agent_id: "agent_spend".to_string(),
1393 source_id: "response-2".to_string(),
1394 route: Box::new(route),
1395 usage: codewhale_models::Usage {
1396 input_tokens: 2_000,
1397 output_tokens: 60,
1398 ..Default::default()
1399 },
1400 },
1401 );
1402
1403 assert_eq!(
1404 app.agent_progress_meta["agent_spend"].received_tokens,
1405 Some(3_100),
1406 "work-bar tally must match worker budget total (input+output)"
1407 );
1408 }
1409
1410 #[test]
1411 fn typed_mailbox_lifecycle_projects_running_waiting_failed_and_done() {
1412 let mut app = App::new(test_options(), &Config::default());
1413
1414 assert!(handle_subagent_mailbox(
1415 &mut app,
1416 1,
1417 &MailboxMessage::started("agent_running", FleetRole::Worker),
1418 ));
1419 assert_eq!(
1420 app.agent_progress_meta["agent_running"]
1421 .current_activity
1422 .as_ref()
1423 .map(|activity| activity.status),
1424 Some(AgentCurrentActivityStatus::Running)
1425 );
1426
1427 assert!(handle_subagent_mailbox(
1428 &mut app,
1429 2,
1430 &MailboxMessage::ToolCallStarted {
1431 agent_id: "agent_running".to_string(),
1432 tool_name: "read_file".to_string(),
1433 step: 3,
1434 },
1435 ));
1436 let running = app.agent_progress_meta["agent_running"]
1437 .current_activity
1438 .as_ref()
1439 .expect("running tool projection");
1440 assert_eq!(running.status, AgentCurrentActivityStatus::RunningTool);
1441 assert_eq!(running.current_tool.as_deref(), Some("read_file"));
1442 assert_eq!(running.step, Some(3));
1443
1444 assert!(handle_subagent_mailbox(
1445 &mut app,
1446 3,
1447 &MailboxMessage::Interrupted {
1448 agent_id: "agent_running".to_string(),
1449 reason: "approval needed".to_string(),
1450 },
1451 ));
1452 let waiting = app.agent_progress_meta["agent_running"]
1453 .current_activity
1454 .as_ref()
1455 .expect("waiting projection");
1456 assert_eq!(waiting.status, AgentCurrentActivityStatus::Waiting);
1457 assert_eq!(waiting.detail.as_deref(), Some("approval needed"));
1458
1459 for (seq, agent_id, terminal, expected) in [
1460 (
1461 4,
1462 "agent_failed",
1463 MailboxMessage::Failed {
1464 agent_id: "agent_failed".to_string(),
1465 error: "verification failed".to_string(),
1466 },
1467 AgentCurrentActivityStatus::Failed,
1468 ),
1469 (
1470 5,
1471 "agent_done",
1472 MailboxMessage::Completed {
1473 agent_id: "agent_done".to_string(),
1474 summary: "verification complete".to_string(),
1475 },
1476 AgentCurrentActivityStatus::Done,
1477 ),
1478 ] {
1479 assert!(handle_subagent_mailbox(&mut app, seq, &terminal));
1480 assert_eq!(
1481 app.agent_progress_meta[agent_id]
1482 .current_activity
1483 .as_ref()
1484 .map(|activity| activity.status),
1485 Some(expected)
1486 );
1487 }
1488 }
1489
1490 #[test]
1491 fn reconcile_projects_typed_status_when_activity_detail_is_missing() {
1492 let mut app = App::new(test_options(), &Config::default());
1493 let mut agent = subagent_result("agent_model_wait", SubAgentStatus::Running);
1494 agent.worker_status = Some(AgentWorkerStatus::ModelWait);
1495 app.subagent_cache.push(agent);
1496
1497 reconcile_subagent_activity_state_at(&mut app, Instant::now());
1498
1499 let activity = app.agent_progress_meta["agent_model_wait"]
1500 .current_activity
1501 .as_ref()
1502 .expect("typed activity fallback");
1503 assert_eq!(activity.status, AgentCurrentActivityStatus::ModelWait);
1504 assert_eq!(activity.detail, None);
1505 }
1506
1507 #[test]
1508 fn mailbox_compact_projection_redacts_secrets_and_control_sequences() {
1509 let mut app = App::new(test_options(), &Config::default());
1510 let agent_id = "agent_safe_projection";
1511 assert!(handle_subagent_mailbox(
1512 &mut app,
1513 1,
1514 &MailboxMessage::started(agent_id, FleetRole::Worker),
1515 ));
1516 let secret = "sk-mailbox-secret-1234567890";
1517 let raw = format!(
1518 "\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}"
1519 );
1520 assert!(handle_subagent_mailbox(
1521 &mut app,
1522 2,
1523 &MailboxMessage::progress(agent_id, raw.clone()),
1524 ));
1525
1526 let activity = app.agent_progress_meta[agent_id]
1527 .current_activity
1528 .as_ref()
1529 .expect("safe activity projection");
1530 let detail = activity.detail.as_deref().expect("safe detail");
1531 assert!(detail.contains("[redacted]"), "{detail:?}");
1532 assert!(!detail.contains(secret), "{detail:?}");
1533 assert!(!detail.contains('\u{1b}'), "{detail:?}");
1534 assert!(!detail.contains("example.invalid"), "{detail:?}");
1535
1536 let card_index = app.subagent_card_index[agent_id];
1537 let HistoryCell::SubAgent(SubAgentCell::Delegate(card)) = &app.history[card_index] else {
1538 panic!("expected delegate card");
1539 };
1540 let rendered = card
1541 .render_lines(120, &codewhale_palette::UI_THEME)
1542 .into_iter()
1543 .flat_map(|line| line.spans.into_iter().map(|span| span.content.into_owned()))
1544 .collect::<String>();
1545 assert!(rendered.contains("[redacted]"), "{rendered:?}");
1546 assert!(!rendered.contains(secret), "{rendered:?}");
1547 assert!(!rendered.contains('\u{1b}'), "{rendered:?}");
1548 assert!(!rendered.contains("example.invalid"), "{rendered:?}");
1549 assert!(
1550 raw.contains(secret),
1551 "source mailbox payload stays untouched"
1552 );
1553 assert!(
1554 raw.contains('\u{1b}'),
1555 "source mailbox payload stays untouched"
1556 );
1557 }
1558
1559 #[test]
1560 fn reconcile_keeps_progress_only_rows_until_cache_knows_the_agent() {
1561 let mut app = App::new(test_options(), &Config::default());
1562
1563 // A progress-first agent: its AgentSpawned/AgentList delivery was
1564 // dropped under channel pressure, so the authoritative cache has
1565 // never seen it. Its sidebar row must survive reconciliation.
1566 app.agent_progress
1567 .insert("agent_orphan".to_string(), "step 2/10".to_string());
1568 app.agent_progress_meta.insert(
1569 "agent_orphan".to_string(),
1570 AgentProgressMeta {
1571 parent_run_id: None,
1572 spawn_depth: 0,
1573 ..AgentProgressMeta::default()
1574 },
1575 );
1576
1577 // A terminal agent the cache DOES know about: its stale progress row
1578 // must still be evicted.
1579 app.subagent_cache
1580 .push(subagent_result("agent_done", SubAgentStatus::Completed));
1581 app.agent_progress
1582 .insert("agent_done".to_string(), "step 9/10".to_string());
1583 app.agent_progress_meta.insert(
1584 "agent_done".to_string(),
1585 AgentProgressMeta {
1586 parent_run_id: None,
1587 spawn_depth: 0,
1588 ..AgentProgressMeta::default()
1589 },
1590 );
1591
1592 reconcile_subagent_activity_state_at(&mut app, Instant::now());
1593
1594 assert!(
1595 app.agent_progress.contains_key("agent_orphan"),
1596 "progress-only agent unknown to the cache must survive reconcile"
1597 );
1598 assert!(
1599 app.agent_progress_meta.contains_key("agent_orphan"),
1600 "progress-only meta unknown to the cache must survive reconcile"
1601 );
1602 assert!(
1603 !app.agent_progress.contains_key("agent_done"),
1604 "cache-known terminal agent progress must still be evicted"
1605 );
1606 assert_eq!(
1607 app.agent_progress_meta["agent_done"]
1608 .current_activity
1609 .as_ref()
1610 .map(|activity| activity.status),
1611 Some(AgentCurrentActivityStatus::Done),
1612 "cache-known terminal agents retain a bounded terminal projection"
1613 );
1614
1615 // Once the authoritative cache reports the orphan as terminal, the
1616 // normal eviction applies and the row is released.
1617 app.subagent_cache
1618 .push(subagent_result("agent_orphan", SubAgentStatus::Completed));
1619 reconcile_subagent_activity_state_at(&mut app, Instant::now());
1620 assert!(
1621 !app.agent_progress.contains_key("agent_orphan"),
1622 "cache supersedes the progress-only row once it knows the agent"
1623 );
1624 assert_eq!(
1625 app.agent_progress_meta["agent_orphan"]
1626 .current_activity
1627 .as_ref()
1628 .map(|activity| activity.status),
1629 Some(AgentCurrentActivityStatus::Done)
1630 );
1631 }
1632
1633 #[test]
1634 fn terminal_cards_archive_after_forty_five_seconds_without_losing_history() {
1635 let mut app = App::new(test_options(), &Config::default());
1636 let agent_id = "agent_archive";
1637 assert!(handle_subagent_mailbox(
1638 &mut app,
1639 1,
1640 &MailboxMessage::started(agent_id, FleetRole::Worker),
1641 ));
1642 app.subagent_cache
1643 .push(subagent_result(agent_id, SubAgentStatus::Completed));
1644
1645 let observed = Instant::now();
1646 reconcile_subagent_activity_state_at(&mut app, observed);
1647 reconcile_subagent_activity_state_at(&mut app, observed + SUBAGENT_TERMINAL_CARD_TTL);
1648 assert!(
1649 app.subagent_cache
1650 .iter()
1651 .any(|agent| agent.agent_id == agent_id),
1652 "the terminal card stays visible through its full 45-second grace period"
1653 );
1654
1655 reconcile_subagent_activity_state_at(
1656 &mut app,
1657 observed + SUBAGENT_TERMINAL_CARD_TTL + Duration::from_millis(1),
1658 );
1659 assert!(
1660 !app.subagent_cache
1661 .iter()
1662 .any(|agent| agent.agent_id == agent_id),
1663 "the compact live cache must archive the settled card after the grace period"
1664 );
1665 assert!(
1666 app.subagent_card_index.contains_key(agent_id),
1667 "archiving a compact card must not delete its transcript record"
1668 );
1669 }
1670
1671 #[test]
1672 fn apply_subagent_terminal_projection_clears_live_progress_and_card_state() {
1673 let mut app = App::new(test_options(), &Config::default());
1674 let started = MailboxMessage::started("agent_done", FleetRole::Worker);
1675 assert!(handle_subagent_mailbox(&mut app, 1, &started));
1676 let card_idx = app.subagent_card_index["agent_done"];
1677 let initial_revision = app.history_revisions[card_idx];
1678
1679 app.subagent_cache
1680 .push(subagent_result("agent_done", SubAgentStatus::Running));
1681 app.agent_progress
1682 .insert("agent_done".to_string(), "step 4/10".to_string());
1683 app.agent_progress_meta.insert(
1684 "agent_done".to_string(),
1685 AgentProgressMeta {
1686 parent_run_id: None,
1687 spawn_depth: 0,
1688 ..AgentProgressMeta::default()
1689 },
1690 );
1691
1692 assert!(apply_subagent_terminal_projection(
1693 &mut app,
1694 "agent_done",
1695 SubAgentStatus::Cancelled,
1696 Some("cancelled by user".to_string())
1697 ));
1698
1699 assert!(!app.agent_progress.contains_key("agent_done"));
1700 assert_eq!(
1701 app.agent_progress_meta["agent_done"]
1702 .current_activity
1703 .as_ref()
1704 .map(|activity| activity.status),
1705 Some(AgentCurrentActivityStatus::Canceled)
1706 );
1707 let agent = app
1708 .subagent_cache
1709 .iter()
1710 .find(|agent| agent.agent_id == "agent_done")
1711 .expect("projected agent remains cached");
1712 assert_eq!(agent.status, SubAgentStatus::Cancelled);
1713 assert_eq!(agent.worker_status, Some(AgentWorkerStatus::Cancelled));
1714 assert_eq!(agent.result.as_deref(), Some("cancelled by user"));
1715 assert_eq!(running_agent_count(&app), 0);
1716 assert_ne!(
1717 app.history_revisions[card_idx], initial_revision,
1718 "terminal projection should invalidate the stale running card"
1719 );
1720 match &app.history[card_idx] {
1721 HistoryCell::SubAgent(SubAgentCell::Delegate(card)) => {
1722 assert_eq!(card.status, AgentLifecycle::Cancelled);
1723 }
1724 cell => panic!("expected delegate card, got {cell:?}"),
1725 }
1726 }
1727
1728 #[test]
1729 fn parent_stop_status_names_only_workers_that_continue_detached() {
1730 let mut app = App::new(test_options(), &Config::default());
1731 app.subagent_cache
1732 .push(subagent_result("agent_b", SubAgentStatus::Running));
1733 app.subagent_cache
1734 .push(subagent_result("agent_done", SubAgentStatus::Completed));
1735 app.agent_progress
1736 .insert("agent_a".to_string(), "running tool".to_string());
1737 app.agent_label_map
1738 .insert("agent_a".to_string(), "Agent 1".to_string());
1739 app.agent_label_map
1740 .insert("agent_b".to_string(), "Southern Right".to_string());
1741 app.agent_label_map
1742 .insert("agent_done".to_string(), "Finished worker".to_string());
1743
1744 let status = parent_stop_status(&app, "Request cancelled");
1745 assert_eq!(
1746 status,
1747 "Request cancelled; detached workers continue (none canceled): Agent 1, Southern Right"
1748 );
1749 assert!(!status.contains("Finished worker"));
1750 }
1751
1752 #[test]
1753 fn parent_stop_status_is_unchanged_without_detached_workers() {
1754 let app = App::new(test_options(), &Config::default());
1755 assert_eq!(
1756 parent_stop_status(&app, "Request cancelled"),
1757 "Request cancelled"
1758 );
1759 }
1760
1761 #[test]
1762 fn completion_before_started_allocates_recovery_delegate_card() {
1763 let mut app = App::new(test_options(), &Config::default());
1764 let completed = MailboxMessage::Completed {
1765 agent_id: "agent_early".to_string(),
1766 summary: "recovered after early completion".to_string(),
1767 };
1768 assert!(
1769 handle_subagent_mailbox(&mut app, 1, &completed),
1770 "completion-first delivery must still open a card"
1771 );
1772 assert!(app.subagent_card_index.contains_key("agent_early"));
1773
1774 let started = MailboxMessage::started("agent_early", FleetRole::Worker);
1775 assert!(handle_subagent_mailbox(&mut app, 2, &started));
1776 match app.history.last() {
1777 Some(HistoryCell::SubAgent(SubAgentCell::Delegate(card))) => {
1778 assert_eq!(card.agent_id, "agent_early");
1779 assert_ne!(card.agent_type, "…");
1780 }
1781 other => panic!("expected delegate card, got {other:?}"),
1782 }
1783 }
1784
1785 /// #4810: each child card carries that child's own ledger — never the
1786 /// parent's, never a sibling's, and never as transcript messages.
1787 #[test]
1788 fn sibling_child_cards_render_disjoint_todo_lists() {
1789 use crate::tools::todo::{TodoItem, TodoListSnapshot, TodoStatus};
1790
1791 fn snapshot(id: u32, content: &str) -> TodoListSnapshot {
1792 TodoListSnapshot {
1793 items: vec![TodoItem {
1794 id,
1795 content: content.to_string(),
1796 status: TodoStatus::InProgress,
1797 }],
1798 completion_pct: 0,
1799 in_progress_id: Some(id),
1800 }
1801 }
1802
1803 fn card_text(app: &App, agent_id: &str) -> String {
1804 let idx = app.subagent_card_index[agent_id];
1805 let HistoryCell::SubAgent(SubAgentCell::Delegate(card)) = &app.history[idx] else {
1806 panic!("expected a delegate card for {agent_id}");
1807 };
1808 assert_eq!(card.agent_id, agent_id);
1809 card.render_lines(120, &app.ui_theme)
1810 .into_iter()
1811 .flat_map(|line| line.spans.into_iter().map(|span| span.content.into_owned()))
1812 .collect()
1813 }
1814
1815 let mut app = App::new(test_options(), &Config::default());
1816 // The parent's own ledger exists and must never surface on a child.
1817 let parent_item = "PARENT ONLY: ship the release";
1818
1819 for (seq, id) in ["agent_left", "agent_right"].into_iter().enumerate() {
1820 assert!(handle_subagent_mailbox(
1821 &mut app,
1822 seq as u64 + 1,
1823 &MailboxMessage::started(id, FleetRole::Worker),
1824 ));
1825 }
1826 assert!(handle_subagent_mailbox(
1827 &mut app,
1828 3,
1829 &MailboxMessage::WorkState {
1830 agent_id: "agent_left".to_string(),
1831 todo: snapshot(1, "LEFT: map the call sites"),
1832 },
1833 ));
1834 assert!(handle_subagent_mailbox(
1835 &mut app,
1836 4,
1837 &MailboxMessage::WorkState {
1838 agent_id: "agent_right".to_string(),
1839 todo: snapshot(1, "RIGHT: write the migration"),
1840 },
1841 ));
1842
1843 let left = card_text(&app, "agent_left");
1844 let right = card_text(&app, "agent_right");
1845 assert!(left.contains("LEFT: map the call sites"), "{left}");
1846 assert!(!left.contains("RIGHT:"), "sibling leak: {left}");
1847 assert!(!left.contains(parent_item), "parent leak: {left}");
1848 assert!(right.contains("RIGHT: write the migration"), "{right}");
1849 assert!(!right.contains("LEFT:"), "sibling leak: {right}");
1850 assert!(!right.contains(parent_item), "parent leak: {right}");
1851
1852 // A nested child of agent_left gets its own card and its own ledger;
1853 // neither its parent's card nor its uncle's card absorbs it.
1854 assert!(handle_subagent_mailbox(
1855 &mut app,
1856 5,
1857 &MailboxMessage::started("agent_nested", FleetRole::Worker),
1858 ));
1859 assert!(handle_subagent_mailbox(
1860 &mut app,
1861 6,
1862 &MailboxMessage::WorkState {
1863 agent_id: "agent_nested".to_string(),
1864 todo: snapshot(1, "NESTED: read one file"),
1865 },
1866 ));
1867 assert!(card_text(&app, "agent_nested").contains("NESTED: read one file"));
1868 assert!(!card_text(&app, "agent_left").contains("NESTED:"));
1869 assert!(!card_text(&app, "agent_right").contains("NESTED:"));
1870
1871 // Nothing entered the transcript as an ordinary message: every To-do
1872 // row lives inside a sub-agent card.
1873 assert!(
1874 !app.history.iter().any(|cell| {
1875 !matches!(cell, HistoryCell::SubAgent(_))
1876 && format!("{cell:?}").contains("map the call sites")
1877 }),
1878 "child To-do must not become an ordinary transcript message"
1879 );
1880 }
1881
1882 /// Work state is a ledger fact, not an activity transition: it must not
1883 /// invent or overwrite what the agent is currently doing.
1884 #[test]
1885 fn work_state_updates_todos_remaining_without_rewriting_activity() {
1886 use crate::tools::todo::{TodoItem, TodoListSnapshot, TodoStatus};
1887
1888 let mut app = App::new(test_options(), &Config::default());
1889 assert!(handle_subagent_mailbox(
1890 &mut app,
1891 1,
1892 &MailboxMessage::started("agent_todos", FleetRole::Worker),
1893 ));
1894 assert!(handle_subagent_mailbox(
1895 &mut app,
1896 2,
1897 &MailboxMessage::ToolCallStarted {
1898 agent_id: "agent_todos".to_string(),
1899 tool_name: "read_file".to_string(),
1900 step: 1,
1901 },
1902 ));
1903 assert!(handle_subagent_mailbox(
1904 &mut app,
1905 3,
1906 &MailboxMessage::WorkState {
1907 agent_id: "agent_todos".to_string(),
1908 todo: TodoListSnapshot {
1909 items: vec![
1910 TodoItem {
1911 id: 1,
1912 content: "done already".to_string(),
1913 status: TodoStatus::Completed,
1914 },
1915 TodoItem {
1916 id: 2,
1917 content: "still cooking".to_string(),
1918 status: TodoStatus::InProgress,
1919 },
1920 TodoItem {
1921 id: 3,
1922 content: "not yet".to_string(),
1923 status: TodoStatus::Pending,
1924 },
1925 ],
1926 completion_pct: 33,
1927 in_progress_id: Some(2),
1928 },
1929 },
1930 ));
1931
1932 let meta = &app.agent_progress_meta["agent_todos"];
1933 assert_eq!(meta.todos_remaining, Some(2));
1934 let activity = meta.current_activity.as_ref().expect("activity");
1935 assert_eq!(activity.status, AgentCurrentActivityStatus::RunningTool);
1936 assert_eq!(activity.current_tool.as_deref(), Some("read_file"));
1937
1938 // Empty publish clears the chip source (no list → no figure).
1939 assert!(handle_subagent_mailbox(
1940 &mut app,
1941 4,
1942 &MailboxMessage::WorkState {
1943 agent_id: "agent_todos".to_string(),
1944 todo: TodoListSnapshot::default(),
1945 },
1946 ));
1947 assert_eq!(app.agent_progress_meta["agent_todos"].todos_remaining, None);
1948 }
1949
1950 #[test]
1951 fn work_state_envelope_does_not_rewrite_current_activity() {
1952 let mut app = App::new(test_options(), &Config::default());
1953 assert!(handle_subagent_mailbox(
1954 &mut app,
1955 1,
1956 &MailboxMessage::started("agent_x", FleetRole::Worker),
1957 ));
1958 assert!(handle_subagent_mailbox(
1959 &mut app,
1960 2,
1961 &MailboxMessage::ToolCallStarted {
1962 agent_id: "agent_x".to_string(),
1963 tool_name: "read_file".to_string(),
1964 step: 2,
1965 },
1966 ));
1967 assert!(handle_subagent_mailbox(
1968 &mut app,
1969 3,
1970 &MailboxMessage::WorkState {
1971 agent_id: "agent_x".to_string(),
1972 todo: crate::tools::todo::TodoListSnapshot {
1973 items: vec![crate::tools::todo::TodoItem {
1974 id: 1,
1975 content: "keep reading".to_string(),
1976 status: crate::tools::todo::TodoStatus::InProgress,
1977 }],
1978 completion_pct: 0,
1979 in_progress_id: Some(1),
1980 },
1981 },
1982 ));
1983
1984 let activity = app.agent_progress_meta["agent_x"]
1985 .current_activity
1986 .as_ref()
1987 .expect("activity");
1988 assert_eq!(activity.status, AgentCurrentActivityStatus::RunningTool);
1989 assert_eq!(activity.current_tool.as_deref(), Some("read_file"));
1990 }
1991
1992 /// Displayed child ledger text goes through the same redaction the rest of
1993 /// the child's displayed strings do.
1994 #[test]
1995 fn work_state_item_text_is_redacted_before_it_reaches_the_card() {
1996 let mut app = App::new(test_options(), &Config::default());
1997 assert!(handle_subagent_mailbox(
1998 &mut app,
1999 1,
2000 &MailboxMessage::started("agent_secret", FleetRole::Worker),
2001 ));
2002 let secret = "sk-mailbox-secret-1234567890";
2003 assert!(handle_subagent_mailbox(
2004 &mut app,
2005 2,
2006 &MailboxMessage::WorkState {
2007 agent_id: "agent_secret".to_string(),
2008 todo: crate::tools::todo::TodoListSnapshot {
2009 items: vec![crate::tools::todo::TodoItem {
2010 id: 4,
2011 content: format!("rotate api_key={secret}"),
2012 status: crate::tools::todo::TodoStatus::InProgress,
2013 }],
2014 completion_pct: 0,
2015 in_progress_id: Some(4),
2016 },
2017 },
2018 ));
2019
2020 let idx = app.subagent_card_index["agent_secret"];
2021 let HistoryCell::SubAgent(SubAgentCell::Delegate(card)) = &app.history[idx] else {
2022 panic!("expected delegate card");
2023 };
2024 let rendered: String = card
2025 .render_lines(120, &codewhale_palette::UI_THEME)
2026 .into_iter()
2027 .flat_map(|line| line.spans.into_iter().map(|span| span.content.into_owned()))
2028 .collect();
2029 assert!(!rendered.contains(secret), "{rendered}");
2030 assert!(rendered.contains("[redacted]"), "{rendered}");
2031 assert!(
2032 rendered.contains("#4"),
2033 "item identity is preserved: {rendered}"
2034 );
2035 }
2036
2037 #[test]
2038 fn fanout_completion_burst_preserves_started_to_done_ordering() {
2039 let mut app = App::new(test_options(), &Config::default());
2040 app.pending_subagent_dispatch = Some("rlm_eval".to_string());
2041 for (seq, id) in ["agent_a", "agent_b"].into_iter().enumerate() {
2042 assert!(handle_subagent_mailbox(
2043 &mut app,
2044 seq as u64 + 1,
2045 &MailboxMessage::started(id, FleetRole::Scout),
2046 ));
2047 }
2048 assert!(handle_subagent_mailbox(
2049 &mut app,
2050 3,
2051 &MailboxMessage::Completed {
2052 agent_id: "agent_a".to_string(),
2053 summary: "a done".to_string(),
2054 },
2055 ));
2056 let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.last() else {
2057 panic!("expected fanout card");
2058 };
2059 assert_eq!(card.workers.len(), 2);
2060 assert_eq!(card.workers[0].status, AgentLifecycle::Completed);
2061 assert_eq!(card.workers[1].status, AgentLifecycle::Running);
2062 }
2063 }
2064
2064 lines RUST