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::{ReactBus, ReactEvent};
17#[cfg(feature = "tool")]
18use crate::event_bus::react_bus::{ToolCallErrorInfo, ToolCallRequest, ToolCallResponse};
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(not(feature = "tool"))]
230 let _ = tool_results;
231
232 #[cfg(feature = "tool")]
233 {
234 let mut result_events: Vec<E> = Vec::new();
235 for r in tool_results {
236 let result = match r.result {
237 Ok(s) => Ok(s),
238 Err(e) => Err(e.into()),
239 };
240 result_events.push(E::tool_response(r.call_id.into(), r.name, result));
241 }
242 filter_and_store(result_events, &middleware, &event_sender, &self.session_tx);
243 }
244
245 emit_event(&event_sender, E::turn_end(finish_reason_str));
246 react_bus.send(ReactEvent::TurnEnd).ok();
247
248 if !should_continue {
249 break;
250 }
251
252 iteration += 1;
253 }
254
255 Ok(())
256 });
257
258 ReActLoopHandle {
259 cancel_token: token_clone,
260 task,
261 }
262 }
263}
264
265#[cfg_attr(not(feature = "tool"), allow(dead_code))]
266struct ToolExecResult {
267 call_id: String,
268 name: String,
269 result: Result<String, String>,
270}
271
272fn emit_event<E: MiddlewareEvent>(sender: &Option<EventSenderFn<E>>, event: E) {
273 if let Some(s) = sender {
274 s(event);
275 }
276}
277
278fn filter_and_store<E: MiddlewareEvent>(
279 events: Vec<E>,
280 middleware: &Option<Arc<MiddlewareChain<E, ErrorsEnabled>>>,
281 event_sender: &Option<EventSenderFn<E>>,
282 session_tx: &Option<mpsc::UnboundedSender<SessionCmd>>,
283) {
284 for event in events {
285 let filtered = if let Some(chain) = middleware {
286 match chain.process(event) {
287 Ok(e) => e,
288 Err(_) => continue,
289 }
290 } else {
291 event
292 };
293 if let Some((role, variant)) = filtered.clone().into_session_message()
294 && let Some(tx) = session_tx
295 {
296 let _ = tx.send(SessionCmd::PushMessages {
297 msgs: vec![FuneraMessage::new(role, variant)],
298 });
299 }
300 if let Some(sender) = event_sender {
301 sender(filtered);
302 }
303 }
304}
305
306async fn process_token_stream<C: crate::provider::StreamChunkExt>(
307 token_bus: &mut TokenBus<C>,
308 react_bus: &ReactBus,
309) -> Result<(
310 String,
311 String,
312 HashMap<usize, ToolCallAccumulator>,
313 Option<FinishReason>,
314)> {
315 let mut assistant_content = String::new();
316 let mut reasoning_content = String::new();
317 let mut tool_call_accums: HashMap<usize, ToolCallAccumulator> = HashMap::new();
318 let mut turn_finish_reason: Option<FinishReason> = None;
319
320 while let Some(result) = token_bus.recv().await {
321 let events = result?;
322 for event in events {
323 match event {
324 TokenEvent::Text(t) => {
325 assistant_content.push_str(&t);
326 let text_msg = FuneraMessage::new(
327 Role::Assistant,
328 MsgVariant::Text(TextMessage {
329 text: t.clone().into(),
330 reasoning_content: None,
331 }),
332 );
333 react_bus.send(ReactEvent::MessageQueued(text_msg)).ok();
334 }
335 TokenEvent::Reasoning(r) => {
336 reasoning_content.push_str(&r);
337 }
338 TokenEvent::ToolDelta {
339 index,
340 call_id,
341 name,
342 args_chunk,
343 } => {
344 let acc =
345 tool_call_accums
346 .entry(index)
347 .or_insert_with(|| ToolCallAccumulator {
348 index,
349 call_id: String::new(),
350 name: String::new(),
351 args: String::new(),
352 });
353 if !call_id.is_empty() {
354 acc.call_id = call_id;
355 }
356 if let Some(n) = name {
357 acc.name = n;
358 }
359 if let Some(chunk) = args_chunk {
360 acc.args.push_str(&chunk);
361 }
362 }
363 TokenEvent::Finish(reason) => {
364 turn_finish_reason = Some(reason);
365 }
366 }
367 }
368 }
369
370 Ok((
371 assistant_content,
372 reasoning_content,
373 tool_call_accums,
374 turn_finish_reason,
375 ))
376}
377
378#[cfg(feature = "tool")]
379async fn handle_turn_finish(
380 finish_reason: Option<&FinishReason>,
381 tool_call_accums: &HashMap<usize, ToolCallAccumulator>,
382 react_bus: &ReactBus,
383 tool_bus: &ToolBus,
384) -> Result<(bool, Vec<ToolExecResult>)> {
385 match finish_reason {
386 None | Some(FinishReason::Stop) => Ok((false, Vec::new())),
387
388 Some(FinishReason::ToolCalls) | Some(FinishReason::Length) => {
389 let mut accums: Vec<_> = tool_call_accums.values().collect();
390 accums.sort_by_key(|a| a.index);
391
392 for acc in &accums {
393 let args: JsonValue = serde_json::from_str(&acc.args).unwrap_or(JsonValue::Null);
394 react_bus
395 .send(ReactEvent::ToolExecRequest(ToolCallRequest {
396 index: acc.index,
397 call_id: acc.call_id.clone(),
398 name: acc.name.clone(),
399 args,
400 }))
401 .ok();
402 }
403
404 use futures::future::join_all;
405 let results: Vec<Result<String, crate::re_act::tool::ToolCallError>> =
406 join_all(accums.iter().map(|acc| {
407 let args: JsonValue =
408 serde_json::from_str(&acc.args).unwrap_or(JsonValue::Null);
409 tool_bus.execute(
410 acc.call_id.clone(),
411 acc.name.clone(),
412 args,
413 Some(react_bus.clone()),
414 )
415 }))
416 .await;
417
418 let mut tool_results = Vec::new();
419 for (acc, result) in accums.iter().zip(results) {
420 match result {
421 Ok(response) => {
422 react_bus
423 .send(ReactEvent::ToolExecResponse(Ok(ToolCallResponse {
424 call_id: acc.call_id.clone(),
425 name: acc.name.clone(),
426 result: response.clone(),
427 })))
428 .ok();
429 tool_results.push(ToolExecResult {
430 call_id: acc.call_id.clone(),
431 name: acc.name.clone(),
432 result: Ok(response),
433 });
434 }
435 Err(e) => {
436 react_bus
437 .send(ReactEvent::ToolExecResponse(Err(ToolCallErrorInfo {
438 call_id: acc.call_id.clone(),
439 name: acc.name.clone(),
440 error: e.to_string(),
441 })))
442 .ok();
443 tool_results.push(ToolExecResult {
444 call_id: acc.call_id.clone(),
445 name: acc.name.clone(),
446 result: Err(e.to_string()),
447 });
448 }
449 }
450 }
451
452 Ok((true, tool_results))
453 }
454
455 _ => Ok((false, Vec::new())),
456 }
457}
458
459#[cfg(not(feature = "tool"))]
460async fn handle_turn_finish(
461 finish_reason: Option<&FinishReason>,
462 tool_call_accums: &HashMap<usize, ToolCallAccumulator>,
463 _react_bus: &ReactBus,
464) -> Result<(bool, Vec<ToolExecResult>)> {
465 match finish_reason {
466 Some(FinishReason::ToolCalls) | Some(FinishReason::Length)
467 if !tool_call_accums.is_empty() =>
468 {
469 eprintln!("warn: LLM requested tool calls but 'tool' feature is disabled");
470 Ok((false, Vec::new()))
471 }
472 _ => Ok((false, Vec::new())),
473 }
474}
475
476#[derive(Debug)]
477struct ToolCallAccumulator {
478 index: usize,
479 call_id: String,
480 name: String,
481 args: String,
482}
483
484#[derive(Debug)]
485pub struct ReActLoopHandle {
486 pub cancel_token: CancellationToken,
487 pub task: JoinHandle<Result<()>>,
488}
489
490#[cfg(test)]
491mod tests {
492 use std::collections::HashMap;
493
494 use async_openai::types::chat::FinishReason;
495
496 use crate::event_bus::env_state_bus::TurnHighWayEvent;
497 use crate::event_bus::react_bus::ReactBus;
498 use crate::test_helpers;
499
500 use super::*;
501
502 #[derive(Debug, Clone)]
503 struct TestEvent(String);
504
505 impl MiddlewareEvent for TestEvent {
506 type Error = String;
507
508 fn assistant_text(content: String, _reasoning: Option<String>) -> Self {
509 TestEvent(content)
510 }
511 fn tool_call_request(_call_id: Arc<str>, name: String, _args: JsonValue) -> Self {
512 TestEvent(name)
513 }
514 fn tool_response(
515 _call_id: Arc<str>,
516 _name: String,
517 _result: Result<String, String>,
518 ) -> Self {
519 TestEvent("tool_result".into())
520 }
521 fn turn_start() -> Self {
522 TestEvent("turn_start".into())
523 }
524 fn turn_end(_finish_reason: Option<String>) -> Self {
525 TestEvent("turn_end".into())
526 }
527 fn done() -> Self {
528 TestEvent("done".into())
529 }
530 fn into_session_message(self) -> Option<(Role, MsgVariant)> {
531 Some((
532 Role::Assistant,
533 MsgVariant::Text(TextMessage {
534 text: self.0.into(),
535 reasoning_content: None,
536 }),
537 ))
538 }
539 }
540
541 #[tokio::test]
544 async fn process_stream_empty() {
545 let (tx, _) = tokio::sync::broadcast::channel(50);
546 let stream = test_helpers::mock_empty_stream();
547 let mut token_bus = TokenBus::with_sender(tx, stream);
548 let react_bus = ReactBus::new();
549
550 let (content, reasoning, accums, reason) = process_token_stream(&mut token_bus, &react_bus)
551 .await
552 .unwrap();
553 assert_eq!(content, "");
554 assert_eq!(reasoning, "");
555 assert!(accums.is_empty());
556 assert!(reason.is_none());
557 }
558
559 #[tokio::test]
560 async fn process_stream_text() {
561 let (tx, _) = tokio::sync::broadcast::channel(50);
562 let stream = test_helpers::mock_text_stream(vec!["Hello", " ", "World"]);
563 let mut token_bus = TokenBus::with_sender(tx, stream);
564 let react_bus = ReactBus::new();
565
566 let (content, reasoning, accums, reason) = process_token_stream(&mut token_bus, &react_bus)
567 .await
568 .unwrap();
569 assert_eq!(content, "Hello World");
570 assert_eq!(reasoning, "");
571 assert!(accums.is_empty());
572 assert!(matches!(reason, Some(FinishReason::Stop)));
573 }
574
575 #[tokio::test]
576 async fn process_stream_tool_call() {
577 let (tx, _) = tokio::sync::broadcast::channel(50);
578 let stream = test_helpers::mock_tool_stream("calc", r#"{"n":42}"#, "call_1");
579 let mut token_bus = TokenBus::with_sender(tx, stream);
580 let react_bus = ReactBus::new();
581
582 let (content, reasoning, accums, reason) = process_token_stream(&mut token_bus, &react_bus)
583 .await
584 .unwrap();
585 assert_eq!(content, "");
586 assert_eq!(reasoning, "");
587 assert_eq!(accums.len(), 1);
588 assert!(matches!(reason, Some(FinishReason::ToolCalls)));
589 }
590
591 #[tokio::test]
592 async fn process_stream_error() {
593 let (tx, _) = tokio::sync::broadcast::channel(50);
594 let stream = test_helpers::mock_error_stream();
595 let mut token_bus = TokenBus::with_sender(tx, stream);
596 let react_bus = ReactBus::new();
597
598 let result = process_token_stream(&mut token_bus, &react_bus).await;
599 assert!(result.is_err());
600 }
601
602 #[tokio::test]
603 async fn process_stream_text_and_tool() {
604 let (tx, _) = tokio::sync::broadcast::channel(50);
605 let stream = test_helpers::mock_text_plus_tool_stream(
606 "Thinking...",
607 "calc",
608 r#"{"n":42}"#,
609 "call_1",
610 );
611 let mut token_bus = TokenBus::with_sender(tx, stream);
612 let react_bus = ReactBus::new();
613
614 let (content, reasoning, accums, reason) = process_token_stream(&mut token_bus, &react_bus)
615 .await
616 .unwrap();
617 assert_eq!(content, "Thinking...");
618 assert_eq!(reasoning, "");
619 assert_eq!(accums.len(), 1);
620 assert!(matches!(reason, Some(FinishReason::ToolCalls)));
621 }
622
623 #[cfg(feature = "tool")]
626 #[tokio::test]
627 async fn handle_finish_stop_with_content() {
628 let react_bus = ReactBus::new();
629 let (tool_bus, _rx) = crate::event_bus::tool_bus::ToolBus::new();
630
631 let (should_continue, results) = handle_turn_finish(
632 Some(&FinishReason::Stop),
633 &HashMap::new(),
634 &react_bus,
635 &tool_bus,
636 )
637 .await
638 .unwrap();
639
640 assert!(!should_continue);
641 assert!(results.is_empty());
642 }
643
644 #[cfg(feature = "tool")]
645 #[tokio::test]
646 async fn handle_finish_none() {
647 let react_bus = ReactBus::new();
648 let (tool_bus, _rx) = crate::event_bus::tool_bus::ToolBus::new();
649
650 let (should_continue, results) =
651 handle_turn_finish(None, &HashMap::new(), &react_bus, &tool_bus)
652 .await
653 .unwrap();
654
655 assert!(!should_continue);
656 assert!(results.is_empty());
657 }
658
659 #[cfg(feature = "tool")]
660 #[tokio::test]
661 async fn handle_finish_stop_with_reasoning() {
662 let react_bus = ReactBus::new();
663 let (tool_bus, _rx) = crate::event_bus::tool_bus::ToolBus::new();
664
665 let (should_continue, results) = handle_turn_finish(
666 Some(&FinishReason::Stop),
667 &HashMap::new(),
668 &react_bus,
669 &tool_bus,
670 )
671 .await
672 .unwrap();
673
674 assert!(!should_continue);
675 assert!(results.is_empty());
676 }
677
678 #[test]
681 fn tool_call_accumulator_fields() {
682 let mut acc = ToolCallAccumulator {
683 index: 1,
684 call_id: "call_1".into(),
685 name: "test".into(),
686 args: r#"{"key":"val"}"#.into(),
687 };
688 assert_eq!(acc.index, 1);
689 assert_eq!(acc.call_id, "call_1");
690 assert_eq!(acc.name, "test");
691 assert_eq!(acc.args, r#"{"key":"val"}"#);
692
693 acc.call_id.push_str("_extended");
694 assert_eq!(acc.call_id, "call_1_extended");
695 }
696
697 #[test]
698 fn tool_call_accumulator_default_via_or_insert() {
699 let mut map: HashMap<usize, ToolCallAccumulator> = HashMap::new();
700 let acc = map.entry(5).or_insert_with(|| ToolCallAccumulator {
701 index: 5,
702 call_id: String::new(),
703 name: String::new(),
704 args: String::new(),
705 });
706 assert_eq!(acc.index, 5);
707 assert!(acc.call_id.is_empty());
708 assert!(acc.name.is_empty());
709 assert!(acc.args.is_empty());
710 }
711
712 #[test]
713 fn tool_call_accumulator_multiple_inserts() {
714 let mut map: HashMap<usize, ToolCallAccumulator> = HashMap::new();
715 map.insert(
716 0,
717 ToolCallAccumulator {
718 index: 0,
719 call_id: "a".into(),
720 name: "t1".into(),
721 args: "{}".into(),
722 },
723 );
724 map.insert(
725 1,
726 ToolCallAccumulator {
727 index: 1,
728 call_id: "b".into(),
729 name: "t2".into(),
730 args: "[]".into(),
731 },
732 );
733 assert_eq!(map.len(), 2);
734 }
735
736 #[cfg(feature = "tool")]
739 struct MockProvider;
740
741 #[cfg(feature = "tool")]
742 impl ChatProvider for MockProvider {
743 type Chunk = async_openai::types::chat::CreateChatCompletionStreamResponse;
744
745 fn build_request_json(
746 _model: &str,
747 messages: &[JsonValue],
748 _skill_content: &str,
749 _tools_json: &JsonValue,
750 ) -> JsonValue {
751 serde_json::json!({
752 "model": "mock",
753 "messages": messages,
754 "stream": true,
755 })
756 }
757
758 async fn create_stream(
759 _client: &async_openai::Client<async_openai::config::OpenAIConfig>,
760 _request_json: JsonValue,
761 ) -> Result<
762 async_openai::types::stream::StreamResponse<Self::Chunk>,
763 async_openai::error::OpenAIError,
764 > {
765 Ok(test_helpers::mock_text_stream(vec!["Mock response"]))
766 }
767 }
768
769 #[cfg(feature = "tool")]
770 #[tokio::test]
771 async fn react_loop_mock_provider_completes() {
772 let (env_state_tx, _env_state_rx) = tokio::sync::broadcast::channel(20);
773 let (tool_bus, _exec_rx) = crate::event_bus::tool_bus::ToolBus::new();
774
775 let (loop_tx, mut rx_from_loop) = tokio::sync::mpsc::channel(5);
776 let (tx_to_loop, loop_rx) = tokio::sync::mpsc::channel(5);
777
778 tokio::spawn(async move {
779 let _req = rx_from_loop.recv().await;
780 let (token_tx, _) = tokio::sync::broadcast::channel(50);
781 let react_bus = ReactBus::new();
782 let _ = tx_to_loop
783 .send(TurnHighWayEvent::TurnPrepareResponse {
784 token_tx,
785 react_bus,
786 })
787 .await;
788 });
789
790 let handle = TurnHighWayHandle {
791 turn_high_way_tx: loop_tx,
792 turn_high_way_rx: loop_rx,
793 };
794
795 let session_tx = crate::chat::session::spawn_session_actor();
796 let (_env, _env_watcher) =
797 crate::env::FuneraEnv::new(async_openai::Client::new(), "mock-model");
798 let env_watcher = _env_watcher;
799
800 let loop_instance = ReActLoop::<MockProvider>::new(
801 1,
802 Some(session_tx.clone()),
803 env_watcher,
804 env_state_tx,
805 handle,
806 )
807 .with_tool_bus(tool_bus);
808
809 let loop_handle = loop_instance.run::<TestEvent>(None, None);
810 let msg = FuneraMessage::new(
811 Role::User,
812 MsgVariant::Text(TextMessage {
813 text: "ping".into(),
814 reasoning_content: None,
815 }),
816 );
817 let _ = session_tx.send(crate::chat::session::SessionCmd::PushMessages { msgs: vec![msg] });
818
819 let result = loop_handle.task.await;
820 assert!(result.is_ok(), "loop task should complete successfully");
821 let inner = result.unwrap();
822 assert!(inner.is_ok(), "loop should return Ok(())");
823 }
824}