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(
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    // ── process_token_stream ────────────────────────────────────
537
538    #[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    // ── handle_turn_finish ───────────────────────────────────────
619
620    #[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    // ── ToolCallAccumulator behavior ────────────────────────────
674
675    #[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    // ── end-to-end ReActLoop with MockProvider ──────────────────
732
733    #[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}