Skip to main content

agent_works/multi_agent/
runtime.rs

1//! Multi-agent runtime — coordinates sub-agent lifecycle, event bridging, and
2//! cancellation.
3//!
4//! The [`MultiAgentRuntime`] is the central coordinator. It is created once during
5//! builder setup and shared via `Arc` to all 6 multi-agent tools.
6
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9
10use agent_base::{
11    AgentBuilder, AgentResult, AgentRuntime, DenyAllApprovalHandler, Language, RunOutcome,
12    RuntimeEvent, SessionId, StreamClient, Tool, UserEvent,
13};
14use tokio::task::JoinSet;
15use tokio_util::sync::CancellationToken;
16
17use super::config::MultiAgentConfig;
18use super::mailbox::{ChildMailbox, MailboxHub, MailboxResult, MailboxStatus, MailboxTask};
19use super::path::AgentPath;
20use super::registry::{AgentRegistry, AgentStatus};
21
22// ---------------------------------------------------------------------------
23// MultiAgentRuntime
24// ---------------------------------------------------------------------------
25
26/// Coordinates sub-agent lifecycle, event bridging, and cancellation.
27///
28/// Created once during builder setup and shared via `Arc` to all 6 multi-agent
29/// tools. Each tool calls methods on the runtime to spawn, communicate with, or
30/// close sub-agents.
31pub struct MultiAgentRuntime {
32    /// Agent lifecycle registry (spawn/close/query).
33    registry: Mutex<AgentRegistry>,
34
35    /// Inter-agent message hub.
36    mailbox: Arc<MailboxHub>,
37
38    /// Shared LLM client (from parent agent).
39    client: Arc<dyn StreamClient>,
40
41    /// Business tools to register on child agents (NOT the 6 multi-agent tools).
42    business_tools: Vec<Arc<dyn Tool>>,
43
44    /// Channel to the bridge task that emits events on parent's event bus.
45    event_tx: Mutex<Option<tokio::sync::mpsc::UnboundedSender<RuntimeEvent>>>,
46
47    /// Root cancellation token (propagates to all children).
48    root_cancel: CancellationToken,
49
50    /// JoinSet tracking all child agent tasks.
51    join_set: Mutex<JoinSet<()>>,
52
53    /// Per-child cancellation tokens.
54    child_cancels: Mutex<HashMap<AgentPath, CancellationToken>>,
55
56    /// Error recovery strategy (inherited from parent).
57    error_recovery: Option<Arc<dyn agent_base::ToolErrorRecovery>>,
58
59    /// Language preference.
60    language: Language,
61
62    /// Parent session manager — for fork_history (child context inheritance).
63    session_manager: Mutex<Option<Arc<agent_base::engine::SessionManager>>>,
64}
65
66impl MultiAgentRuntime {
67    /// Create a new multi-agent runtime.
68    ///
69    /// This is called internally by the builder. Tools receive an `Arc<Self>`.
70    pub fn new(
71        config: MultiAgentConfig,
72        client: Arc<dyn StreamClient>,
73        business_tools: Vec<Arc<dyn Tool>>,
74        root_cancel: CancellationToken,
75        error_recovery: Option<Arc<dyn agent_base::ToolErrorRecovery>>,
76        language: Language,
77    ) -> Self {
78        Self {
79            registry: Mutex::new(AgentRegistry::new(config)),
80            mailbox: Arc::new(MailboxHub::new()),
81            client,
82            business_tools,
83            event_tx: Mutex::new(None),
84            root_cancel,
85            join_set: Mutex::new(JoinSet::new()),
86            child_cancels: Mutex::new(HashMap::new()),
87            error_recovery,
88            language,
89            session_manager: Mutex::new(None),
90        }
91    }
92
93    /// Set the event sender for bridging child events to parent.
94    ///
95    /// Called by the builder after creating the bridge channel.
96    pub fn set_event_sender(&self, tx: tokio::sync::mpsc::UnboundedSender<RuntimeEvent>) {
97        *self.event_tx.lock().unwrap() = Some(tx);
98    }
99
100    /// Set the parent session manager for fork_history support.
101    ///
102    /// Called by the builder after creating the runtime.
103    pub fn set_session_manager(&self, session_manager: Arc<agent_base::engine::SessionManager>) {
104        *self.session_manager.lock().unwrap() = Some(session_manager);
105    }
106
107    /// Spawn a child agent at the given path with a specific system prompt.
108    ///
109    /// This is called by the `spawn_agent` tool. It:
110    /// 1. Checks spawn limits
111    /// 2. Registers the agent in the registry
112    /// 3. Creates a mailbox
113    /// 4. Builds a child AgentRuntime
114    /// 5. Spawns a tokio task for the child's event loop
115    /// 6. Returns the AgentPath
116    ///
117    /// `parent_messages` optionally provides context from the parent session
118    /// for fork_history support.
119    ///
120    /// # Errors
121    ///
122    /// Returns a string error message if spawning fails (limits exceeded, etc.).
123    pub async fn spawn_child(
124        &self,
125        name: &str,
126        system_prompt: String,
127        depth: i32,
128        tool_count: usize,
129        parent_messages: Vec<agent_base::ChatMessage>,
130    ) -> Result<String, String> {
131        let path = AgentPath::root().join(name);
132
133        // 1. Check limits and register
134        {
135            let mut registry = self.registry.lock().unwrap();
136            registry.can_spawn(depth).map_err(|e| e.to_string())?;
137            registry
138                .register(&path, depth, tool_count)
139                .map_err(|e| e.to_string())?;
140        }
141
142        // 2. Create mailbox
143        let child_mailbox = self
144            .mailbox
145            .register(&path)
146            .ok_or_else(|| "mailbox already exists".to_string())?;
147
148        // 3. Build child AgentRuntime (roll back registry+mailbox on failure)
149        let child_runtime = self.build_child_runtime(system_prompt).await.map_err(|e| {
150            self.registry.lock().unwrap().close(&path);
151            self.mailbox.unregister(&path);
152            format!("failed to build child runtime: {}", e)
153        })?;
154
155        // 4. Create session for child and pre-fill with parent context
156        let session_id = child_runtime.create_session().await;
157        self.prefill_child_session(&child_runtime, &session_id, &parent_messages)
158            .await
159            .map_err(|e| {
160                self.registry.lock().unwrap().close(&path);
161                self.mailbox.unregister(&path);
162                format!("failed to prefill child session: {}", e)
163            })?;
164
165        // 5. Create child cancellation token
166        let child_cancel = self.root_cancel.child_token();
167        {
168            let mut cancels = self.child_cancels.lock().unwrap();
169            cancels.insert(path.clone(), child_cancel.clone());
170        }
171
172        // 6. Spawn child agent event loop
173        let agent_path = path.clone();
174        let mailbox_for_task = self.mailbox.clone();
175        let mailbox_for_close = self.mailbox.clone();
176        let event_tx = self.event_tx.lock().unwrap().clone();
177        let registry_agent_path = path.clone();
178
179        self.join_set.lock().unwrap().spawn(async move {
180            run_child_loop(
181                child_mailbox,
182                child_runtime,
183                session_id,
184                agent_path.clone(),
185                mailbox_for_task,
186                event_tx,
187                child_cancel,
188            )
189            .await;
190
191            // Post close notification when loop exits
192            mailbox_for_close.post_result(MailboxResult {
193                agent_path,
194                status: MailboxStatus::Closed,
195                result: None,
196            });
197        });
198
199        self.registry
200            .lock()
201            .unwrap()
202            .set_status(&registry_agent_path, AgentStatus::Idle);
203
204        Ok(path.to_string())
205    }
206
207    /// Spawn a child agent with fork_history support.
208    ///
209    /// `fork_history`: "none" (default), "all", or a number N for last N turns.
210    /// `parent_session_id`: the parent agent's session ID.
211    pub async fn spawn_child_with_history(
212        &self,
213        name: &str,
214        system_prompt: String,
215        depth: i32,
216        tool_count: usize,
217        fork_history: Option<String>,
218        parent_session_id: &SessionId,
219    ) -> Result<String, String> {
220        let parent_messages = self
221            .resolve_fork_history(fork_history, parent_session_id)
222            .await;
223        self.spawn_child(name, system_prompt, depth, tool_count, parent_messages)
224            .await
225    }
226
227    /// Resolve fork_history parameter into a list of parent ChatMessages.
228    pub(crate) async fn resolve_fork_history(
229        &self,
230        fork_history: Option<String>,
231        parent_session_id: &SessionId,
232    ) -> Vec<agent_base::ChatMessage> {
233        use agent_base::ChatMessage;
234        let mode = match fork_history.as_deref() {
235            None | Some("none") => return vec![],
236            Some(s) => s,
237        };
238
239        let sm = match self.session_manager.lock().unwrap().as_ref() {
240            Some(sm) => sm.clone(),
241            None => {
242                tracing::warn!("fork_history requested but no session_manager set");
243                return vec![];
244            }
245        };
246
247        // Get all messages from parent session
248        let all_messages = match sm.session_or_err(parent_session_id).await {
249            Ok(session) => session.chat_messages().to_vec(),
250            Err(e) => {
251                tracing::warn!(session_id = parent_session_id.id, error = %e, "failed to load parent session for fork_history");
252                return vec![];
253            }
254        };
255
256        if all_messages.is_empty() {
257            return vec![];
258        }
259
260        // Filter out system messages (child has its own system prompt)
261        let non_system: Vec<ChatMessage> = all_messages
262            .into_iter()
263            .filter(|m| !matches!(m, ChatMessage::System { .. }))
264            .collect();
265
266        match mode {
267            "all" => non_system,
268            n_str => {
269                // Parse N: number of recent user/assistant message pairs (turns)
270                let n: usize = match n_str.parse() {
271                    Ok(n) if n > 0 => n,
272                    _ => {
273                        tracing::warn!(
274                            fork_history = n_str,
275                            "invalid fork_history value, treating as 'none'"
276                        );
277                        return vec![];
278                    }
279                };
280
281                // Count turns from the end (each turn = user message followed by response)
282                let mut turns = 0usize;
283                let mut cutoff = non_system.len();
284                for (i, msg) in non_system.iter().enumerate().rev() {
285                    if matches!(msg, ChatMessage::User { .. }) {
286                        turns += 1;
287                        if turns >= n {
288                            cutoff = i;
289                            break;
290                        }
291                    }
292                }
293                non_system[cutoff..].to_vec()
294            }
295        }
296    }
297
298    /// Send a message to a child agent (no execution trigger).
299    ///
300    /// Called by `send_message` tool.
301    pub fn send_message(&self, agent_path: &str, message: String) -> Result<bool, String> {
302        let path = self.parse_path(agent_path)?;
303        Ok(self.mailbox.send_message(&path, message))
304    }
305
306    /// Send a task to a child agent (triggers execution).
307    ///
308    /// Called by `followup_task` tool. Updates status to Running.
309    pub fn send_task(
310        &self,
311        agent_path: &str,
312        task: String,
313        interrupt: bool,
314    ) -> Result<bool, String> {
315        let path = self.parse_path(agent_path)?;
316        if !self.mailbox.contains(&path) {
317            return Err("agent not found".to_string());
318        }
319        let sent = self.mailbox.send_task(&path, task, interrupt);
320        if sent {
321            self.registry
322                .lock()
323                .unwrap()
324                .set_status(&path, AgentStatus::Running);
325        }
326        Ok(sent)
327    }
328
329    /// Wait for a result from any or a specific child agent.
330    ///
331    /// Called by `wait_agent` tool. Blocks until a result arrives or timeout.
332    pub async fn wait_for_result(&self, agent_path: Option<&str>, timeout_ms: u64) -> WaitResult {
333        let filter_path = match agent_path {
334            Some(s) => match AgentPath::parse(s) {
335                Some(p) => Some(p),
336                None => {
337                    return WaitResult {
338                        status: "error".to_string(),
339                        result: Some(format!("invalid agent path: {}", s)),
340                        agent_path: None,
341                        has_more: false,
342                    };
343                }
344            },
345            None => None,
346        };
347
348        let mut seq = self.mailbox.subscribe_seq();
349        let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
350
351        loop {
352            // Check for existing results
353            let result = match &filter_path {
354                Some(path) => self.mailbox.try_recv_result(path),
355                None => self.mailbox.try_recv_any(),
356            };
357
358            if let Some(r) = result {
359                let has_more = self.mailbox.total_pending_results() > 0;
360                let (status_str, result_text) = match r.status {
361                    MailboxStatus::Ok => ("ok".to_string(), r.result),
362                    MailboxStatus::Error => ("error".to_string(), r.result),
363                    MailboxStatus::Closed => ("closed".to_string(), r.result),
364                };
365                return WaitResult {
366                    status: status_str,
367                    result: result_text,
368                    agent_path: Some(r.agent_path.to_string()),
369                    has_more,
370                };
371            }
372
373            // Wait for sequence number change or timeout
374            let now = tokio::time::Instant::now();
375            if now >= deadline {
376                return WaitResult {
377                    status: "timeout".to_string(),
378                    result: None,
379                    agent_path: None,
380                    has_more: false,
381                };
382            }
383
384            let remaining = deadline - now;
385            tokio::select! {
386                _ = seq.changed() => {
387                    // Sequence changed — loop back to check results
388                    continue;
389                }
390                _ = tokio::time::sleep(remaining) => {
391                    return WaitResult {
392                        status: "timeout".to_string(),
393                        result: None,
394                        agent_path: None,
395                        has_more: false,
396                    };
397                }
398            }
399        }
400    }
401
402    /// Close a child agent.
403    ///
404    /// Called by `close_agent` tool. Cancels the child's task, removes from
405    /// registry, and posts a Closed result.
406    pub fn close_agent(&self, agent_path: &str) -> Result<CloseResult, String> {
407        let path = self.parse_path(agent_path)?;
408
409        // Get previous status
410        let previous_status = {
411            let registry = self.registry.lock().unwrap();
412            registry
413                .get(&path)
414                .map(|e| format!("{:?}", e.status).to_lowercase())
415                .unwrap_or_else(|| "unknown".to_string())
416        };
417
418        // Cancel child token
419        {
420            let mut cancels = self.child_cancels.lock().unwrap();
421            if let Some(token) = cancels.remove(&path) {
422                token.cancel();
423            }
424        }
425
426        // Close in registry
427        let existed = { self.registry.lock().unwrap().close(&path).is_some() };
428
429        // Unregister mailbox
430        self.mailbox.unregister(&path);
431
432        Ok(CloseResult {
433            closed: existed,
434            previous_status,
435            message: if existed {
436                "agent closed".to_string()
437            } else {
438                "agent not found".to_string()
439            },
440        })
441    }
442
443    /// List all active sub-agents.
444    ///
445    /// Called by `list_agents` tool.
446    pub fn list_agents(&self) -> Vec<AgentInfo> {
447        let registry = self.registry.lock().unwrap();
448        registry
449            .list()
450            .into_iter()
451            .map(|e| AgentInfo {
452                agent_path: e.path.to_string(),
453                status: format!("{:?}", e.status).to_lowercase(),
454                tool_count: e.tool_count,
455            })
456            .collect()
457    }
458
459    /// Get the mailbox hub (for tools that need it directly).
460    pub fn mailbox(&self) -> &Arc<MailboxHub> {
461        &self.mailbox
462    }
463
464    /// Get reference to the registry.
465    pub fn registry(&self) -> &Mutex<AgentRegistry> {
466        &self.registry
467    }
468
469    /// Cancel all child agents.
470    pub fn cancel_all(&self) {
471        let mut cancels = self.child_cancels.lock().unwrap();
472        for (_, token) in cancels.drain() {
473            token.cancel();
474        }
475    }
476}
477
478impl Drop for MultiAgentRuntime {
479    fn drop(&mut self) {
480        self.cancel_all();
481        // Drain any already-completed join handles to detect panics
482        let mut js = self.join_set.lock().unwrap();
483        while let Some(result) = js.try_join_next() {
484            if let Err(e) = result
485                && e.is_panic()
486            {
487                tracing::error!(
488                    error = %e,
489                    "child agent task panicked"
490                );
491            }
492        }
493    }
494}
495
496impl MultiAgentRuntime {
497    fn parse_path(&self, s: &str) -> Result<AgentPath, String> {
498        AgentPath::parse(s).ok_or_else(|| format!("invalid agent path: '{}'", s))
499    }
500
501    async fn build_child_runtime(&self, system_prompt: String) -> AgentResult<AgentRuntime> {
502        let mut builder = AgentBuilder::new(self.client.clone())
503            .system_prompt(system_prompt)
504            .approval_handler(Arc::new(DenyAllApprovalHandler))
505            .language(self.language.clone());
506
507        // Register business tools (NOT multi-agent tools)
508        for tool in &self.business_tools {
509            builder = builder.register_tool_arc(tool.clone());
510        }
511
512        if let Some(ref recovery) = self.error_recovery {
513            builder = builder.error_recovery(recovery.clone());
514        }
515
516        builder.build()
517    }
518
519    /// Pre-fill a child session with parent conversation context (fork_history).
520    ///
521    /// Skips system messages and tool-call-only assistant messages. Assistant text
522    /// responses and tool results are stored as system messages with labels so the
523    /// child sees the context without confusing role semantics.
524    pub(crate) async fn prefill_child_session(
525        &self,
526        child_runtime: &AgentRuntime,
527        session_id: &SessionId,
528        parent_messages: &[agent_base::ChatMessage],
529    ) -> AgentResult<()> {
530        use agent_base::ChatMessage;
531
532        for msg in parent_messages {
533            match msg {
534                ChatMessage::User { content, .. } => {
535                    child_runtime.add_user_message(session_id, content).await?;
536                }
537                ChatMessage::Assistant {
538                    content: Some(text),
539                    ..
540                } => {
541                    child_runtime
542                        .add_system_message(
543                            session_id,
544                            format!("[Parent assistant response]: {}", text),
545                        )
546                        .await?;
547                }
548                ChatMessage::Assistant { tool_calls, .. } if tool_calls.is_some() => {
549                    // Skip tool-call-only messages — parent's tool decisions
550                    // don't make sense in the child's context.
551                }
552                ChatMessage::Tool {
553                    tool_call_id,
554                    content,
555                } => {
556                    child_runtime
557                        .add_system_message(
558                            session_id,
559                            format!("[Parent tool result ({}): {}]", tool_call_id, content),
560                        )
561                        .await?;
562                }
563                _ => {} // Skip system messages and empty assistant
564            }
565        }
566
567        Ok(())
568    }
569}
570
571// ---------------------------------------------------------------------------
572// Result types
573// ---------------------------------------------------------------------------
574
575/// Result from `wait_for_result()`.
576#[derive(Clone, Debug)]
577pub struct WaitResult {
578    pub status: String,
579    pub result: Option<String>,
580    pub agent_path: Option<String>,
581    pub has_more: bool,
582}
583
584/// Result from `close_agent()`.
585#[derive(Clone, Debug)]
586pub struct CloseResult {
587    pub closed: bool,
588    pub previous_status: String,
589    pub message: String,
590}
591
592/// Agent info for `list_agents()`.
593#[derive(Clone, Debug, serde::Serialize)]
594pub struct AgentInfo {
595    pub agent_path: String,
596    pub status: String,
597    pub tool_count: usize,
598}
599
600// ---------------------------------------------------------------------------
601// Child agent event loop
602// ---------------------------------------------------------------------------
603
604/// Run the child agent's main event loop.
605///
606/// This function runs inside a tokio task spawned by [`MultiAgentRuntime::spawn_child`].
607/// It:
608/// 1. Subscribes to child agent events and bridges them to parent
609/// 2. Listens for tasks from the mailbox
610/// 3. Executes each task via `run_turn`
611/// 4. Posts results back via the mailbox
612async fn run_child_loop(
613    child_mailbox: ChildMailbox,
614    child_runtime: AgentRuntime,
615    session_id: SessionId,
616    agent_path: AgentPath,
617    mailbox: Arc<MailboxHub>,
618    event_tx: Option<tokio::sync::mpsc::UnboundedSender<RuntimeEvent>>,
619    child_cancel: CancellationToken,
620) {
621    let mut task_rx = child_mailbox.task_rx;
622
623    // Spawn event bridging: forward child events to parent as SubAgentEvent
624    if let Some(tx) = event_tx {
625        let mut child_events = child_runtime.subscribe_runtime_events();
626        let bridge_path = agent_path.to_string();
627        let bridge_cancel = child_cancel.clone();
628
629        tokio::spawn(async move {
630            loop {
631                tokio::select! {
632                    _ = bridge_cancel.cancelled() => break,
633                    event = child_events.recv() => {
634                        match event {
635                            Ok(event) => {
636                                if matches!(event, RuntimeEvent::RunFinished { .. } | RuntimeEvent::RunCancelled { .. }) {
637                                    continue;
638                                }
639                                let _ = tx.send(RuntimeEvent::UserEvent {
640                                    session_id: SessionId::new(0),
641                                    event: UserEvent::SubAgentEvent {
642                                        subagent: bridge_path.clone(),
643                                        event: Box::new(event),
644                                    },
645                                    agent_id: None,
646                                    trace_id: None,
647                                });
648                            }
649                            Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
650                                tracing::warn!(
651                                    subagent = %bridge_path,
652                                    lagged = n,
653                                    "child event bridge lagged"
654                                );
655                            }
656                            Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
657                        }
658                    }
659                }
660            }
661        });
662    }
663
664    // Main task loop
665    loop {
666        tokio::select! {
667            _ = child_cancel.cancelled() => {
668                break;
669            }
670            task = task_rx.recv() => {
671                match task {
672                    Some(task) => {
673                        let input = build_child_input(&task);
674                        let result = child_runtime.run_turn_collect(
675                            session_id.clone(),
676                            &input,
677                        ).await;
678
679                        match result {
680                            Ok((_events, outcome)) => {
681                                let summary = summarize_outcome(&outcome);
682                                mailbox.post_result(MailboxResult {
683                                    agent_path: agent_path.clone(),
684                                    status: MailboxStatus::Ok,
685                                    result: Some(summary),
686                                });
687                            }
688                            Err(e) => {
689                                mailbox.post_result(MailboxResult {
690                                    agent_path: agent_path.clone(),
691                                    status: MailboxStatus::Error,
692                                    result: Some(e.to_string()),
693                                });
694                            }
695                        }
696                    }
697                    None => break, // task channel closed
698                }
699            }
700        }
701    }
702}
703
704/// Build the input text for a child agent from a mailbox task.
705fn build_child_input(task: &MailboxTask) -> String {
706    if task.pending_messages.is_empty() {
707        task.task.clone()
708    } else {
709        let mut parts: Vec<String> = Vec::new();
710        for msg in &task.pending_messages {
711            parts.push(format!("[Message]: {}", msg));
712        }
713        parts.push(format!("[Task]: {}", task.task));
714        parts.join("\n\n")
715    }
716}
717
718/// Extract a human-readable summary from a run outcome.
719fn summarize_outcome(outcome: &RunOutcome) -> String {
720    match outcome {
721        RunOutcome::Completed => "task completed".to_string(),
722        RunOutcome::Failed { error } => format!("task failed: {}", error),
723        RunOutcome::MaxTurnsExceeded { turns } => {
724            format!("max turns exceeded ({} turns)", turns)
725        }
726        RunOutcome::Cancelled => "cancelled".to_string(),
727    }
728}
729
730// ---------------------------------------------------------------------------
731// Tests
732// ---------------------------------------------------------------------------
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use agent_base::RunOutcome;
738
739    // ── summarize_outcome ──
740
741    #[test]
742    fn test_summarize_completed() {
743        let s = summarize_outcome(&RunOutcome::Completed);
744        assert_eq!(s, "task completed");
745    }
746
747    #[test]
748    fn test_summarize_failed() {
749        let outcome = RunOutcome::Failed {
750            error: "connection refused".to_string(),
751        };
752        let s = summarize_outcome(&outcome);
753        assert_eq!(s, "task failed: connection refused");
754    }
755
756    #[test]
757    fn test_summarize_max_turns() {
758        let outcome = RunOutcome::MaxTurnsExceeded { turns: 42 };
759        let s = summarize_outcome(&outcome);
760        assert!(s.contains("max turns exceeded"));
761        assert!(s.contains("42"));
762    }
763
764    #[test]
765    fn test_summarize_cancelled() {
766        let s = summarize_outcome(&RunOutcome::Cancelled);
767        assert_eq!(s, "cancelled");
768    }
769
770    // ── build_child_input ──
771
772    #[test]
773    fn test_build_child_input_task_only() {
774        let task = MailboxTask {
775            task: "do work".into(),
776            interrupt: true,
777            pending_messages: vec![],
778        };
779        let out = build_child_input(&task);
780        assert_eq!(out, "do work");
781    }
782
783    #[test]
784    fn test_build_child_input_with_pending_messages() {
785        let task = MailboxTask {
786            task: "do work".into(),
787            interrupt: false,
788            pending_messages: vec!["context 1".into(), "context 2".into()],
789        };
790        let out = build_child_input(&task);
791        assert!(out.contains("[Message]: context 1"));
792        assert!(out.contains("[Message]: context 2"));
793        assert!(out.contains("[Task]: do work"));
794        // Messages come before task
795        let msg_pos = out.find("[Message]:").unwrap();
796        let task_pos = out.find("[Task]:").unwrap();
797        assert!(msg_pos < task_pos, "messages should precede task");
798    }
799
800    #[test]
801    fn test_build_child_input_single_message() {
802        let task = MailboxTask {
803            task: "final task".into(),
804            interrupt: true,
805            pending_messages: vec!["hint".into()],
806        };
807        let out = build_child_input(&task);
808        assert_eq!(out, "[Message]: hint\n\n[Task]: final task");
809    }
810
811    // ── fork_history: resolve_fork_history ──
812
813    /// Mock LLM client for fork_history tests (minimal — never called).
814    #[derive(Clone)]
815    struct NoopLlmClient;
816
817    #[async_trait::async_trait]
818    impl agent_base::LlmClient for NoopLlmClient {
819        async fn chat(
820            &self,
821            _messages: &[agent_base::ChatMessage],
822            _tools: &[serde_json::Value],
823            _reasoning: Option<&agent_base::ReasoningConfig>,
824            _response_format: Option<&agent_base::ResponseFormat>,
825        ) -> agent_base::AgentResult<serde_json::Value> {
826            unimplemented!()
827        }
828
829        async fn chat_stream(
830            &self,
831            _messages: &[agent_base::ChatMessage],
832            _tools: &[serde_json::Value],
833            _reasoning: Option<&agent_base::ReasoningConfig>,
834            _response_format: Option<&agent_base::ResponseFormat>,
835        ) -> agent_base::AgentResult<
836            std::pin::Pin<
837                Box<
838                    dyn futures_core::Stream<
839                            Item = agent_base::AgentResult<agent_base::StreamChunk>,
840                        > + Send,
841                >,
842            >,
843        > {
844            unimplemented!()
845        }
846
847        fn capabilities(&self) -> agent_base::LlmCapabilities {
848            agent_base::LlmCapabilities {
849                supports_streaming: true,
850                supports_tools: false,
851                supports_vision: false,
852                supports_thinking: false,
853                max_context_tokens: None,
854                max_output_tokens: None,
855            }
856        }
857    }
858
859    /// Build a MultiAgentRuntime with a parent runtime that has a populated session.
860    async fn setup_fork_history_test(
861        parent_messages: Vec<agent_base::ChatMessage>,
862    ) -> (Arc<MultiAgentRuntime>, agent_base::SessionId) {
863        use tokio_util::sync::CancellationToken;
864
865        let llm = agent_base::llm::adapt(Arc::new(NoopLlmClient));
866        let parent_runtime = agent_base::AgentBuilder::new(llm)
867            .build()
868            .expect("build parent runtime");
869        let parent_sid = parent_runtime.create_session().await;
870
871        // Push messages directly into the session's chat_messages vector so
872        // we can use proper Assistant/Tool variants (not just System).
873        parent_runtime
874            .with_session_mut(&parent_sid, |session| {
875                session.chat_messages_mut().extend(parent_messages.clone());
876            })
877            .await
878            .unwrap();
879
880        let session_manager = Arc::new(parent_runtime.session_manager().clone());
881
882        let ma_runtime = Arc::new(MultiAgentRuntime::new(
883            MultiAgentConfig::enabled(),
884            agent_base::llm::adapt(Arc::new(NoopLlmClient)),
885            vec![],
886            CancellationToken::new(),
887            None,
888            agent_base::Language::En,
889        ));
890        ma_runtime.set_session_manager(session_manager);
891
892        (ma_runtime, parent_sid)
893    }
894
895    #[tokio::test]
896    async fn resolve_fork_history_none_returns_empty() {
897        let messages = vec![agent_base::ChatMessage::User {
898            content: "hello".into(),
899            images: vec![],
900            ephemeral: false,
901        }];
902        let (ma, parent_sid) = setup_fork_history_test(messages).await;
903
904        // None
905        let result = ma.resolve_fork_history(None, &parent_sid).await;
906        assert!(result.is_empty());
907
908        // Some("none")
909        let result = ma
910            .resolve_fork_history(Some("none".to_string()), &parent_sid)
911            .await;
912        assert!(result.is_empty());
913    }
914
915    #[tokio::test]
916    async fn resolve_fork_history_all_returns_all_non_system() {
917        let messages = vec![
918            agent_base::ChatMessage::User {
919                content: "question 1".into(),
920                images: vec![],
921                ephemeral: false,
922            },
923            agent_base::ChatMessage::Assistant {
924                content: Some("answer 1".into()),
925                reasoning_content: None,
926                tool_calls: None,
927            },
928            agent_base::ChatMessage::User {
929                content: "question 2".into(),
930                images: vec![],
931                ephemeral: false,
932            },
933            agent_base::ChatMessage::Assistant {
934                content: Some("answer 2".into()),
935                reasoning_content: None,
936                tool_calls: None,
937            },
938        ];
939        let (ma, parent_sid) = setup_fork_history_test(messages).await;
940
941        let result = ma
942            .resolve_fork_history(Some("all".to_string()), &parent_sid)
943            .await;
944
945        // Should have 4 messages (2 user + 2 assistant) — system messages are filtered out
946        assert_eq!(result.len(), 4);
947        assert!(matches!(result[0], agent_base::ChatMessage::User { .. }));
948        assert!(matches!(
949            result[1],
950            agent_base::ChatMessage::Assistant { .. }
951        ));
952        assert!(matches!(result[2], agent_base::ChatMessage::User { .. }));
953        assert!(matches!(
954            result[3],
955            agent_base::ChatMessage::Assistant { .. }
956        ));
957    }
958
959    #[tokio::test]
960    async fn resolve_fork_history_n_turns() {
961        // 3 turns: 3 user messages, 3 assistant responses
962        let messages = vec![
963            agent_base::ChatMessage::User {
964                content: "q1".into(),
965                images: vec![],
966                ephemeral: false,
967            },
968            agent_base::ChatMessage::Assistant {
969                content: Some("a1".into()),
970                reasoning_content: None,
971                tool_calls: None,
972            },
973            agent_base::ChatMessage::User {
974                content: "q2".into(),
975                images: vec![],
976                ephemeral: false,
977            },
978            agent_base::ChatMessage::Assistant {
979                content: Some("a2".into()),
980                reasoning_content: None,
981                tool_calls: None,
982            },
983            agent_base::ChatMessage::User {
984                content: "q3".into(),
985                images: vec![],
986                ephemeral: false,
987            },
988            agent_base::ChatMessage::Assistant {
989                content: Some("a3".into()),
990                reasoning_content: None,
991                tool_calls: None,
992            },
993        ];
994        let (ma, parent_sid) = setup_fork_history_test(messages).await;
995
996        // Last 1 turn
997        let result = ma
998            .resolve_fork_history(Some("1".to_string()), &parent_sid)
999            .await;
1000        assert_eq!(result.len(), 2, "1 turn = user q3 + assistant a3");
1001        assert!(matches!(result[0], agent_base::ChatMessage::User { .. }));
1002        assert_eq!(extract_user_content(&result[0]), "q3");
1003
1004        // Last 2 turns
1005        let result = ma
1006            .resolve_fork_history(Some("2".to_string()), &parent_sid)
1007            .await;
1008        assert_eq!(result.len(), 4, "2 turns = q2,a2,q3,a3");
1009    }
1010
1011    #[tokio::test]
1012    async fn resolve_fork_history_invalid_number_treats_as_none() {
1013        let messages = vec![agent_base::ChatMessage::User {
1014            content: "hello".into(),
1015            images: vec![],
1016            ephemeral: false,
1017        }];
1018        let (ma, parent_sid) = setup_fork_history_test(messages).await;
1019
1020        // Invalid number → empty
1021        let result = ma
1022            .resolve_fork_history(Some("not-a-number".to_string()), &parent_sid)
1023            .await;
1024        assert!(result.is_empty());
1025
1026        // Zero → empty
1027        let result = ma
1028            .resolve_fork_history(Some("0".to_string()), &parent_sid)
1029            .await;
1030        assert!(result.is_empty());
1031    }
1032
1033    #[tokio::test]
1034    async fn resolve_fork_history_no_session_manager_returns_empty() {
1035        use tokio_util::sync::CancellationToken;
1036
1037        let ma_runtime = MultiAgentRuntime::new(
1038            MultiAgentConfig::enabled(),
1039            agent_base::llm::adapt(Arc::new(NoopLlmClient)),
1040            vec![],
1041            CancellationToken::new(),
1042            None,
1043            agent_base::Language::En,
1044        );
1045        // session_manager is NOT set
1046
1047        let sid = agent_base::SessionId::new(9999);
1048        let result = ma_runtime
1049            .resolve_fork_history(Some("all".to_string()), &sid)
1050            .await;
1051        assert!(result.is_empty());
1052    }
1053
1054    #[tokio::test]
1055    async fn resolve_fork_history_empty_session_returns_empty() {
1056        let (ma, parent_sid) = setup_fork_history_test(vec![]).await;
1057
1058        let result = ma
1059            .resolve_fork_history(Some("all".to_string()), &parent_sid)
1060            .await;
1061        assert!(result.is_empty());
1062    }
1063
1064    // ── fork_history: prefill_child_session ──
1065
1066    #[tokio::test]
1067    async fn prefill_child_session_user_and_assistant() {
1068        let llm = agent_base::llm::adapt(Arc::new(NoopLlmClient));
1069        let child_runtime = agent_base::AgentBuilder::new(llm)
1070            .build()
1071            .expect("build child runtime");
1072        let child_sid = child_runtime.create_session().await;
1073
1074        let parent_messages = vec![
1075            agent_base::ChatMessage::User {
1076                content: "user question".into(),
1077                images: vec![],
1078                ephemeral: false,
1079            },
1080            agent_base::ChatMessage::Assistant {
1081                content: Some("assistant reply".into()),
1082                reasoning_content: None,
1083                tool_calls: None,
1084            },
1085            agent_base::ChatMessage::Tool {
1086                tool_call_id: "call_123".into(),
1087                content: "tool output".into(),
1088            },
1089        ];
1090
1091        // Create a minimal MultiAgentRuntime just to call prefill_child_session
1092        use tokio_util::sync::CancellationToken;
1093        let ma_runtime = MultiAgentRuntime::new(
1094            MultiAgentConfig::enabled(),
1095            agent_base::llm::adapt(Arc::new(NoopLlmClient)),
1096            vec![],
1097            CancellationToken::new(),
1098            None,
1099            agent_base::Language::En,
1100        );
1101
1102        ma_runtime
1103            .prefill_child_session(&child_runtime, &child_sid, &parent_messages)
1104            .await
1105            .expect("prefill should succeed");
1106
1107        // Verify the child session contains the pre-filled messages
1108        let session = child_runtime
1109            .session(&child_sid)
1110            .await
1111            .expect("session exists");
1112        let msgs = session.chat_messages().to_vec();
1113
1114        // Should have: user msg + system msg (assistant) + system msg (tool)
1115        assert_eq!(msgs.len(), 3);
1116        assert!(matches!(msgs[0], agent_base::ChatMessage::User { .. }));
1117        assert!(matches!(msgs[1], agent_base::ChatMessage::System { .. }));
1118        assert!(matches!(msgs[2], agent_base::ChatMessage::System { .. }));
1119    }
1120
1121    #[tokio::test]
1122    async fn prefill_child_session_tool_call_only_skipped() {
1123        let llm = agent_base::llm::adapt(Arc::new(NoopLlmClient));
1124        let child_runtime = agent_base::AgentBuilder::new(llm)
1125            .build()
1126            .expect("build child runtime");
1127        let child_sid = child_runtime.create_session().await;
1128
1129        // Assistant message with only tool_calls (no text content) should be skipped
1130        let parent_messages = vec![
1131            agent_base::ChatMessage::User {
1132                content: "do something".into(),
1133                images: vec![],
1134                ephemeral: false,
1135            },
1136            agent_base::ChatMessage::Assistant {
1137                content: None, // no text — tool call only
1138                reasoning_content: None,
1139                tool_calls: Some(vec![]),
1140            },
1141        ];
1142
1143        use tokio_util::sync::CancellationToken;
1144        let ma_runtime = MultiAgentRuntime::new(
1145            MultiAgentConfig::enabled(),
1146            agent_base::llm::adapt(Arc::new(NoopLlmClient)),
1147            vec![],
1148            CancellationToken::new(),
1149            None,
1150            agent_base::Language::En,
1151        );
1152
1153        ma_runtime
1154            .prefill_child_session(&child_runtime, &child_sid, &parent_messages)
1155            .await
1156            .expect("prefill should succeed");
1157
1158        let session = child_runtime
1159            .session(&child_sid)
1160            .await
1161            .expect("session exists");
1162        let msgs = session.chat_messages().to_vec();
1163
1164        // Only the user message — tool-call-only assistant should be skipped
1165        assert_eq!(msgs.len(), 1);
1166        assert!(matches!(msgs[0], agent_base::ChatMessage::User { .. }));
1167    }
1168
1169    #[tokio::test]
1170    async fn prefill_child_session_empty_vec_noop() {
1171        let llm = agent_base::llm::adapt(Arc::new(NoopLlmClient));
1172        let child_runtime = agent_base::AgentBuilder::new(llm)
1173            .build()
1174            .expect("build child runtime");
1175        let child_sid = child_runtime.create_session().await;
1176
1177        use tokio_util::sync::CancellationToken;
1178        let ma_runtime = MultiAgentRuntime::new(
1179            MultiAgentConfig::enabled(),
1180            agent_base::llm::adapt(Arc::new(NoopLlmClient)),
1181            vec![],
1182            CancellationToken::new(),
1183            None,
1184            agent_base::Language::En,
1185        );
1186
1187        ma_runtime
1188            .prefill_child_session(&child_runtime, &child_sid, &[])
1189            .await
1190            .expect("prefill should succeed");
1191
1192        let session = child_runtime
1193            .session(&child_sid)
1194            .await
1195            .expect("session exists");
1196        let msgs = session.chat_messages().to_vec();
1197
1198        // System prompt is added but we don't assert exact count — just that no user/injected msgs
1199        assert!(msgs.is_empty() || matches!(msgs[0], agent_base::ChatMessage::System { .. }));
1200    }
1201
1202    fn extract_user_content(msg: &agent_base::ChatMessage) -> &str {
1203        match msg {
1204            agent_base::ChatMessage::User { content, .. } => content.as_str(),
1205            _ => "",
1206        }
1207    }
1208}