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(
405 acc.call_id.clone(),
406 acc.name.clone(),
407 args,
408 Some(react_bus.clone()),
409 )
410 }))
411 .await;
412
413 let mut tool_results = Vec::new();
414 for (acc, result) in accums.iter().zip(results) {
415 match result {
416 Ok(response) => {
417 react_bus
418 .send(ReactEvent::ToolExecResponse(Ok(ToolCallResponse {
419 call_id: acc.call_id.clone(),
420 name: acc.name.clone(),
421 result: response.clone(),
422 })))
423 .ok();
424 tool_results.push(ToolExecResult {
425 call_id: acc.call_id.clone(),
426 name: acc.name.clone(),
427 result: Ok(response),
428 });
429 }
430 Err(e) => {
431 react_bus
432 .send(ReactEvent::ToolExecResponse(Err(ToolCallErrorInfo {
433 call_id: acc.call_id.clone(),
434 name: acc.name.clone(),
435 error: e.to_string(),
436 })))
437 .ok();
438 tool_results.push(ToolExecResult {
439 call_id: acc.call_id.clone(),
440 name: acc.name.clone(),
441 result: Err(e.to_string()),
442 });
443 }
444 }
445 }
446
447 Ok((true, tool_results))
448 }
449
450 _ => Ok((false, Vec::new())),
451 }
452}
453
454#[cfg(not(feature = "tool"))]
455async fn handle_turn_finish(
456 finish_reason: Option<&FinishReason>,
457 tool_call_accums: &HashMap<usize, ToolCallAccumulator>,
458 _react_bus: &ReactBus,
459) -> Result<(bool, Vec<ToolExecResult>)> {
460 match finish_reason {
461 Some(FinishReason::ToolCalls) | Some(FinishReason::Length)
462 if !tool_call_accums.is_empty() =>
463 {
464 eprintln!("warn: LLM requested tool calls but 'tool' feature is disabled");
465 Ok((false, Vec::new()))
466 }
467 _ => Ok((false, Vec::new())),
468 }
469}
470
471#[derive(Debug)]
472struct ToolCallAccumulator {
473 index: usize,
474 call_id: String,
475 name: String,
476 args: String,
477}
478
479#[derive(Debug)]
480pub struct ReActLoopHandle {
481 pub cancel_token: CancellationToken,
482 pub task: JoinHandle<Result<()>>,
483}
484
485#[cfg(test)]
486mod tests {
487 use std::collections::HashMap;
488
489 use async_openai::types::chat::FinishReason;
490
491 use crate::event_bus::env_state_bus::TurnHighWayEvent;
492 use crate::event_bus::react_bus::ReactBus;
493 use crate::test_helpers;
494
495 use super::*;
496
497 #[derive(Debug, Clone)]
498 struct TestEvent(String);
499
500 impl MiddlewareEvent for TestEvent {
501 type Error = String;
502
503 fn assistant_text(content: String, _reasoning: Option<String>) -> Self {
504 TestEvent(content)
505 }
506 fn tool_call_request(_call_id: Arc<str>, name: String, _args: JsonValue) -> Self {
507 TestEvent(name)
508 }
509 fn tool_response(
510 _call_id: Arc<str>,
511 _name: String,
512 _result: Result<String, String>,
513 ) -> Self {
514 TestEvent("tool_result".into())
515 }
516 fn turn_start() -> Self {
517 TestEvent("turn_start".into())
518 }
519 fn turn_end(_finish_reason: Option<String>) -> Self {
520 TestEvent("turn_end".into())
521 }
522 fn done() -> Self {
523 TestEvent("done".into())
524 }
525 fn into_session_message(self) -> Option<(Role, MsgVariant)> {
526 Some((
527 Role::Assistant,
528 MsgVariant::Text(TextMessage {
529 text: self.0.into(),
530 reasoning_content: None,
531 }),
532 ))
533 }
534 }
535
536 #[tokio::test]
539 async fn process_stream_empty() {
540 let (tx, _) = tokio::sync::broadcast::channel(50);
541 let stream = test_helpers::mock_empty_stream();
542 let mut token_bus = TokenBus::with_sender(tx, stream);
543 let react_bus = ReactBus::new();
544
545 let (content, reasoning, accums, reason) = process_token_stream(&mut token_bus, &react_bus)
546 .await
547 .unwrap();
548 assert_eq!(content, "");
549 assert_eq!(reasoning, "");
550 assert!(accums.is_empty());
551 assert!(reason.is_none());
552 }
553
554 #[tokio::test]
555 async fn process_stream_text() {
556 let (tx, _) = tokio::sync::broadcast::channel(50);
557 let stream = test_helpers::mock_text_stream(vec!["Hello", " ", "World"]);
558 let mut token_bus = TokenBus::with_sender(tx, stream);
559 let react_bus = ReactBus::new();
560
561 let (content, reasoning, accums, reason) = process_token_stream(&mut token_bus, &react_bus)
562 .await
563 .unwrap();
564 assert_eq!(content, "Hello World");
565 assert_eq!(reasoning, "");
566 assert!(accums.is_empty());
567 assert!(matches!(reason, Some(FinishReason::Stop)));
568 }
569
570 #[tokio::test]
571 async fn process_stream_tool_call() {
572 let (tx, _) = tokio::sync::broadcast::channel(50);
573 let stream = test_helpers::mock_tool_stream("calc", r#"{"n":42}"#, "call_1");
574 let mut token_bus = TokenBus::with_sender(tx, stream);
575 let react_bus = ReactBus::new();
576
577 let (content, reasoning, accums, reason) = process_token_stream(&mut token_bus, &react_bus)
578 .await
579 .unwrap();
580 assert_eq!(content, "");
581 assert_eq!(reasoning, "");
582 assert_eq!(accums.len(), 1);
583 assert!(matches!(reason, Some(FinishReason::ToolCalls)));
584 }
585
586 #[tokio::test]
587 async fn process_stream_error() {
588 let (tx, _) = tokio::sync::broadcast::channel(50);
589 let stream = test_helpers::mock_error_stream();
590 let mut token_bus = TokenBus::with_sender(tx, stream);
591 let react_bus = ReactBus::new();
592
593 let result = process_token_stream(&mut token_bus, &react_bus).await;
594 assert!(result.is_err());
595 }
596
597 #[tokio::test]
598 async fn process_stream_text_and_tool() {
599 let (tx, _) = tokio::sync::broadcast::channel(50);
600 let stream = test_helpers::mock_text_plus_tool_stream(
601 "Thinking...",
602 "calc",
603 r#"{"n":42}"#,
604 "call_1",
605 );
606 let mut token_bus = TokenBus::with_sender(tx, stream);
607 let react_bus = ReactBus::new();
608
609 let (content, reasoning, accums, reason) = process_token_stream(&mut token_bus, &react_bus)
610 .await
611 .unwrap();
612 assert_eq!(content, "Thinking...");
613 assert_eq!(reasoning, "");
614 assert_eq!(accums.len(), 1);
615 assert!(matches!(reason, Some(FinishReason::ToolCalls)));
616 }
617
618 #[cfg(feature = "tool")]
621 #[tokio::test]
622 async fn handle_finish_stop_with_content() {
623 let react_bus = ReactBus::new();
624 let (tool_bus, _rx) = crate::event_bus::tool_bus::ToolBus::new();
625
626 let (should_continue, results) = handle_turn_finish(
627 Some(&FinishReason::Stop),
628 &HashMap::new(),
629 &react_bus,
630 &tool_bus,
631 )
632 .await
633 .unwrap();
634
635 assert!(!should_continue);
636 assert!(results.is_empty());
637 }
638
639 #[cfg(feature = "tool")]
640 #[tokio::test]
641 async fn handle_finish_none() {
642 let react_bus = ReactBus::new();
643 let (tool_bus, _rx) = crate::event_bus::tool_bus::ToolBus::new();
644
645 let (should_continue, results) =
646 handle_turn_finish(None, &HashMap::new(), &react_bus, &tool_bus)
647 .await
648 .unwrap();
649
650 assert!(!should_continue);
651 assert!(results.is_empty());
652 }
653
654 #[cfg(feature = "tool")]
655 #[tokio::test]
656 async fn handle_finish_stop_with_reasoning() {
657 let react_bus = ReactBus::new();
658 let (tool_bus, _rx) = crate::event_bus::tool_bus::ToolBus::new();
659
660 let (should_continue, results) = handle_turn_finish(
661 Some(&FinishReason::Stop),
662 &HashMap::new(),
663 &react_bus,
664 &tool_bus,
665 )
666 .await
667 .unwrap();
668
669 assert!(!should_continue);
670 assert!(results.is_empty());
671 }
672
673 #[test]
676 fn tool_call_accumulator_fields() {
677 let mut acc = ToolCallAccumulator {
678 index: 1,
679 call_id: "call_1".into(),
680 name: "test".into(),
681 args: r#"{"key":"val"}"#.into(),
682 };
683 assert_eq!(acc.index, 1);
684 assert_eq!(acc.call_id, "call_1");
685 assert_eq!(acc.name, "test");
686 assert_eq!(acc.args, r#"{"key":"val"}"#);
687
688 acc.call_id.push_str("_extended");
689 assert_eq!(acc.call_id, "call_1_extended");
690 }
691
692 #[test]
693 fn tool_call_accumulator_default_via_or_insert() {
694 let mut map: HashMap<usize, ToolCallAccumulator> = HashMap::new();
695 let acc = map.entry(5).or_insert_with(|| ToolCallAccumulator {
696 index: 5,
697 call_id: String::new(),
698 name: String::new(),
699 args: String::new(),
700 });
701 assert_eq!(acc.index, 5);
702 assert!(acc.call_id.is_empty());
703 assert!(acc.name.is_empty());
704 assert!(acc.args.is_empty());
705 }
706
707 #[test]
708 fn tool_call_accumulator_multiple_inserts() {
709 let mut map: HashMap<usize, ToolCallAccumulator> = HashMap::new();
710 map.insert(
711 0,
712 ToolCallAccumulator {
713 index: 0,
714 call_id: "a".into(),
715 name: "t1".into(),
716 args: "{}".into(),
717 },
718 );
719 map.insert(
720 1,
721 ToolCallAccumulator {
722 index: 1,
723 call_id: "b".into(),
724 name: "t2".into(),
725 args: "[]".into(),
726 },
727 );
728 assert_eq!(map.len(), 2);
729 }
730
731 #[cfg(feature = "tool")]
734 struct MockProvider;
735
736 #[cfg(feature = "tool")]
737 impl ChatProvider for MockProvider {
738 type Chunk = async_openai::types::chat::CreateChatCompletionStreamResponse;
739
740 fn build_request_json(
741 _model: &str,
742 messages: &[JsonValue],
743 _skill_content: &str,
744 _tools_json: &JsonValue,
745 ) -> JsonValue {
746 serde_json::json!({
747 "model": "mock",
748 "messages": messages,
749 "stream": true,
750 })
751 }
752
753 async fn create_stream(
754 _client: &async_openai::Client<async_openai::config::OpenAIConfig>,
755 _request_json: JsonValue,
756 ) -> Result<
757 async_openai::types::stream::StreamResponse<Self::Chunk>,
758 async_openai::error::OpenAIError,
759 > {
760 Ok(test_helpers::mock_text_stream(vec!["Mock response"]))
761 }
762 }
763
764 #[cfg(feature = "tool")]
765 #[tokio::test]
766 async fn react_loop_mock_provider_completes() {
767 let (env_state_tx, _env_state_rx) = tokio::sync::broadcast::channel(20);
768 let (tool_bus, _exec_rx) = crate::event_bus::tool_bus::ToolBus::new();
769
770 let (loop_tx, mut rx_from_loop) = tokio::sync::mpsc::channel(5);
771 let (tx_to_loop, loop_rx) = tokio::sync::mpsc::channel(5);
772
773 tokio::spawn(async move {
774 let _req = rx_from_loop.recv().await;
775 let (token_tx, _) = tokio::sync::broadcast::channel(50);
776 let react_bus = ReactBus::new();
777 let _ = tx_to_loop
778 .send(TurnHighWayEvent::TurnPrepareResponse {
779 token_tx,
780 react_bus,
781 })
782 .await;
783 });
784
785 let handle = TurnHighWayHandle {
786 turn_high_way_tx: loop_tx,
787 turn_high_way_rx: loop_rx,
788 };
789
790 let session_tx = crate::chat::session::spawn_session_actor();
791 let (_env, _env_watcher) =
792 crate::env::FuneraEnv::new(async_openai::Client::new(), "mock-model");
793 let env_watcher = _env_watcher;
794
795 let loop_instance = ReActLoop::<MockProvider>::new(
796 1,
797 Some(session_tx.clone()),
798 env_watcher,
799 env_state_tx,
800 handle,
801 )
802 .with_tool_bus(tool_bus);
803
804 let loop_handle = loop_instance.run::<TestEvent>(None, None);
805 let msg = FuneraMessage::new(
806 Role::User,
807 MsgVariant::Text(TextMessage {
808 text: "ping".into(),
809 reasoning_content: None,
810 }),
811 );
812 let _ = session_tx.send(crate::chat::session::SessionCmd::PushMessages { msgs: vec![msg] });
813
814 let result = loop_handle.task.await;
815 assert!(result.is_ok(), "loop task should complete successfully");
816 let inner = result.unwrap();
817 assert!(inner.is_ok(), "loop should return Ok(())");
818 }
819}