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::{
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
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                #[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    // ── process_token_stream ────────────────────────────────────
532
533    #[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    // ── handle_turn_finish ───────────────────────────────────────
614
615    #[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    // ── ToolCallAccumulator behavior ────────────────────────────
669
670    #[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    // ── end-to-end ReActLoop with MockProvider ──────────────────
727
728    #[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}