1use std::collections::HashMap;
2use std::marker::PhantomData;
3use std::sync::Arc;
4
5use anyhow::Result;
6use async_openai::types::chat::FinishReason;
7use serde_json::Value as JsonValue;
8use tokio::sync::{broadcast, mpsc, oneshot};
9use tokio::task::JoinHandle;
10use tokio_util::sync::CancellationToken;
11
12use crate::chat::message::{FuneraMessage, MsgVariant, Role, TextMessage};
13use crate::chat::session::SessionCmd;
14use crate::env::FuneraEnvWatcher;
15use crate::event_bus::env_state_bus::{EnvStateEvent, TurnHighWayHandle};
16use crate::event_bus::react_bus::{
17 ReactBus, ReactEvent, ToolCallErrorInfo, ToolCallRequest, ToolCallResponse,
18};
19use crate::event_bus::token_bus::{TokenBus, TokenEvent};
20#[cfg(feature = "tool")]
21use crate::event_bus::tool_bus::ToolBus;
22use crate::middleware::{ErrorsEnabled, EventSenderFn, MiddlewareChain, MiddlewareEvent};
23use crate::provider::ChatProvider;
24
25#[cfg(feature = "skill")]
26pub mod skills;
27#[cfg(feature = "tool")]
28pub mod tool;
29#[cfg(feature = "tool")]
30pub mod tool_executor;
31
32pub struct ReActLoopConfig {
37 pub buffer: usize,
39 pub max_iteration: usize,
41 pub env_watcher: FuneraEnvWatcher,
43 #[cfg(feature = "tool")]
45 pub tool_bus: ToolBus,
46 pub env_state_tx: broadcast::Sender<EnvStateEvent>,
48 pub turn_highway_handle: TurnHighWayHandle,
50 pub session_tx: Option<mpsc::UnboundedSender<SessionCmd>>,
52}
53
54impl ReActLoopConfig {
55 pub fn new(
56 buffer: usize,
57 max_iteration: usize,
58 env_watcher: FuneraEnvWatcher,
59 env_state_tx: broadcast::Sender<EnvStateEvent>,
60 turn_highway_handle: TurnHighWayHandle,
61 ) -> Self {
62 Self {
63 buffer,
64 max_iteration,
65 env_watcher,
66 #[cfg(feature = "tool")]
67 tool_bus: {
68 let (tool_bus, _) = ToolBus::new();
69 tool_bus
70 },
71 env_state_tx,
72 turn_highway_handle,
73 session_tx: None,
74 }
75 }
76
77 #[cfg(feature = "tool")]
78 pub fn with_tool_bus(mut self, tool_bus: ToolBus) -> Self {
79 self.tool_bus = tool_bus;
82 self
83 }
84}
85
86pub struct ReActLoop<P: ChatProvider> {
87 session_tx: Option<mpsc::UnboundedSender<SessionCmd>>,
88 max_iteration: usize,
89 env_watcher: FuneraEnvWatcher,
90 #[cfg(feature = "tool")]
91 tool_bus: ToolBus,
92 #[allow(dead_code)]
93 env_state_tx: broadcast::Sender<EnvStateEvent>,
94 turn_highway_handle: TurnHighWayHandle,
95 _phantom: PhantomData<P>,
96}
97
98impl<P: ChatProvider> ReActLoop<P> {
99 pub fn new(
100 max_iteration: usize,
101 session_tx: Option<mpsc::UnboundedSender<SessionCmd>>,
102 env_watcher: FuneraEnvWatcher,
103 env_state_tx: broadcast::Sender<EnvStateEvent>,
104 turn_highway_handle: TurnHighWayHandle,
105 ) -> Self {
106 Self {
107 session_tx,
108 max_iteration,
109 env_watcher,
110 #[cfg(feature = "tool")]
111 tool_bus: {
112 let (tool_bus, _) = ToolBus::new();
113 tool_bus
114 },
115 env_state_tx,
116 turn_highway_handle,
117 _phantom: PhantomData,
118 }
119 }
120
121 #[cfg(feature = "tool")]
122 pub fn with_tool_bus(mut self, tool_bus: ToolBus) -> Self {
123 self.tool_bus = tool_bus;
124 self
125 }
126
127 pub fn from_config(config: ReActLoopConfig) -> Self {
128 Self {
129 session_tx: config.session_tx,
130 max_iteration: config.max_iteration,
131 env_watcher: config.env_watcher,
132 #[cfg(feature = "tool")]
133 tool_bus: config.tool_bus,
134 env_state_tx: config.env_state_tx,
135 turn_highway_handle: config.turn_highway_handle,
136 _phantom: PhantomData,
137 }
138 }
139
140 async fn build_history_from(tx: &mpsc::UnboundedSender<SessionCmd>) -> Vec<JsonValue> {
141 let (respond, rx) = oneshot::channel();
142 let _ = tx.send(SessionCmd::FetchContext { respond });
143 rx.await.unwrap_or_default()
144 }
145
146 pub fn run<E: MiddlewareEvent>(
147 mut self,
148 middleware: Option<Arc<MiddlewareChain<E, ErrorsEnabled>>>,
149 event_sender: Option<EventSenderFn<E>>,
150 ) -> ReActLoopHandle {
151 let token = CancellationToken::new();
152 let token_clone = token.clone();
153
154 let task = tokio::spawn(async move {
155 let mut iteration = 0;
156 let mut env_watcher = self.env_watcher;
157
158 while iteration < self.max_iteration {
159 let client = env_watcher.watch_client();
160 #[cfg(feature = "tool")]
161 let tools_json = env_watcher.watch_tool();
162 #[cfg(not(feature = "tool"))]
163 let tools_json = JsonValue::Array(Vec::new());
164 let model = env_watcher.watch_model();
165 #[cfg(feature = "skill")]
166 let skill_content = env_watcher.watch_skill();
167 #[cfg(not(feature = "skill"))]
168 let skill_content = String::new();
169
170 let history_json = if let Some(ref tx) = self.session_tx {
171 Self::build_history_from(tx).await
172 } else {
173 Vec::new()
174 };
175
176 let (token_tx, react_bus) = self.turn_highway_handle.prepare_turn().await;
177
178 let request_json =
179 P::build_request_json(&model, &history_json, &skill_content, &tools_json);
180
181 react_bus.send(ReactEvent::TurnStart).ok();
182
183 emit_event(&event_sender, E::turn_start());
184
185 let stream = P::create_stream(&client, request_json).await?;
186
187 let mut token_bus = TokenBus::<P::Chunk>::with_sender(token_tx, stream);
188 let (assistant_content, reasoning_content, tool_call_accums, turn_finish_reason) =
189 process_token_stream(&mut token_bus, &react_bus).await?;
190
191 let finish_reason_str = turn_finish_reason.as_ref().map(|r| format!("{:?}", r));
192
193 let mut turn_events: Vec<E> = Vec::new();
194
195 let reasoning = if reasoning_content.is_empty() {
196 None
197 } else {
198 Some(reasoning_content.clone())
199 };
200
201 if !assistant_content.is_empty() {
202 turn_events.push(E::assistant_text(assistant_content.clone(), reasoning));
203 }
204
205 let mut accums_sorted: Vec<_> = tool_call_accums.values().collect();
206 accums_sorted.sort_by_key(|a| a.index);
207 for acc in &accums_sorted {
208 let args: JsonValue =
209 serde_json::from_str(&acc.args).unwrap_or(JsonValue::Null);
210 turn_events.push(E::tool_call_request(
211 acc.call_id.clone().into(),
212 acc.name.clone(),
213 args,
214 ));
215 }
216
217 filter_and_store(turn_events, &middleware, &event_sender, &self.session_tx);
218
219 let (should_continue, tool_results) = handle_turn_finish(
220 turn_finish_reason.as_ref(),
221 &tool_call_accums,
222 &react_bus,
223 #[cfg(feature = "tool")]
224 &self.tool_bus,
225 )
226 .await?;
227
228 #[cfg(feature = "tool")]
229 {
230 let mut result_events: Vec<E> = Vec::new();
231 for r in tool_results {
232 let result = match r.result {
233 Ok(s) => Ok(s),
234 Err(e) => Err(e.into()),
235 };
236 result_events.push(E::tool_response(r.call_id.into(), r.name, result));
237 }
238 filter_and_store(result_events, &middleware, &event_sender, &self.session_tx);
239 }
240
241 emit_event(&event_sender, E::turn_end(finish_reason_str));
242 react_bus.send(ReactEvent::TurnEnd).ok();
243
244 if !should_continue {
245 break;
246 }
247
248 iteration += 1;
249 }
250
251 Ok(())
252 });
253
254 ReActLoopHandle {
255 cancel_token: token_clone,
256 task,
257 }
258 }
259}
260
261struct ToolExecResult {
262 call_id: String,
263 name: String,
264 result: Result<String, String>,
265}
266
267fn emit_event<E: MiddlewareEvent>(sender: &Option<EventSenderFn<E>>, event: E) {
268 if let Some(s) = sender {
269 s(event);
270 }
271}
272
273fn filter_and_store<E: MiddlewareEvent>(
274 events: Vec<E>,
275 middleware: &Option<Arc<MiddlewareChain<E, ErrorsEnabled>>>,
276 event_sender: &Option<EventSenderFn<E>>,
277 session_tx: &Option<mpsc::UnboundedSender<SessionCmd>>,
278) {
279 for event in events {
280 let filtered = if let Some(chain) = middleware {
281 match chain.process(event) {
282 Ok(e) => e,
283 Err(_) => continue,
284 }
285 } else {
286 event
287 };
288 if let Some((role, variant)) = filtered.clone().into_session_message()
289 && let Some(tx) = session_tx
290 {
291 let _ = tx.send(SessionCmd::PushMessages {
292 msgs: vec![FuneraMessage::new(role, variant)],
293 });
294 }
295 if let Some(sender) = event_sender {
296 sender(filtered);
297 }
298 }
299}
300
301async fn process_token_stream<C: crate::provider::StreamChunkExt>(
302 token_bus: &mut TokenBus<C>,
303 react_bus: &ReactBus,
304) -> Result<(
305 String,
306 String,
307 HashMap<usize, ToolCallAccumulator>,
308 Option<FinishReason>,
309)> {
310 let mut assistant_content = String::new();
311 let mut reasoning_content = String::new();
312 let mut tool_call_accums: HashMap<usize, ToolCallAccumulator> = HashMap::new();
313 let mut turn_finish_reason: Option<FinishReason> = None;
314
315 while let Some(result) = token_bus.recv().await {
316 let events = result?;
317 for event in events {
318 match event {
319 TokenEvent::Text(t) => {
320 assistant_content.push_str(&t);
321 let text_msg = FuneraMessage::new(
322 Role::Assistant,
323 MsgVariant::Text(TextMessage {
324 text: t.clone().into(),
325 reasoning_content: None,
326 }),
327 );
328 react_bus.send(ReactEvent::MessageQueued(text_msg)).ok();
329 }
330 TokenEvent::Reasoning(r) => {
331 reasoning_content.push_str(&r);
332 }
333 TokenEvent::ToolDelta {
334 index,
335 call_id,
336 name,
337 args_chunk,
338 } => {
339 let acc =
340 tool_call_accums
341 .entry(index)
342 .or_insert_with(|| ToolCallAccumulator {
343 index,
344 call_id: String::new(),
345 name: String::new(),
346 args: String::new(),
347 });
348 if !call_id.is_empty() {
349 acc.call_id = call_id;
350 }
351 if let Some(n) = name {
352 acc.name = n;
353 }
354 if let Some(chunk) = args_chunk {
355 acc.args.push_str(&chunk);
356 }
357 }
358 TokenEvent::Finish(reason) => {
359 turn_finish_reason = Some(reason);
360 }
361 }
362 }
363 }
364
365 Ok((
366 assistant_content,
367 reasoning_content,
368 tool_call_accums,
369 turn_finish_reason,
370 ))
371}
372
373#[cfg(feature = "tool")]
374async fn handle_turn_finish(
375 finish_reason: Option<&FinishReason>,
376 tool_call_accums: &HashMap<usize, ToolCallAccumulator>,
377 react_bus: &ReactBus,
378 tool_bus: &ToolBus,
379) -> Result<(bool, Vec<ToolExecResult>)> {
380 match finish_reason {
381 None | Some(FinishReason::Stop) => Ok((false, Vec::new())),
382
383 Some(FinishReason::ToolCalls) | Some(FinishReason::Length) => {
384 let mut accums: Vec<_> = tool_call_accums.values().collect();
385 accums.sort_by_key(|a| a.index);
386
387 for acc in &accums {
388 let args: JsonValue = serde_json::from_str(&acc.args).unwrap_or(JsonValue::Null);
389 react_bus
390 .send(ReactEvent::ToolExecRequest(ToolCallRequest {
391 index: acc.index,
392 call_id: acc.call_id.clone(),
393 name: acc.name.clone(),
394 args,
395 }))
396 .ok();
397 }
398
399 use futures::future::join_all;
400 let results: Vec<Result<String, crate::re_act::tool::ToolCallError>> =
401 join_all(accums.iter().map(|acc| {
402 let args: JsonValue =
403 serde_json::from_str(&acc.args).unwrap_or(JsonValue::Null);
404 tool_bus.execute(acc.call_id.clone(), acc.name.clone(), args)
405 }))
406 .await;
407
408 let mut tool_results = Vec::new();
409 for (acc, result) in accums.iter().zip(results) {
410 match result {
411 Ok(response) => {
412 react_bus
413 .send(ReactEvent::ToolExecResponse(Ok(ToolCallResponse {
414 call_id: acc.call_id.clone(),
415 name: acc.name.clone(),
416 result: response.clone(),
417 })))
418 .ok();
419 tool_results.push(ToolExecResult {
420 call_id: acc.call_id.clone(),
421 name: acc.name.clone(),
422 result: Ok(response),
423 });
424 }
425 Err(e) => {
426 react_bus
427 .send(ReactEvent::ToolExecResponse(Err(ToolCallErrorInfo {
428 call_id: acc.call_id.clone(),
429 name: acc.name.clone(),
430 error: e.to_string(),
431 })))
432 .ok();
433 tool_results.push(ToolExecResult {
434 call_id: acc.call_id.clone(),
435 name: acc.name.clone(),
436 result: Err(e.to_string()),
437 });
438 }
439 }
440 }
441
442 Ok((true, tool_results))
443 }
444
445 _ => Ok((false, Vec::new())),
446 }
447}
448
449#[cfg(not(feature = "tool"))]
450async fn handle_turn_finish(
451 finish_reason: Option<&FinishReason>,
452 tool_call_accums: &HashMap<usize, ToolCallAccumulator>,
453 _react_bus: &ReactBus,
454) -> Result<(bool, Vec<ToolExecResult>)> {
455 match finish_reason {
456 Some(FinishReason::ToolCalls) | Some(FinishReason::Length)
457 if !tool_call_accums.is_empty() =>
458 {
459 eprintln!("warn: LLM requested tool calls but 'tool' feature is disabled");
460 Ok((false, Vec::new()))
461 }
462 _ => Ok((false, Vec::new())),
463 }
464}
465
466#[derive(Debug)]
467struct ToolCallAccumulator {
468 index: usize,
469 call_id: String,
470 name: String,
471 args: String,
472}
473
474#[derive(Debug)]
475pub struct ReActLoopHandle {
476 pub cancel_token: CancellationToken,
477 pub task: JoinHandle<Result<()>>,
478}
479
480#[cfg(test)]
481mod tests {
482 use std::collections::HashMap;
483
484 use async_openai::types::chat::FinishReason;
485
486 use crate::event_bus::env_state_bus::TurnHighWayEvent;
487 use crate::event_bus::react_bus::ReactBus;
488 use crate::test_helpers;
489
490 use super::*;
491
492 #[derive(Debug, Clone)]
493 struct TestEvent(String);
494
495 impl MiddlewareEvent for TestEvent {
496 type Error = String;
497
498 fn assistant_text(content: String, _reasoning: Option<String>) -> Self {
499 TestEvent(content)
500 }
501 fn tool_call_request(_call_id: Arc<str>, name: String, _args: JsonValue) -> Self {
502 TestEvent(name)
503 }
504 fn tool_response(
505 _call_id: Arc<str>,
506 _name: String,
507 _result: Result<String, String>,
508 ) -> Self {
509 TestEvent("tool_result".into())
510 }
511 fn turn_start() -> Self {
512 TestEvent("turn_start".into())
513 }
514 fn turn_end(_finish_reason: Option<String>) -> Self {
515 TestEvent("turn_end".into())
516 }
517 fn done() -> Self {
518 TestEvent("done".into())
519 }
520 fn into_session_message(self) -> Option<(Role, MsgVariant)> {
521 Some((
522 Role::Assistant,
523 MsgVariant::Text(TextMessage {
524 text: self.0.into(),
525 reasoning_content: None,
526 }),
527 ))
528 }
529 }
530
531 #[tokio::test]
534 async fn process_stream_empty() {
535 let (tx, _) = tokio::sync::broadcast::channel(50);
536 let stream = test_helpers::mock_empty_stream();
537 let mut token_bus = TokenBus::with_sender(tx, stream);
538 let react_bus = ReactBus::new();
539
540 let (content, reasoning, accums, reason) = process_token_stream(&mut token_bus, &react_bus)
541 .await
542 .unwrap();
543 assert_eq!(content, "");
544 assert_eq!(reasoning, "");
545 assert!(accums.is_empty());
546 assert!(reason.is_none());
547 }
548
549 #[tokio::test]
550 async fn process_stream_text() {
551 let (tx, _) = tokio::sync::broadcast::channel(50);
552 let stream = test_helpers::mock_text_stream(vec!["Hello", " ", "World"]);
553 let mut token_bus = TokenBus::with_sender(tx, stream);
554 let react_bus = ReactBus::new();
555
556 let (content, reasoning, accums, reason) = process_token_stream(&mut token_bus, &react_bus)
557 .await
558 .unwrap();
559 assert_eq!(content, "Hello World");
560 assert_eq!(reasoning, "");
561 assert!(accums.is_empty());
562 assert!(matches!(reason, Some(FinishReason::Stop)));
563 }
564
565 #[tokio::test]
566 async fn process_stream_tool_call() {
567 let (tx, _) = tokio::sync::broadcast::channel(50);
568 let stream = test_helpers::mock_tool_stream("calc", r#"{"n":42}"#, "call_1");
569 let mut token_bus = TokenBus::with_sender(tx, stream);
570 let react_bus = ReactBus::new();
571
572 let (content, reasoning, accums, reason) = process_token_stream(&mut token_bus, &react_bus)
573 .await
574 .unwrap();
575 assert_eq!(content, "");
576 assert_eq!(reasoning, "");
577 assert_eq!(accums.len(), 1);
578 assert!(matches!(reason, Some(FinishReason::ToolCalls)));
579 }
580
581 #[tokio::test]
582 async fn process_stream_error() {
583 let (tx, _) = tokio::sync::broadcast::channel(50);
584 let stream = test_helpers::mock_error_stream();
585 let mut token_bus = TokenBus::with_sender(tx, stream);
586 let react_bus = ReactBus::new();
587
588 let result = process_token_stream(&mut token_bus, &react_bus).await;
589 assert!(result.is_err());
590 }
591
592 #[tokio::test]
593 async fn process_stream_text_and_tool() {
594 let (tx, _) = tokio::sync::broadcast::channel(50);
595 let stream = test_helpers::mock_text_plus_tool_stream(
596 "Thinking...",
597 "calc",
598 r#"{"n":42}"#,
599 "call_1",
600 );
601 let mut token_bus = TokenBus::with_sender(tx, stream);
602 let react_bus = ReactBus::new();
603
604 let (content, reasoning, accums, reason) = process_token_stream(&mut token_bus, &react_bus)
605 .await
606 .unwrap();
607 assert_eq!(content, "Thinking...");
608 assert_eq!(reasoning, "");
609 assert_eq!(accums.len(), 1);
610 assert!(matches!(reason, Some(FinishReason::ToolCalls)));
611 }
612
613 #[cfg(feature = "tool")]
616 #[tokio::test]
617 async fn handle_finish_stop_with_content() {
618 let react_bus = ReactBus::new();
619 let (tool_bus, _rx) = crate::event_bus::tool_bus::ToolBus::new();
620
621 let (should_continue, results) = handle_turn_finish(
622 Some(&FinishReason::Stop),
623 &HashMap::new(),
624 &react_bus,
625 &tool_bus,
626 )
627 .await
628 .unwrap();
629
630 assert!(!should_continue);
631 assert!(results.is_empty());
632 }
633
634 #[cfg(feature = "tool")]
635 #[tokio::test]
636 async fn handle_finish_none() {
637 let react_bus = ReactBus::new();
638 let (tool_bus, _rx) = crate::event_bus::tool_bus::ToolBus::new();
639
640 let (should_continue, results) =
641 handle_turn_finish(None, &HashMap::new(), &react_bus, &tool_bus)
642 .await
643 .unwrap();
644
645 assert!(!should_continue);
646 assert!(results.is_empty());
647 }
648
649 #[cfg(feature = "tool")]
650 #[tokio::test]
651 async fn handle_finish_stop_with_reasoning() {
652 let react_bus = ReactBus::new();
653 let (tool_bus, _rx) = crate::event_bus::tool_bus::ToolBus::new();
654
655 let (should_continue, results) = handle_turn_finish(
656 Some(&FinishReason::Stop),
657 &HashMap::new(),
658 &react_bus,
659 &tool_bus,
660 )
661 .await
662 .unwrap();
663
664 assert!(!should_continue);
665 assert!(results.is_empty());
666 }
667
668 #[test]
671 fn tool_call_accumulator_fields() {
672 let mut acc = ToolCallAccumulator {
673 index: 1,
674 call_id: "call_1".into(),
675 name: "test".into(),
676 args: r#"{"key":"val"}"#.into(),
677 };
678 assert_eq!(acc.index, 1);
679 assert_eq!(acc.call_id, "call_1");
680 assert_eq!(acc.name, "test");
681 assert_eq!(acc.args, r#"{"key":"val"}"#);
682
683 acc.call_id.push_str("_extended");
684 assert_eq!(acc.call_id, "call_1_extended");
685 }
686
687 #[test]
688 fn tool_call_accumulator_default_via_or_insert() {
689 let mut map: HashMap<usize, ToolCallAccumulator> = HashMap::new();
690 let acc = map.entry(5).or_insert_with(|| ToolCallAccumulator {
691 index: 5,
692 call_id: String::new(),
693 name: String::new(),
694 args: String::new(),
695 });
696 assert_eq!(acc.index, 5);
697 assert!(acc.call_id.is_empty());
698 assert!(acc.name.is_empty());
699 assert!(acc.args.is_empty());
700 }
701
702 #[test]
703 fn tool_call_accumulator_multiple_inserts() {
704 let mut map: HashMap<usize, ToolCallAccumulator> = HashMap::new();
705 map.insert(
706 0,
707 ToolCallAccumulator {
708 index: 0,
709 call_id: "a".into(),
710 name: "t1".into(),
711 args: "{}".into(),
712 },
713 );
714 map.insert(
715 1,
716 ToolCallAccumulator {
717 index: 1,
718 call_id: "b".into(),
719 name: "t2".into(),
720 args: "[]".into(),
721 },
722 );
723 assert_eq!(map.len(), 2);
724 }
725
726 #[cfg(feature = "tool")]
729 struct MockProvider;
730
731 #[cfg(feature = "tool")]
732 impl ChatProvider for MockProvider {
733 type Chunk = async_openai::types::chat::CreateChatCompletionStreamResponse;
734
735 fn build_request_json(
736 _model: &str,
737 messages: &[JsonValue],
738 _skill_content: &str,
739 _tools_json: &JsonValue,
740 ) -> JsonValue {
741 serde_json::json!({
742 "model": "mock",
743 "messages": messages,
744 "stream": true,
745 })
746 }
747
748 async fn create_stream(
749 _client: &async_openai::Client<async_openai::config::OpenAIConfig>,
750 _request_json: JsonValue,
751 ) -> Result<
752 async_openai::types::stream::StreamResponse<Self::Chunk>,
753 async_openai::error::OpenAIError,
754 > {
755 Ok(test_helpers::mock_text_stream(vec!["Mock response"]))
756 }
757 }
758
759 #[cfg(feature = "tool")]
760 #[tokio::test]
761 async fn react_loop_mock_provider_completes() {
762 let (env_state_tx, _env_state_rx) = tokio::sync::broadcast::channel(20);
763 let (tool_bus, _exec_rx) = crate::event_bus::tool_bus::ToolBus::new();
764
765 let (loop_tx, mut rx_from_loop) = tokio::sync::mpsc::channel(5);
766 let (tx_to_loop, loop_rx) = tokio::sync::mpsc::channel(5);
767
768 tokio::spawn(async move {
769 let _req = rx_from_loop.recv().await;
770 let (token_tx, _) = tokio::sync::broadcast::channel(50);
771 let react_bus = ReactBus::new();
772 let _ = tx_to_loop
773 .send(TurnHighWayEvent::TurnPrepareResponse {
774 token_tx,
775 react_bus,
776 })
777 .await;
778 });
779
780 let handle = TurnHighWayHandle {
781 turn_high_way_tx: loop_tx,
782 turn_high_way_rx: loop_rx,
783 };
784
785 let session_tx = crate::chat::session::spawn_session_actor();
786 let (_env, _env_watcher) =
787 crate::env::FuneraEnv::new(async_openai::Client::new(), "mock-model");
788 let env_watcher = _env_watcher;
789
790 let loop_instance = ReActLoop::<MockProvider>::new(
791 1,
792 Some(session_tx.clone()),
793 env_watcher,
794 env_state_tx,
795 handle,
796 )
797 .with_tool_bus(tool_bus);
798
799 let loop_handle = loop_instance.run::<TestEvent>(None, None);
800 let msg = FuneraMessage::new(
801 Role::User,
802 MsgVariant::Text(TextMessage {
803 text: "ping".into(),
804 reasoning_content: None,
805 }),
806 );
807 let _ = session_tx.send(crate::chat::session::SessionCmd::PushMessages { msgs: vec![msg] });
808
809 let result = loop_handle.task.await;
810 assert!(result.is_ok(), "loop task should complete successfully");
811 let inner = result.unwrap();
812 assert!(inner.is_ok(), "loop should return Ok(())");
813 }
814}