1use std::collections::HashMap;
15
16use forge_primitives::runtime::{SubagentCall, SubagentCallStatus, SubagentCard};
17use forge_primitives::{ContentBlock, Message, TaskNotificationStatus};
18
19pub const TAIL_CAP: usize = 4;
21
22pub fn subagent_cards(messages: &[Message]) -> Vec<SubagentCard> {
24 let mut tracker = CardTracker::default();
25 for message in messages {
26 tracker.apply(message);
27 }
28 tracker.cards()
29}
30
31struct Instance {
33 id: String,
34 dispatch_name: String,
37 agent_type: Option<String>,
40 backgrounded: bool,
43 children: Vec<ChildCall>,
44}
45
46struct ChildCall {
48 id: String,
51 name: String,
52 title: String,
53 status: SubagentCallStatus,
54}
55
56#[derive(Default)]
58struct Roster {
59 name: Option<String>,
62 started: bool,
63 ended: bool,
64 ended_at_ms: Option<u64>,
65 ended_badly: bool,
68 backgrounded: Option<bool>,
70 usage: Option<forge_primitives::messages::TaskUsage>,
71}
72
73#[derive(Default)]
75struct Answer {
76 failed: bool,
77 hand_back: bool,
78}
79
80#[derive(Default)]
82pub struct CardTracker {
83 instances: Vec<Instance>,
84 at: HashMap<String, usize>,
85 roster: HashMap<String, Roster>,
86 task_ids: HashMap<String, String>,
89 answers: HashMap<String, Answer>,
90 made_under: HashMap<String, String>,
94 child_owner: HashMap<String, usize>,
97}
98
99impl CardTracker {
100 pub fn apply(&mut self, message: &Message) {
107 match message {
108 Message::Assistant { message: envelope, parent_tool_use_id, .. } => {
109 let parent = parent_tool_use_id.as_deref().filter(|p| !p.trim().is_empty());
110 for block in &envelope.content {
111 match block {
112 ContentBlock::ToolUse { id, name, input, .. } => {
113 if let Some(parent) = parent {
114 self.made_under.insert(id.clone(), parent.to_owned());
115 }
116 match parent {
117 None if matches!(name.as_str(), "Task" | "Agent") => {
125 self.at.insert(id.clone(), self.instances.len());
126 self.instances.push(Instance {
127 id: id.clone(),
128 dispatch_name: dispatch_name(input, name),
129 agent_type: input
130 .get("subagent_type")
131 .and_then(serde_json::Value::as_str)
132 .map(str::trim)
133 .filter(|value| !value.is_empty())
134 .map(str::to_owned),
135 backgrounded: input
136 .get("run_in_background")
137 .and_then(serde_json::Value::as_bool)
138 .unwrap_or(false),
139 children: Vec::new(),
140 });
141 }
142 Some(parent) => {
143 let slot = self
147 .owning_dispatch(parent)
148 .and_then(|owner| self.at.get(owner).copied());
149 if let Some(slot) = slot {
150 self.child_owner.insert(id.clone(), slot);
151 self.instances[slot]
152 .children
153 .push(child_of(id, name, input));
154 } else {
155 tracing::debug!(
156 event_name = "subagent_call_without_a_card",
157 call = %id,
158 parent = %parent,
159 "a parented call resolves to no card; it stays where the wire put it",
160 );
161 }
162 }
163 None => {}
164 }
165 }
166 ContentBlock::ServerToolResult { tool_use_id, .. } => {
170 self.settle_child(tool_use_id, false);
171 }
172 _ => {}
173 }
174 }
175 }
176 Message::User { message: envelope, .. } => {
177 for block in &envelope.content {
178 let ContentBlock::ToolResult { tool_use_id, content, is_error, .. } = block
179 else {
180 continue;
181 };
182 let hand_back = content_is_hand_back(content);
183 let answer = self.answers.entry(tool_use_id.clone()).or_default();
184 answer.failed |= *is_error;
185 answer.hand_back |= hand_back;
186 self.settle_child(tool_use_id, *is_error);
187 }
188 }
189 Message::TaskStarted { task_id, tool_use_id, description, extras, .. } => {
190 self.link_task(task_id, tool_use_id.as_deref());
191 let row = self.roster.entry(task_id.clone()).or_default();
192 row.started = true;
193 if !description.trim().is_empty() {
194 row.name = Some(description.clone());
195 }
196 if let Some(flag) =
199 extras.get("is_backgrounded").and_then(serde_json::Value::as_bool)
200 {
201 row.backgrounded = Some(flag);
202 }
203 }
204 Message::TaskProgress { task_id, tool_use_id, usage, .. } => {
205 self.link_task(task_id, tool_use_id.as_deref());
206 let row = self.roster.entry(task_id.clone()).or_default();
207 row.started = true;
208 row.usage = Some(usage.clone());
209 }
210 Message::TaskUpdated { task_id, patch, .. } => {
211 let row = self.roster.entry(task_id.clone()).or_default();
212 row.started = true;
213 if let Some(status) = patch.status.as_deref()
214 && is_terminal_status(status)
215 {
216 row.ended = true;
217 row.ended_badly |= is_failure_status(status);
218 }
219 if let Some(end_ms) = patch.end_time {
222 row.ended = true;
223 row.ended_at_ms = Some(end_ms);
224 }
225 }
226 Message::TaskNotification { task_id, status, tool_use_id, usage, .. } => {
227 self.link_task(task_id, tool_use_id.as_deref());
228 let row = self.roster.entry(task_id.clone()).or_default();
229 if !matches!(status, TaskNotificationStatus::Unknown) {
230 row.ended = true;
231 }
232 row.ended_badly |= matches!(
233 status,
234 TaskNotificationStatus::Failed | TaskNotificationStatus::Stopped
235 );
236 if usage.is_some() {
237 row.usage.clone_from(usage);
238 }
239 }
240 _ => {}
241 }
242 }
243
244 pub fn cards(&self) -> Vec<SubagentCard> {
246 self.instances
247 .iter()
248 .filter_map(|instance| {
249 let row = self
250 .task_ids
251 .iter()
252 .find(|(_, id)| id.as_str() == instance.id)
253 .and_then(|(task_id, _)| self.roster.get(task_id));
254 let calls = instance.children.len();
255 let any_child_open = instance.children.iter().any(|child| {
256 matches!(
257 child.status,
258 SubagentCallStatus::Pending | SubagentCallStatus::InProgress
259 )
260 });
261 let answer = self.answers.get(instance.id.as_str());
262 let liveness = match row {
263 Some(row) if row.ended => Liveness::Settled,
264 Some(row) if row.started => Liveness::Running,
265 _ if any_child_open => Liveness::Running,
270 _ if answer.is_none() => Liveness::Running,
271 _ if finished(answer) => Liveness::Settled,
272 _ if calls > 0 => Liveness::Settled,
282 _ => return None,
283 };
284 let tail = instance.children[calls.saturating_sub(TAIL_CAP)..]
285 .iter()
286 .map(|child| SubagentCall {
287 name: child.name.clone(),
288 title: child.title.clone(),
289 status: child.status,
290 })
291 .collect();
292 Some(SubagentCard {
293 name: row
294 .and_then(|row| row.name.clone())
295 .unwrap_or_else(|| instance.dispatch_name.clone()),
296 dispatch_id: instance.id.clone(),
297 agent_type: instance.agent_type.clone(),
298 running: matches!(liveness, Liveness::Running),
299 failed: answer.is_some_and(|answer| answer.failed)
300 || row.is_some_and(|row| row.ended_badly),
301 backgrounded: row
302 .and_then(|row| row.backgrounded)
303 .unwrap_or(instance.backgrounded),
304 ended_at_ms: row.and_then(|row| row.ended_at_ms),
305 calls,
306 tail,
307 usage: row.and_then(|row| row.usage.clone()),
308 })
309 })
310 .collect()
311 }
312
313 pub(crate) fn dispatch_of(&self, task_id: &str) -> Option<&str> {
317 let linked =
318 self.task_ids.get(task_id).map(String::as_str).filter(|id| !id.trim().is_empty())?;
319 self.owning_dispatch(linked)
320 }
321
322 pub(crate) fn owning_dispatch(&self, call: &str) -> Option<&str> {
327 let mut cursor = call;
328 for _ in 0..=self.made_under.len() {
331 if let Some(&slot) = self.at.get(cursor) {
332 return Some(self.instances[slot].id.as_str());
333 }
334 if let Some(&slot) = self.child_owner.get(cursor) {
335 return Some(self.instances[slot].id.as_str());
336 }
337 cursor = self.made_under.get(cursor)?;
338 }
339 None
340 }
341
342 fn link_task(&mut self, task_id: &str, tool_use_id: Option<&str>) {
344 if let Some(id) = tool_use_id.filter(|id| !id.trim().is_empty()) {
345 self.task_ids.entry(task_id.to_owned()).or_insert_with(|| id.to_owned());
346 }
347 }
348
349 fn settle_child(&mut self, tool_use_id: &str, failed: bool) {
352 let status =
353 if failed { SubagentCallStatus::Failed } else { SubagentCallStatus::Completed };
354 let Some(&slot) = self.child_owner.get(tool_use_id) else {
355 return;
356 };
357 if let Some(child) =
358 self.instances[slot].children.iter_mut().find(|child| child.id == tool_use_id)
359 {
360 child.status = status;
361 }
362 }
363}
364
365enum Liveness {
367 Running,
368 Settled,
369}
370
371fn finished(answer: Option<&Answer>) -> bool {
380 let Some(answer) = answer else {
381 return false;
382 };
383 answer.failed || answer.hand_back
384}
385
386fn content_is_hand_back(content: &serde_json::Value) -> bool {
388 const HAND_BACK: &str = "[Subagent hand-back]";
389 match content {
390 serde_json::Value::String(text) => text.contains(HAND_BACK),
391 other => other.to_string().contains(HAND_BACK),
392 }
393}
394
395fn dispatch_name(input: &serde_json::Value, tool: &str) -> String {
399 let read = |key: &str| {
400 input
401 .get(key)
402 .and_then(serde_json::Value::as_str)
403 .map(str::trim)
404 .filter(|value| !value.is_empty())
405 .map(str::to_owned)
406 };
407 read("description").or_else(|| read("subagent_type")).unwrap_or_else(|| tool.to_owned())
408}
409
410fn is_terminal_status(status: &str) -> bool {
414 matches!(status, "completed" | "failed" | "killed" | "stopped" | "timed_out" | "error")
415}
416
417fn is_failure_status(status: &str) -> bool {
420 matches!(status, "failed" | "killed" | "stopped" | "timed_out" | "error")
421}
422
423fn child_of(id: &str, name: &str, input: &serde_json::Value) -> ChildCall {
427 let call = crate::tooling::create_tool_call(id, name, input, None);
428 ChildCall {
429 id: id.to_owned(),
430 name: name.to_owned(),
431 title: call.title,
432 status: SubagentCallStatus::Pending,
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use forge_primitives::runtime::SubagentCard;
439 use forge_primitives::{
440 AssistantEnvelope, ContentBlock, Message, TaskNotificationStatus, UserEnvelope,
441 };
442
443 use super::{CardTracker, TAIL_CAP, subagent_cards};
444
445 fn assistant(content: Vec<ContentBlock>, parent: Option<&str>) -> Message {
448 Message::Assistant {
449 message: AssistantEnvelope {
450 id: "msg_01".to_owned(),
451 role: "assistant".to_owned(),
452 model: "claude-opus-4-5".to_owned(),
453 content,
454 stop_reason: None,
455 stop_sequence: None,
456 usage: None,
457 extras: serde_json::Map::new(),
458 },
459 session_id: "session".to_owned(),
460 parent_tool_use_id: parent.map(str::to_owned),
461 error: None,
462 uuid: None,
463 timestamp: None,
464 extras: serde_json::Map::new(),
465 }
466 }
467
468 fn call(id: &str, name: &str, input: serde_json::Value, parent: Option<&str>) -> Message {
470 assistant(
471 vec![ContentBlock::ToolUse {
472 id: id.to_owned(),
473 name: name.to_owned(),
474 input,
475 extras: serde_json::Map::new(),
476 }],
477 parent,
478 )
479 }
480
481 fn dispatch(id: &str, description: &str, subagent_type: &str) -> Message {
483 call(
484 id,
485 "Task",
486 serde_json::json!({
487 "description": description,
488 "subagent_type": subagent_type,
489 "prompt": "do the thing",
490 }),
491 None,
492 )
493 }
494
495 fn result(id: &str) -> Message {
497 answer(id, "ok")
498 }
499
500 fn answer(id: &str, text: &str) -> Message {
502 Message::User {
503 message: UserEnvelope {
504 role: "user".to_owned(),
505 content: vec![ContentBlock::ToolResult {
506 tool_use_id: id.to_owned(),
507 content: serde_json::json!([{"type": "text", "text": text}]),
508 is_error: false,
509 extras: serde_json::Map::new(),
510 }],
511 extras: serde_json::Map::new(),
512 },
513 session_id: "session".to_owned(),
514 parent_tool_use_id: None,
515 uuid: None,
516 tool_use_result: None,
517 timestamp: None,
518 synthetic: false,
519 extras: serde_json::Map::new(),
520 }
521 }
522
523 fn launch_ack(id: &str) -> Message {
527 answer(
528 id,
529 "Async agent launched successfully. (This tool result is internal metadata, never \
530 quote or paste any part of it, including the agentId below.)\nagentId: a5f83c2a9b88e4db5",
531 )
532 }
533
534 fn hand_back(id: &str) -> Message {
536 answer(
537 id,
538 "[Subagent hand-back] The text below is the final report of a subagent this session \
539 delegated to.",
540 )
541 }
542
543 fn task_started(task_id: &str, tool_use_id: &str, description: &str) -> Message {
546 Message::TaskStarted {
547 task_id: task_id.to_owned(),
548 description: description.to_owned(),
549 uuid: "u-start".to_owned(),
550 session_id: "session".to_owned(),
551 tool_use_id: Some(tool_use_id.to_owned()),
552 task_type: Some("local_agent".to_owned()),
553 extras: serde_json::Map::new(),
554 }
555 }
556
557 fn task_ended(task_id: &str, status: &str, end_ms: Option<u64>) -> Message {
559 Message::TaskUpdated {
560 task_id: task_id.to_owned(),
561 patch: forge_primitives::messages::TaskUpdatePatch {
562 status: Some(status.to_owned()),
563 end_time: end_ms,
564 extras: serde_json::Map::new(),
565 },
566 uuid: "u-upd".to_owned(),
567 session_id: "session".to_owned(),
568 extras: serde_json::Map::new(),
569 }
570 }
571
572 fn task_progress(task_id: &str, tool_use_id: &str) -> Message {
575 Message::TaskProgress {
576 task_id: task_id.to_owned(),
577 description: "Running something".to_owned(),
578 usage: forge_primitives::messages::TaskUsage {
579 total_tokens: 9714,
580 tool_uses: 1,
581 duration_ms: 2716,
582 extras: serde_json::Map::new(),
583 },
584 uuid: "u-prog".to_owned(),
585 session_id: "session".to_owned(),
586 tool_use_id: Some(tool_use_id.to_owned()),
587 last_tool_name: None,
588 workflow_progress: Vec::new(),
589 extras: serde_json::Map::new(),
590 }
591 }
592
593 fn task_notified(task_id: &str, tool_use_id: &str, status: &str) -> Message {
595 let status = match status {
596 "completed" => TaskNotificationStatus::Completed,
597 "failed" => TaskNotificationStatus::Failed,
598 "stopped" => TaskNotificationStatus::Stopped,
599 _ => TaskNotificationStatus::Unknown,
600 };
601 Message::TaskNotification {
602 task_id: task_id.to_owned(),
603 status,
604 output_file: "/tmp/sub.output".to_owned(),
605 summary: "done".to_owned(),
606 uuid: "u-note".to_owned(),
607 session_id: "session".to_owned(),
608 tool_use_id: Some(tool_use_id.to_owned()),
609 usage: None,
610 extras: serde_json::Map::new(),
611 }
612 }
613
614 fn background_dispatch(id: &str, description: &str, subagent_type: &str) -> Message {
616 call(
617 id,
618 "Task",
619 serde_json::json!({
620 "description": description,
621 "subagent_type": subagent_type,
622 "run_in_background": true,
623 "prompt": "do the thing",
624 }),
625 None,
626 )
627 }
628
629 fn task_started_flagging(
632 task_id: &str,
633 tool_use_id: &str,
634 description: &str,
635 is_backgrounded: bool,
636 ) -> Message {
637 let mut started = task_started(task_id, tool_use_id, description);
638 if let Message::TaskStarted { extras, .. } = &mut started {
639 extras.insert("is_backgrounded".to_owned(), serde_json::Value::Bool(is_backgrounded));
640 }
641 started
642 }
643
644 fn errored_result(id: &str) -> Message {
646 let mut answered = result(id);
647 if let Message::User { message, .. } = &mut answered
648 && let Some(ContentBlock::ToolResult { is_error, .. }) = message.content.first_mut()
649 {
650 *is_error = true;
651 }
652 answered
653 }
654
655 fn names(cards: &[SubagentCard]) -> Vec<&str> {
656 cards.iter().map(|card| card.name.as_str()).collect()
657 }
658
659 #[test]
663 fn each_dispatch_is_its_own_card_with_its_own_calls() {
664 let messages = [
665 dispatch("toolu_a", "cli-version", "Explore"),
666 dispatch("toolu_b", "web-session-review", "Explore"),
667 call(
668 "toolu_a1",
669 "Read",
670 serde_json::json!({"file_path": "src/lib.rs"}),
671 Some("toolu_a"),
672 ),
673 call("toolu_a2", "Bash", serde_json::json!({"command": "just check"}), Some("toolu_a")),
674 call("toolu_b1", "Bash", serde_json::json!({"command": "cargo test"}), Some("toolu_b")),
675 ];
676
677 let cards = subagent_cards(&messages);
678
679 assert_eq!(names(&cards), ["cli-version", "web-session-review"], "one card per dispatch");
680 assert_eq!(
681 (cards[0].dispatch_id.as_str(), cards[1].dispatch_id.as_str()),
682 ("toolu_a", "toolu_b"),
683 "each card names the dispatch that opened it, which is how a view joins it to the row",
684 );
685 assert_eq!(cards[0].calls, 2, "the first instance ran two calls");
686 assert_eq!(
687 cards[0].tail.iter().map(|call| call.title.as_str()).collect::<Vec<_>>(),
688 ["Read src/lib.rs", "just check"],
689 "and its tail names what each call touched",
690 );
691 assert_eq!(cards[1].calls, 1, "the second instance ran one");
692 }
693
694 #[test]
697 fn the_tail_caps_at_the_last_four_calls() {
698 let mut messages = vec![dispatch("toolu_a", "cli-version", "Explore")];
699 for n in 0..6 {
700 messages.push(call(
701 &format!("toolu_a{n}"),
702 "Read",
703 serde_json::json!({"file_path": format!("src/{n}.rs")}),
704 Some("toolu_a"),
705 ));
706 }
707
708 let cards = subagent_cards(&messages);
709
710 assert_eq!(cards[0].calls, 6, "every call counts");
711 assert_eq!(
712 cards[0].tail.iter().map(|call| call.title.as_str()).collect::<Vec<_>>(),
713 ["Read src/2.rs", "Read src/3.rs", "Read src/4.rs", "Read src/5.rs"],
714 "and the tail is the most recent {TAIL_CAP}",
715 );
716 }
717
718 #[test]
720 fn a_call_no_instance_dispatched_is_not_a_card() {
721 let messages = [
722 call("toolu_main", "Read", serde_json::json!({"file_path": "src/lib.rs"}), None),
723 call(
724 "toolu_orphan",
725 "Read",
726 serde_json::json!({"file_path": "src/main.rs"}),
727 Some("toolu_absent"),
728 ),
729 ];
730
731 assert!(subagent_cards(&messages).is_empty());
732 }
733
734 #[test]
741 fn a_folded_sidecar_with_every_call_settled_is_a_card() {
742 let folded = [
743 dispatch("toolu_a", "map it", "Explore"),
744 result("toolu_a"),
745 call(
746 "toolu_a1",
747 "Read",
748 serde_json::json!({"file_path": "src/lib.rs"}),
749 Some("toolu_a"),
750 ),
751 result("toolu_a1"),
752 ];
753
754 let cards = subagent_cards(&folded);
755
756 assert_eq!(cards.len(), 1, "the folded instance is drawn");
757 assert!(!cards[0].running, "every call settled is the completion evidence");
758 assert_eq!(cards[0].calls, 1, "and the card carries the call");
759 assert!(
760 subagent_cards(&[dispatch("toolu_b", "empty", "Explore"), result("toolu_b")])
761 .is_empty(),
762 "a dispatch with no frames at all is not drawn",
763 );
764 }
765
766 #[test]
770 fn the_roster_decides_liveness_and_names_the_card() {
771 let running = [
772 dispatch("toolu_a", "", "Explore"),
773 result("toolu_a"),
774 task_started("t-a", "toolu_a", "cli-version"),
775 ];
776 let cards = subagent_cards(&running);
777 assert_eq!(names(&cards), ["cli-version"], "the roster names the instance");
778 assert!(cards[0].running, "an opened task with no terminal frame is still running");
779
780 let ended = [
781 dispatch("toolu_a", "cli-version", "Explore"),
782 result("toolu_a"),
783 task_started("t-a", "toolu_a", "cli-version"),
784 task_ended("t-a", "completed", Some(1_700_000_000_123)),
785 ];
786 let cards = subagent_cards(&ended);
787 assert!(!cards[0].running, "a terminal frame ends the instance");
788 assert_eq!(cards[0].ended_at_ms, Some(1_700_000_000_123));
789 }
790
791 #[test]
796 fn a_progress_report_links_a_task_the_page_never_saw_start() {
797 let messages = [
798 dispatch("toolu_a", "cli-version", "Explore"),
799 task_progress("t-a", "toolu_a"),
800 task_ended("t-a", "completed", None),
801 ];
802
803 let cards = subagent_cards(&messages);
804
805 assert_eq!(names(&cards), ["cli-version"], "the report links the task to its dispatch");
806 assert!(!cards[0].running, "and the close that follows ends the card");
807 }
808
809 #[test]
812 fn a_notification_ends_an_instance_nothing_else_closed() {
813 let messages = [
814 dispatch("toolu_a", "cli-version", "Explore"),
815 task_notified("t-a", "toolu_a", "completed"),
816 ];
817
818 let cards = subagent_cards(&messages);
819
820 assert_eq!(names(&cards), ["cli-version"], "the notification names the instance");
821 assert!(!cards[0].running, "and is what ends it");
822 }
823
824 #[test]
827 fn an_instance_with_an_open_call_is_running() {
828 let messages = [
829 dispatch("toolu_a", "cli-version", "Explore"),
830 launch_ack("toolu_a"),
831 call("toolu_a1", "Bash", serde_json::json!({"command": "just check"}), Some("toolu_a")),
832 ];
833
834 assert!(subagent_cards(&messages)[0].running, "a call that has not come back is work");
835 }
836
837 #[test]
839 fn a_dispatch_answered_only_by_its_launch_ack_is_not_drawn() {
840 let messages = [
841 dispatch("toolu_a", "cli-version", "Explore"),
842 launch_ack("toolu_a"),
843 dispatch("toolu_b", "web-session-review", "Explore"),
844 launch_ack("toolu_b"),
845 ];
846
847 assert!(subagent_cards(&messages).is_empty(), "a launch ack is not an ending");
848 }
849
850 #[test]
853 fn a_dispatch_answered_by_the_hand_back_is_settled() {
854 let cards =
855 subagent_cards(&[dispatch("toolu_a", "cli-version", "Explore"), hand_back("toolu_a")]);
856
857 assert_eq!(names(&cards), ["cli-version"]);
858 assert!(!cards[0].running);
859 }
860
861 #[test]
864 fn an_errored_answer_settles_the_card_as_failed() {
865 let cards = subagent_cards(&[
866 dispatch("toolu_a", "cli-version", "Explore"),
867 errored_result("toolu_a"),
868 ]);
869
870 assert_eq!(names(&cards), ["cli-version"], "the errored answer still names it");
871 assert!(!cards[0].running, "an errored answer is an ending");
872 assert!(cards[0].failed, "and the card carries its cross");
873 }
874
875 #[test]
878 fn the_backgrounded_flag_prefers_the_rosters_word() {
879 let asked = subagent_cards(&[background_dispatch("toolu_a", "cli-version", "Explore")]);
880 assert!(asked[0].backgrounded, "the dispatch's own intent is the fallback");
881
882 let roster_says_no = subagent_cards(&[
883 background_dispatch("toolu_a", "cli-version", "Explore"),
884 task_started_flagging("t-a", "toolu_a", "cli-version", false),
885 ]);
886 assert!(
887 !roster_says_no[0].backgrounded,
888 "the roster's word supersedes the dispatch's, either way"
889 );
890 }
891
892 #[test]
894 fn a_dispatch_a_subagent_made_is_a_call_and_not_a_card() {
895 let messages = [
896 dispatch("toolu_a", "cli-version", "Explore"),
897 call("toolu_a9", "Task", serde_json::json!({"description": "inner"}), Some("toolu_a")),
898 call(
899 "toolu_inner1",
900 "Read",
901 serde_json::json!({"file_path": "src/inner.rs"}),
902 Some("toolu_a9"),
903 ),
904 ];
905
906 let cards = subagent_cards(&messages);
907
908 assert_eq!(names(&cards), ["cli-version"], "one card, for the session's own dispatch");
909 assert_eq!(
910 cards[0].tail.len(),
911 2,
912 "the inner dispatch and the call under it are that card's own calls",
913 );
914 }
915
916 #[test]
918 fn a_dispatch_with_no_result_yet_is_running() {
919 assert!(subagent_cards(&[dispatch("toolu_a", "cli-version", "Explore")])[0].running);
920 }
921
922 #[test]
930 fn a_dispatch_three_levels_down_resolves_to_the_card_that_owns_it() {
931 let messages = [
932 dispatch("toolu_a", "outer", "Explore"),
933 call(
934 "toolu_inner",
935 "Agent",
936 serde_json::json!({"description": "inner"}),
937 Some("toolu_a"),
938 ),
939 call(
940 "toolu_deeper",
941 "Agent",
942 serde_json::json!({"description": "deeper"}),
943 Some("toolu_inner"),
944 ),
945 task_started("t-a", "toolu_a", "outer"),
946 task_started("t-inner", "toolu_inner", "inner"),
947 task_started("t-deeper", "toolu_deeper", "deeper"),
948 ];
949 let mut tracker = CardTracker::default();
950 for message in &messages {
951 tracker.apply(message);
952 }
953
954 assert_eq!(
955 tracker.dispatch_of("t-a"),
956 Some("toolu_a"),
957 "the session's own dispatch is its card",
958 );
959 assert_eq!(
960 tracker.dispatch_of("t-inner"),
961 Some("toolu_a"),
962 "a nested dispatch is the child line of the card that fired it",
963 );
964 assert_eq!(
965 tracker.dispatch_of("t-deeper"),
966 Some("toolu_a"),
967 "and the hop past that child line still reaches the same card",
968 );
969 assert_eq!(
973 tracker.instances[0].children.iter().map(|child| child.id.as_str()).collect::<Vec<_>>(),
974 vec!["toolu_inner", "toolu_deeper"],
975 "both deeper calls belong to the card's own call list",
976 );
977 let result = serde_json::json!({
979 "type": "user",
980 "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_deeper", "content": "done"}]},
981 "uuid": "u-deep",
982 "session_id": "s",
983 });
984 tracker.apply(&serde_json::from_value(result).expect("a result frame"));
985 let settled = tracker.instances[0]
986 .children
987 .iter()
988 .find(|child| child.id == "toolu_deeper")
989 .map(|child| child.status);
990 assert_eq!(
991 settled,
992 Some(forge_primitives::runtime::SubagentCallStatus::Completed),
993 "the third-level call settles on its own result",
994 );
995 let mut early = CardTracker::default();
998 early.apply(&dispatch("toolu_a", "outer", "Explore"));
999 early.apply(&call("toolu_deep", "Bash", serde_json::json!({}), Some("toolu_inner")));
1000 early.apply(&call("toolu_inner", "Agent", serde_json::json!({}), Some("toolu_a")));
1001 early.apply(&task_started("t-deep", "toolu_deep", "deep"));
1002 assert_eq!(
1003 early.dispatch_of("t-deep"),
1004 Some("toolu_a"),
1005 "the parent link alone reaches the card when the child line arrived late",
1006 );
1007
1008 let mut orphan = CardTracker::default();
1012 orphan.apply(&call("toolu_plain", "Read", serde_json::json!({}), None));
1013 orphan.apply(&call("toolu_unowned", "Agent", serde_json::json!({}), Some("toolu_plain")));
1014 orphan.apply(&task_started("t-unowned", "toolu_unowned", "unowned"));
1015 assert_eq!(orphan.dispatch_of("t-unowned"), None);
1016 }
1017
1018 #[test]
1021 fn the_usage_the_task_reported_rides_the_card() {
1022 let progress = Message::TaskProgress {
1023 task_id: "t-a".to_owned(),
1024 description: "Running Bash".to_owned(),
1025 usage: forge_primitives::messages::TaskUsage {
1026 total_tokens: 12_047,
1027 tool_uses: 2,
1028 duration_ms: 4_953,
1029 extras: serde_json::Map::new(),
1030 },
1031 uuid: "u-p".to_owned(),
1032 session_id: "session".to_owned(),
1033 tool_use_id: Some("toolu_a".to_owned()),
1034 last_tool_name: Some("Bash".to_owned()),
1035 workflow_progress: Vec::new(),
1036 extras: serde_json::Map::new(),
1037 };
1038 let messages = [
1039 dispatch("toolu_a", "cli-version", "Explore"),
1040 task_started("t-a", "toolu_a", "cli-version"),
1041 progress,
1042 ];
1043
1044 let cards = subagent_cards(&messages);
1045
1046 let usage = cards[0].usage.as_ref().expect("the progress frame's usage");
1047 assert_eq!(usage.total_tokens, 12_047);
1048 assert_eq!(usage.tool_uses, 2);
1049 assert_eq!(usage.duration_ms, 4_953);
1050 assert_eq!(
1051 cards[0].agent_type.as_deref(),
1052 Some("Explore"),
1053 "and the head's own word for what ran rides the card",
1054 );
1055 }
1056
1057 #[test]
1059 fn a_child_settles_on_its_own_result() {
1060 let messages = [
1061 dispatch("toolu_a", "cli-version", "Explore"),
1062 call("toolu_a1", "Bash", serde_json::json!({"command": "just check"}), Some("toolu_a")),
1063 result("toolu_a1"),
1064 ];
1065
1066 let cards = subagent_cards(&messages);
1067
1068 assert_eq!(
1069 cards[0].tail[0].status,
1070 forge_primitives::runtime::SubagentCallStatus::Completed,
1071 "the child's result settles its row",
1072 );
1073 }
1074
1075 #[test]
1078 fn a_bad_ending_marks_the_card_failed() {
1079 let of = |word: &str| {
1080 subagent_cards(&[
1081 dispatch("toolu_a", "cli-version", "Explore"),
1082 task_started("t-a", "toolu_a", "cli-version"),
1083 task_ended("t-a", word, None),
1084 ])
1085 .remove(0)
1086 };
1087
1088 assert!(of("failed").failed, "a failed word marks the card");
1089 assert!(of("killed").failed, "and so does a kill");
1090 assert!(of("stopped").failed, "and a cancel, which reads as a kill");
1091 assert!(!of("completed").failed, "a clean finish does not");
1092
1093 let notified = subagent_cards(&[
1094 dispatch("toolu_a", "cli-version", "Explore"),
1095 task_notified("t-a", "toolu_a", "stopped"),
1096 ]);
1097 assert!(notified[0].failed, "a stopped notification carries the same word");
1098 }
1099
1100 #[test]
1110 fn the_incremental_tracker_folds_interleaved_rosters() {
1111 let messages = [
1112 dispatch("toolu_a", "cli-version", "Explore"),
1113 dispatch("toolu_b", "web-session-review", "Explore"),
1114 task_started("t-a", "toolu_a", "cli-version"),
1115 call(
1116 "toolu_a1",
1117 "Read",
1118 serde_json::json!({"file_path": "src/lib.rs"}),
1119 Some("toolu_a"),
1120 ),
1121 result("toolu_a1"),
1122 task_started("t-b", "toolu_b", "web-session-review"),
1123 call("toolu_b1", "Bash", serde_json::json!({"command": "cargo test"}), Some("toolu_b")),
1124 result("toolu_b"),
1125 task_ended("t-b", "completed", Some(1_700_000_000_000)),
1126 ];
1127
1128 let mut tracker = CardTracker::default();
1129 for message in &messages {
1130 tracker.apply(message);
1131 let _ = tracker.cards();
1132 }
1133
1134 let cards = tracker.cards();
1135 assert_eq!(names(&cards), ["cli-version", "web-session-review"]);
1136 assert!(cards[0].running, "t-a has no terminal frame, so it is still running");
1137 assert!(
1138 !cards[1].running && cards[1].ended_at_ms == Some(1_700_000_000_000),
1139 "t-b's roster frame links, so its ending reaches its card"
1140 );
1141 }
1142}