Skip to main content

forge_workspace/
subagent_cards.rs

1//! The session's sub-agent instances, folded as the frames arrive.
2//!
3//! One card per `Task`/`Agent` dispatch, joined with the frames that ran
4//! under it. The identity is the wire's own link: a dispatch is an
5//! assistant frame carrying the `Task` tool call, and every frame the
6//! instance produces names it in `parent_tool_use_id`.
7//!
8//! **Two entry points, one fold.** [`subagent_cards`] drives
9//! [`CardTracker`] over a conversation that already exists (a read, a
10//! resumed page); the session task drives the same tracker one frame at a
11//! time and emits when the list moves, so a live session never re-walks
12//! its conversation to push a card.
13
14use std::collections::HashMap;
15
16use forge_primitives::runtime::{SubagentCall, SubagentCallStatus, SubagentCard};
17use forge_primitives::{ContentBlock, Message, TaskNotificationStatus};
18
19/// How many of an instance's calls its card draws.
20pub const TAIL_CAP: usize = 4;
21
22/// Every instance `messages` holds, in dispatch order.
23pub 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
31/// One instance while it is being folded.
32struct Instance {
33    id: String,
34    /// The name its dispatch carried, used when no roster frame names it
35    /// better.
36    dispatch_name: String,
37    /// The agent type the dispatch named, which is the card head's own word
38    /// for what ran.
39    agent_type: Option<String>,
40    /// Whether it runs in the background: the dispatch's own intent, which
41    /// a roster frame later overrides with what the CLI actually did.
42    backgrounded: bool,
43    children: Vec<ChildCall>,
44}
45
46/// One call an instance fired.
47struct ChildCall {
48    /// The `tool_use` id, kept so the result row that answers it can settle
49    /// the child when it arrives.
50    id: String,
51    name: String,
52    title: String,
53    status: SubagentCallStatus,
54}
55
56/// What the CLI's task roster says about one task, keyed by its task id.
57#[derive(Default)]
58struct Roster {
59    /// The name the CLI's own row uses for the instance, which its frame
60    /// states and the dispatch's input only repeats.
61    name: Option<String>,
62    started: bool,
63    ended: bool,
64    ended_at_ms: Option<u64>,
65    /// Whether the word that ended it was a failure - the terminal set
66    /// minus the clean endings.
67    ended_badly: bool,
68    /// The CLI's own backgrounding, when its frame stated one.
69    backgrounded: Option<bool>,
70    usage: Option<forge_primitives::messages::TaskUsage>,
71}
72
73/// What the rows answering one dispatch say about it.
74#[derive(Default)]
75struct Answer {
76    failed: bool,
77    hand_back: bool,
78}
79
80/// The instances as frames arrive, one at a time.
81#[derive(Default)]
82pub struct CardTracker {
83    instances: Vec<Instance>,
84    at: HashMap<String, usize>,
85    roster: HashMap<String, Roster>,
86    /// The task id the CLI assigned to a dispatch's tool call, which is
87    /// what lets a later lifecycle frame find the card it belongs to.
88    task_ids: HashMap<String, String>,
89    answers: HashMap<String, Answer>,
90    /// The call each assistant tool-use id was made under. Only a
91    /// parentless `Task`/`Agent` call opens an instance, so this is the link
92    /// that walks a deeper dispatch up to the card that owns it.
93    made_under: HashMap<String, String>,
94    /// The instance each of its recorded child calls belongs to, so a walk
95    /// that names a child resolves without scanning every instance.
96    child_owner: HashMap<String, usize>,
97}
98
99impl CardTracker {
100    /// Fold one frame in.
101    ///
102    /// Every arm is a per-frame lookup, never a walk of the conversation.
103    /// The caller compares [`Self::cards`] against what it last held to
104    /// decide whether to announce - the list is the object, so a frame
105    /// that changed nothing private pushes nothing.
106    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                                // A dispatch the session itself made. A `Task` a
118                                // sub-agent made is that instance's own call, drawn
119                                // in its tail and not as a card of its own: the
120                                // work the inner agent then does names THAT
121                                // dispatch as its parent, so a refusal there
122                                // resolves to this instance's row through its child
123                                // lines (`dispatch_of`).
124                                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                                    // The parent may itself be a dispatch a
144                                    // deeper agent fired, so resolve the chain
145                                    // to the card the read path would give it.
146                                    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                        // A server-side tool's result arrives inline in the
167                        // assistant frame that made the call, so it settles a
168                        // child here rather than on a user row.
169                        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                // The CLI's own backgrounding rides the frame's unmodelled
197                // fields; the dispatch's intent is only a fallback.
198                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                // An end time is a statement that the task ended, whether or
220                // not the patch also names a status this build knows.
221                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    /// The instances as they stand, in dispatch order.
245    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                    // A sub-agent's dispatch answers with a launch
266                    // acknowledgement and nothing else (see `finished`), so an
267                    // open child and an unanswered dispatch are the two things
268                    // that say it is working.
269                    _ if any_child_open => Liveness::Running,
270                    _ if answer.is_none() => Liveness::Running,
271                    _ if finished(answer) => Liveness::Settled,
272                    // **Every call settled is this instance's completion
273                    // evidence where the launch ack is the only dispatch
274                    // answer.** A sidecar folded into a resumed conversation
275                    // carries each call; an instance still working has its
276                    // last call open. The frame that would name a killed or
277                    // failed end is not in the sidecar, so requiring one
278                    // dropped every resumed card. A dispatch with no frames at
279                    // all is a read of a transcript written before the fold,
280                    // and is not drawn.
281                    _ 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    /// The row a refusal by `task_id` belongs under. The CLI names the agent,
314    /// never the dispatch, so the agent's task id is resolved through the
315    /// chain; a task no instance owns answers `None` and the frame stays put.
316    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    /// The card dispatch that owns `call`: the call itself when it opened a
323    /// card, else the instance whose child line it is, else the call it was
324    /// made under. Only the outermost dispatch is a card, so a depth-3 call
325    /// names a child line rather than a card and needs the extra hop.
326    pub(crate) fn owning_dispatch(&self, call: &str) -> Option<&str> {
327        let mut cursor = call;
328        // Bounded, so a parent link pair naming each other cannot spin: the
329        // links are read from the CLI's frames rather than this module.
330        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    /// Note which task a dispatch's tool call was assigned.
343    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    /// Settle the child call `tool_use_id` answers, when it is one of an
350    /// instance's own calls.
351    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
365/// What a session's own frames say about whether an instance is over.
366enum Liveness {
367    Running,
368    Settled,
369}
370
371/// Whether the rows answering a dispatch say the instance is over.
372///
373/// Usually they say nothing of the kind. The CLI acknowledges a dispatch the
374/// moment it launches, and that acknowledgement is 1066 of the 1077 dispatch
375/// results measured across 220 session transcripts, the rest being 4 real
376/// hand-backs and 7 with no status at all. So the result's existence is not
377/// an ending, and reading it as one draws a finished card, with a check mark
378/// and a settled line, for work that is still running.
379fn finished(answer: Option<&Answer>) -> bool {
380    let Some(answer) = answer else {
381        return false;
382    };
383    answer.failed || answer.hand_back
384}
385
386/// Whether a result's content carries the CLI's hand-back marker.
387fn 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
395/// What the dispatch called this instance. The description is the name the
396/// CLI's roster and its own rows use; the type is what is left when the
397/// dispatch named none.
398fn 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
410/// Whether a wire status word says the task is over. The CLI's vocabulary
411/// is free-form and forge does not classify all of it, so this names the
412/// endings: a status nobody has seen is not evidence that work stopped.
413fn is_terminal_status(status: &str) -> bool {
414    matches!(status, "completed" | "failed" | "killed" | "stopped" | "timed_out" | "error")
415}
416
417/// Whether an ending's word is a failure. `killed` and `stopped` are in:
418/// a cancel is not a clean finish.
419fn is_failure_status(status: &str) -> bool {
420    matches!(status, "failed" | "killed" | "stopped" | "timed_out" | "error")
421}
422
423/// One call an instance fired, as it arrives: pending until its result
424/// lands, named by the shared call builder so a card's tail and the chat's
425/// rows resolve a title the same way.
426fn 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    /// An assistant frame carrying `content`, from the instance named by
446    /// `parent` and from the session itself when it is `None`.
447    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    /// A tool call frame the instance named by `parent` produced.
469    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    /// One `Task` dispatch: the call that opens an instance.
482    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    /// The user frame that answers `id`, which is what settles a call.
496    fn result(id: &str) -> Message {
497        answer(id, "ok")
498    }
499
500    /// One result whose content is `text`, in the block shape the wire uses.
501    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    /// The result the CLI writes when it launches a dispatch. Measured 1066
524    /// of 1077 dispatch results across 220 transcripts, and it is what a
525    /// page opened after the instance ran sees.
526    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    /// The result the CLI writes when a sub-agent reports back.
535    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    /// The CLI's own roster row for a dispatch: the task id it assigned,
544    /// the call that opened it, and what it calls the instance.
545    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    /// The roster saying how the task ended, with the instant it did.
558    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    /// One progress report, the frame a page attaching mid-turn gets for a
573    /// task whose start it missed.
574    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    /// The terminal notification the CLI sends when a task ends.
594    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    /// A dispatch whose own input asked to run in the background.
615    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    /// The roster row carrying the CLI's own backgrounding flag on the
630    /// frame's unmodelled fields, where the wire puts it.
631    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    /// The user frame that answers `id` with an error.
645    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    /// The drawing is one card per INSTANCE: two dispatches of one type are
660    /// two cards, and both instances' calls follow their own dispatch even
661    /// when the two are interleaved live.
662    #[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    /// The tail caps at [`TAIL_CAP`] most recent calls while `calls` counts
695    /// every one.
696    #[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    /// A call nobody dispatched belongs to no instance.
719    #[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    /// **A dispatch whose sidecar folded all of its calls is settled.** The
735    /// sidecar carries the calls and the results that settle them, and its
736    /// dispatch answer is only the launch acknowledgement, so requiring a
737    /// roster ending dropped every card a resumed conversation has. A
738    /// dispatch with no frames at all is a read written before the fold and
739    /// stays undrawn (#1763).
740    #[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    /// The roster is the authority once it has spoken: an opened task with
767    /// no terminal frame is running, and a terminal one carries the instant
768    /// it ended.
769    #[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    /// A progress report is the frame a page attaching mid-turn gets for a
792    /// task whose start it missed, and it is what links that task to the
793    /// dispatch it came from. Without it the close that follows names a
794    /// task nothing can find.
795    #[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    /// The notification is the last frame a task sends, and it is the only
810    /// one that ends a card when no `task_updated` arrives.
811    #[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    /// A call still open under an instance is running work, which is the
825    /// evidence a read has when no roster frame arrived at all.
826    #[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    /// A dispatch the CLI launched and never reported on is not drawn.
838    #[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    /// A dispatch answered by a real report is over, and drawn. It is the
851    /// rarer shape, 4 of the 1077 measured results.
852    #[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    /// A dispatch whose answer errored is over, and over badly: the error
862    /// is the ending the read has when no roster frame says how it went.
863    #[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    /// The CLI's own backgrounding word rides the roster frame's unmodelled
876    /// fields, and the dispatch's own intent is only the fallback.
877    #[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    /// A dispatch a sub-agent made is that instance's own call.
893    #[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    /// A dispatch that never came back is running.
917    #[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    /// **A dispatch three levels down resolves to the card that owns it.**
923    /// Only a parentless `Task`/`Agent` call opens a card; a dispatch a
924    /// sub-agent made is a call inside the instance that fired it, and one
925    /// the depth-2 agent made names that child line as its parent - so its
926    /// own agent's frames named an id no card owns and drew nowhere. The
927    /// parent link on every assistant tool-use id is what walks the chain
928    /// back to the top-level dispatch.
929    #[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        // The live fold and the replay fold must count the same children: the
970        // read stamps a nested row with the card, so the live tracker resolves
971        // its parent the same way rather than dropping the call.
972        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        // A nested call's result settles through the same index.
978        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        // A frame can arrive before the call it names was registered as a
996        // child line; the parent link alone still reaches the card.
997        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        // A chain that never reaches a card resolves to nothing: the refusal
1009        // stays a row at the session's level rather than being stamped with an
1010        // id no expansion takes.
1011        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    /// The usage its last progress or notification frame reported rides the
1019    /// card, which is what the card's note draws.
1020    #[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    /// An inner call's own result settles that row.
1058    #[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    /// A bad ending marks the card: the roster's own word decides, and a
1076    /// kill counts as one.
1077    #[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    /// The tracker folds two instances' frames interleaved: `t-b`'s roster
1101    /// frame links to its dispatch and its ending reaches its card, `t-a`'s
1102    /// has no terminal frame so it is still running, and a `cards()` read in
1103    /// the middle of the stream does not disturb the fold.
1104    ///
1105    /// **This does not compare the tracker against `subagent_cards`.** One
1106    /// implementation backs both, so a pure function over the same state
1107    /// cannot disagree with itself and an equality here could never fail.
1108    /// What it pins is the interleaving itself.
1109    #[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}