Skip to main content

funera_core/
re_act.rs

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
32/// Configuration for the ReAct loop execution.
33///
34/// Controls buffer sizes, iteration limits, environment watchers, event buses,
35/// and optional session integration.
36pub struct ReActLoopConfig {
37    /// Internal channel buffer size for event buses (e.g. token stream relay).
38    pub buffer: usize,
39    /// Maximum number of ReAct iterations per call (tool-calling cycles).
40    pub max_iteration: usize,
41    /// Watcher for dynamic environment changes (client, model, tools, skills, sandbox).
42    pub env_watcher: FuneraEnvWatcher,
43    /// Channel for sending tool execution commands to the background executor.
44    #[cfg(feature = "tool")]
45    pub tool_bus: ToolBus,
46    /// Sender for environment state events (session lifecycle, tool changes).
47    pub env_state_tx: broadcast::Sender<EnvStateEvent>,
48    /// Handle for the per-turn highway protocol (allocates per-turn event buses).
49    pub turn_highway_handle: TurnHighWayHandle,
50    /// Optional sender to the session actor — enables message history persistence.
51    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        // Placeholder: tool_bus is already set via the field; this allows
80        // extending the builder-style construction if needed.
81        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                // `tool_results` is only consumed when the `tool` feature is on.
229                #[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    // ── process_token_stream ────────────────────────────────────
542
543    #[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    // ── handle_turn_finish ───────────────────────────────────────
624
625    #[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    // ── ToolCallAccumulator behavior ────────────────────────────
679
680    #[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    // ── end-to-end ReActLoop with MockProvider ──────────────────
737
738    #[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}