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