1use 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
22pub struct MultiAgentRuntime {
32 registry: Mutex<AgentRegistry>,
34
35 mailbox: Arc<MailboxHub>,
37
38 client: Arc<dyn StreamClient>,
40
41 business_tools: Vec<Arc<dyn Tool>>,
43
44 event_tx: Mutex<Option<tokio::sync::mpsc::UnboundedSender<RuntimeEvent>>>,
46
47 root_cancel: CancellationToken,
49
50 join_set: Mutex<JoinSet<()>>,
52
53 child_cancels: Mutex<HashMap<AgentPath, CancellationToken>>,
55
56 error_recovery: Option<Arc<dyn agent_base::ToolErrorRecovery>>,
58
59 language: Language,
61
62 session_manager: Mutex<Option<Arc<agent_base::engine::SessionManager>>>,
64}
65
66impl MultiAgentRuntime {
67 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 pub fn set_event_sender(&self, tx: tokio::sync::mpsc::UnboundedSender<RuntimeEvent>) {
97 *self.event_tx.lock().unwrap() = Some(tx);
98 }
99
100 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 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 {
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 let child_mailbox = self
144 .mailbox
145 .register(&path)
146 .ok_or_else(|| "mailbox already exists".to_string())?;
147
148 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 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 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 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 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(®istry_agent_path, AgentStatus::Idle);
203
204 Ok(path.to_string())
205 }
206
207 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 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 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 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 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 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 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 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 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 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 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 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 pub fn close_agent(&self, agent_path: &str) -> Result<CloseResult, String> {
407 let path = self.parse_path(agent_path)?;
408
409 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 {
420 let mut cancels = self.child_cancels.lock().unwrap();
421 if let Some(token) = cancels.remove(&path) {
422 token.cancel();
423 }
424 }
425
426 let existed = { self.registry.lock().unwrap().close(&path).is_some() };
428
429 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 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 pub fn mailbox(&self) -> &Arc<MailboxHub> {
461 &self.mailbox
462 }
463
464 pub fn registry(&self) -> &Mutex<AgentRegistry> {
466 &self.registry
467 }
468
469 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 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 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 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 }
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 _ => {} }
565 }
566
567 Ok(())
568 }
569}
570
571#[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#[derive(Clone, Debug)]
586pub struct CloseResult {
587 pub closed: bool,
588 pub previous_status: String,
589 pub message: String,
590}
591
592#[derive(Clone, Debug, serde::Serialize)]
594pub struct AgentInfo {
595 pub agent_path: String,
596 pub status: String,
597 pub tool_count: usize,
598}
599
600async 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 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 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, }
699 }
700 }
701 }
702}
703
704fn 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
718fn 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#[cfg(test)]
735mod tests {
736 use super::*;
737 use agent_base::RunOutcome;
738
739 #[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 #[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 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 #[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 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 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 let result = ma.resolve_fork_history(None, &parent_sid).await;
906 assert!(result.is_empty());
907
908 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 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 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 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 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 let result = ma
1022 .resolve_fork_history(Some("not-a-number".to_string()), &parent_sid)
1023 .await;
1024 assert!(result.is_empty());
1025
1026 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 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 #[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 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 let session = child_runtime
1109 .session(&child_sid)
1110 .await
1111 .expect("session exists");
1112 let msgs = session.chat_messages().to_vec();
1113
1114 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 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, 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 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 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}