Skip to main content

recursive/llm/
mock.rs

1//! Deterministic LLM for tests and offline development.
2//!
3//! `MockProvider` is fed a queue of pre-baked completions. The agent treats
4//! it identically to a real provider, so the agent loop is fully testable
5//! without network access.
6// Test infrastructure: lock().unwrap() is acceptable because mock is only
7// used in test contexts and mutex poison would indicate a test bug.
8#![allow(clippy::unwrap_used, clippy::expect_used)]
9
10use std::sync::Mutex;
11
12use async_trait::async_trait;
13
14use super::StructuredRequest;
15use super::{ChatProvider, Completion, StreamSender, ToolSpec};
16use crate::error::{Error, Result};
17use crate::message::Message;
18use tracing::Instrument;
19
20#[derive(Default)]
21pub struct MockProvider {
22    scripted: Mutex<Vec<Completion>>,
23    /// Queue of pre-baked errors to return before consuming scripted completions.
24    /// Each call to `complete()` pops from this queue first; if non-empty,
25    /// the error is returned instead of the next scripted completion.
26    errors: Mutex<Vec<Error>>,
27    /// Queue of pre-baked JSON responses for `complete_structured`.
28    structured_responses: Mutex<Vec<Result<serde_json::Value>>>,
29    calls: Mutex<Vec<Vec<Message>>>,
30    /// Optional notifier — `notify_one()`'d after every `complete()` call,
31    /// before returning. Used by tests that need to deterministically race
32    /// some side action (e.g. cancellation) against agent progress.
33    on_complete: Option<std::sync::Arc<tokio::sync::Notify>>,
34    /// Optional synchronous hook — invoked after every `complete()` call,
35    /// before returning. Prefer this over `on_complete` when the side
36    /// effect must happen before the agent continues (e.g. cancellation).
37    on_complete_fn: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
38}
39
40impl MockProvider {
41    /// Alias for `new` — both accept completions that may have `usage` set.
42    /// Provided for API symmetry when callers want to be explicit about usage.
43    pub fn with_usage(scripted: Vec<Completion>) -> Self {
44        Self::new(scripted)
45    }
46
47    /// Create a new MockProvider with the given scripted completions.
48    pub fn new(scripted: Vec<Completion>) -> Self {
49        Self {
50            scripted: Mutex::new(scripted),
51            calls: Mutex::new(Vec::new()),
52            errors: Mutex::new(Vec::new()),
53            structured_responses: Mutex::new(Vec::new()),
54            on_complete: None,
55            on_complete_fn: None,
56        }
57    }
58
59    /// Attach a notifier that fires once per `complete()` call, after the
60    /// completion is dequeued and immediately before it is returned. Tests
61    /// use this to deterministically synchronise external side-effects
62    /// (e.g. cancellation) with agent progress, avoiding wall-clock sleeps.
63    pub fn with_on_complete(mut self, notify: std::sync::Arc<tokio::sync::Notify>) -> Self {
64        self.on_complete = Some(notify);
65        self
66    }
67
68    /// Attach a synchronous hook that runs once per `complete()` call, after
69    /// the completion is dequeued and immediately before it is returned.
70    pub fn with_on_complete_fn<F>(mut self, hook: F) -> Self
71    where
72        F: Fn() + Send + Sync + 'static,
73    {
74        self.on_complete_fn = Some(std::sync::Arc::new(hook));
75        self
76    }
77
78    /// Set a queue of errors to return from `complete()` before the scripted
79    /// completions are consumed. Each call to `complete()` pops the next error;
80    /// when the error queue is empty, scripted completions are returned as usual.
81    pub fn with_errors(self, errors: Vec<Error>) -> Self {
82        *self.errors.lock().unwrap() = errors;
83        self
84    }
85
86    /// Snapshot of the transcripts the agent has sent to this provider.
87    pub fn calls(&self) -> Vec<Vec<Message>> {
88        self.calls.lock().unwrap().clone()
89    }
90
91    /// Set the queue of structured responses for `complete_structured`.
92    /// Each call to `complete_structured` pops the next response.
93    /// If the queue is empty, it returns an error (fallback path).
94    pub fn with_structured_responses(self, responses: Vec<Result<serde_json::Value>>) -> Self {
95        *self.structured_responses.lock().unwrap() = responses;
96        self
97    }
98}
99
100#[async_trait]
101impl ChatProvider for MockProvider {
102    async fn complete(&self, messages: &[Message], _tools: &[ToolSpec]) -> Result<Completion> {
103        let span = tracing::info_span!("llm.complete", provider = "mock", model = "mock");
104        async move {
105            // Emit info log so tracing-test can capture the span
106            tracing::info!("mock llm call");
107            self.calls.lock().unwrap().push(messages.to_vec());
108            // Check error queue first — errors are injected before scripted completions.
109            {
110                let mut errors = self.errors.lock().unwrap();
111                if !errors.is_empty() {
112                    let err = errors.remove(0);
113                    return Err(err);
114                }
115            }
116            let mut queue = self.scripted.lock().unwrap();
117            if queue.is_empty() {
118                return Err(Error::Llm {
119                    provider: "mock".into(),
120                    message: "MockProvider: no scripted completions left".into(),
121                });
122            }
123            let completion = queue.remove(0);
124            drop(queue);
125            if let Some(ref hook) = self.on_complete_fn {
126                hook();
127            }
128            if let Some(ref notify) = self.on_complete {
129                notify.notify_one();
130            }
131            Ok(completion)
132        }
133        .instrument(span)
134        .await
135    }
136
137    async fn complete_structured(&self, _req: StructuredRequest) -> Result<serde_json::Value> {
138        let mut queue = self.structured_responses.lock().unwrap();
139        if queue.is_empty() {
140            // Default: return error to trigger fallback
141            return Err(Error::Config {
142                message: "MockProvider: no structured responses configured".into(),
143            });
144        }
145        queue.remove(0)
146    }
147
148    async fn stream(
149        &self,
150        messages: &[Message],
151        tools: &[ToolSpec],
152        stream_tx: Option<StreamSender>,
153        _cancel_token: Option<tokio_util::sync::CancellationToken>,
154    ) -> Result<Completion> {
155        // MockProvider: just delegate to complete and emit the full content
156        let completion = self.complete(messages, tools).await?;
157        if let Some(tx) = stream_tx {
158            if let Some(reasoning) = &completion.reasoning_content {
159                if !reasoning.is_empty() {
160                    let _ = tx.send(super::StreamChunk::Reasoning(reasoning.clone()));
161                }
162            }
163            if !completion.content.is_empty() {
164                let _ = tx.send(super::StreamChunk::Text(completion.content.clone()));
165            }
166        }
167        Ok(completion)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[tokio::test]
176    async fn returns_scripted_in_order_and_records_calls() {
177        let provider = MockProvider::new(vec![
178            Completion {
179                content: "one".into(),
180                tool_calls: vec![],
181                finish_reason: Some("stop".into()),
182                usage: None,
183                reasoning_content: None,
184            },
185            Completion {
186                content: "two".into(),
187                tool_calls: vec![],
188                finish_reason: Some("stop".into()),
189                usage: None,
190                reasoning_content: None,
191            },
192        ]);
193
194        let m1 = vec![Message::user("hi")];
195        let r1 = provider.complete(&m1, &[]).await.unwrap();
196        assert_eq!(r1.content, "one");
197
198        let m2 = vec![Message::user("bye")];
199        let r2 = provider.complete(&m2, &[]).await.unwrap();
200        assert_eq!(r2.content, "two");
201
202        assert_eq!(provider.calls().len(), 2);
203        assert_eq!(provider.calls()[0][0].content, "hi");
204    }
205
206    #[tokio::test]
207    async fn errors_when_queue_drained() {
208        let provider = MockProvider::new(vec![]);
209        let err = provider.complete(&[], &[]).await.unwrap_err();
210        assert!(matches!(err, Error::Llm { .. }));
211    }
212}
213
214#[cfg(test)]
215mod tracing_tests {
216    use super::*;
217    use crate::llm::TokenUsage;
218    use tracing_test::traced_test;
219
220    #[tokio::test]
221    async fn errors_are_injected_before_scripted_completions() {
222        // kills `if !errors.is_empty()` guard removal mutation
223        use crate::error::Error;
224        let provider = MockProvider::new(vec![Completion {
225            content: "fallback".into(),
226            tool_calls: vec![],
227            finish_reason: Some("stop".into()),
228            usage: None,
229            reasoning_content: None,
230        }])
231        .with_errors(vec![Error::Llm {
232            provider: "mock".into(),
233            message: "injected error".into(),
234        }]);
235
236        // First call should return the injected error.
237        let r1 = provider.complete(&[], &[]).await;
238        assert!(r1.is_err(), "first call must return injected error");
239        assert!(r1.unwrap_err().to_string().contains("injected error"));
240
241        // Second call should return the scripted completion.
242        let r2 = provider.complete(&[], &[]).await;
243        assert!(r2.is_ok(), "second call must return scripted completion");
244        assert_eq!(r2.unwrap().content, "fallback");
245    }
246
247    #[tokio::test]
248    async fn complete_structured_returns_scripted_value() {
249        // kills `queue.remove(0)` → `Ok(Default::default())` and empty-queue mutations
250        use crate::llm::StructuredRequest;
251        use crate::message::Message;
252        let provider = MockProvider::new(vec![])
253            .with_structured_responses(vec![Ok(serde_json::json!({"key": "val"}))]);
254        let req = StructuredRequest {
255            messages: vec![Message::user("q")],
256            schema: serde_json::json!({}),
257            schema_name: "test".into(),
258        };
259        let result = provider.complete_structured(req).await.unwrap();
260        assert_eq!(
261            result["key"], "val",
262            "must return the scripted structured value"
263        );
264    }
265
266    #[tokio::test]
267    async fn complete_structured_empty_queue_returns_error() {
268        // kills `if queue.is_empty() { return Err(...) }` removal mutation
269        use crate::llm::StructuredRequest;
270        use crate::message::Message;
271        let provider = MockProvider::new(vec![]);
272        // No structured responses configured — must error.
273        let req = StructuredRequest {
274            messages: vec![Message::user("q")],
275            schema: serde_json::json!({}),
276            schema_name: "test".into(),
277        };
278        let result = provider.complete_structured(req).await;
279        assert!(result.is_err(), "empty structured queue must return error");
280    }
281
282    #[tokio::test]
283    async fn stream_sends_content_to_channel() {
284        // kills `if !completion.content.is_empty()` guard removal mutation
285        use crate::llm::StreamChunk;
286        let provider = MockProvider::new(vec![Completion {
287            content: "streamed text".into(),
288            tool_calls: vec![],
289            finish_reason: Some("stop".into()),
290            usage: None,
291            reasoning_content: None,
292        }]);
293        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
294        provider.stream(&[], &[], Some(tx), None).await.unwrap();
295        let chunk = rx.try_recv().expect("must receive a chunk");
296        assert!(
297            matches!(chunk, StreamChunk::Text(t) if t == "streamed text"),
298            "must receive Text chunk with completion content"
299        );
300    }
301
302    #[tokio::test]
303    async fn stream_sends_reasoning_before_text() {
304        // kills `if !reasoning.is_empty()` guard removal mutation
305        use crate::llm::StreamChunk;
306        let provider = MockProvider::new(vec![Completion {
307            content: "answer".into(),
308            tool_calls: vec![],
309            finish_reason: Some("stop".into()),
310            usage: None,
311            reasoning_content: Some("thinking step".into()),
312        }]);
313        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
314        provider.stream(&[], &[], Some(tx), None).await.unwrap();
315
316        let first = rx.try_recv().expect("must have first chunk");
317        let second = rx.try_recv().expect("must have second chunk");
318        assert!(
319            matches!(first, StreamChunk::Reasoning(r) if r == "thinking step"),
320            "first chunk must be Reasoning"
321        );
322        assert!(
323            matches!(second, StreamChunk::Text(t) if t == "answer"),
324            "second chunk must be Text"
325        );
326    }
327
328    #[tokio::test]
329    async fn calls_records_all_messages() {
330        // kills `self.calls.lock().unwrap().push(messages.to_vec())` removal mutation
331        let provider = MockProvider::new(vec![
332            Completion {
333                content: "a".into(),
334                tool_calls: vec![],
335                finish_reason: None,
336                usage: None,
337                reasoning_content: None,
338            },
339            Completion {
340                content: "b".into(),
341                tool_calls: vec![],
342                finish_reason: None,
343                usage: None,
344                reasoning_content: None,
345            },
346        ]);
347        let msgs1 = vec![Message::user("first call")];
348        let msgs2 = vec![Message::user("second call")];
349        provider.complete(&msgs1, &[]).await.unwrap();
350        provider.complete(&msgs2, &[]).await.unwrap();
351
352        let calls = provider.calls();
353        assert_eq!(calls.len(), 2, "must record 2 calls");
354        assert_eq!(calls[0][0].content, "first call");
355        assert_eq!(calls[1][0].content, "second call");
356    }
357
358    #[traced_test]
359    #[tokio::test]
360    async fn llm_complete_records_token_fields() {
361        let usage = TokenUsage {
362            reasoning_tokens: 0,
363            prompt_tokens: 100,
364            completion_tokens: 50,
365            total_tokens: 150,
366            cache_hit_tokens: 0,
367            cache_miss_tokens: 0,
368            // Goal 273: default 0 for MockProvider (no reasoning).
369        };
370        let provider = MockProvider::new(vec![Completion {
371            content: "response".into(),
372            tool_calls: vec![],
373            finish_reason: Some("stop".into()),
374            usage: Some(usage),
375            reasoning_content: None,
376        }]);
377
378        provider.complete(&[], &[]).await.unwrap();
379
380        // Should have created an llm.complete span - check for span name in output
381        assert!(logs_contain("llm.complete"));
382    }
383}