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
7use std::sync::Mutex;
8
9use async_trait::async_trait;
10
11use super::StructuredRequest;
12use super::{Completion, LlmProvider, StreamSender, ToolSpec};
13use crate::error::{Error, Result};
14use crate::message::Message;
15use tracing::Instrument;
16
17#[derive(Default)]
18pub struct MockProvider {
19    scripted: Mutex<Vec<Completion>>,
20    /// Queue of pre-baked JSON responses for `complete_structured`.
21    structured_responses: Mutex<Vec<Result<serde_json::Value>>>,
22    calls: Mutex<Vec<Vec<Message>>>,
23    /// Optional notifier — `notify_one()`'d after every `complete()` call,
24    /// before returning. Used by tests that need to deterministically race
25    /// some side action (e.g. cancellation) against agent progress.
26    on_complete: Option<std::sync::Arc<tokio::sync::Notify>>,
27    /// Optional synchronous hook — invoked after every `complete()` call,
28    /// before returning. Prefer this over `on_complete` when the side
29    /// effect must happen before the agent continues (e.g. cancellation).
30    on_complete_fn: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
31}
32
33impl MockProvider {
34    /// Alias for `new` — both accept completions that may have `usage` set.
35    /// Provided for API symmetry when callers want to be explicit about usage.
36    pub fn with_usage(scripted: Vec<Completion>) -> Self {
37        Self::new(scripted)
38    }
39
40    /// Create a new MockProvider with the given scripted completions.
41    pub fn new(scripted: Vec<Completion>) -> Self {
42        Self {
43            scripted: Mutex::new(scripted),
44            calls: Mutex::new(Vec::new()),
45            structured_responses: Mutex::new(Vec::new()),
46            on_complete: None,
47            on_complete_fn: None,
48        }
49    }
50
51    /// Attach a notifier that fires once per `complete()` call, after the
52    /// completion is dequeued and immediately before it is returned. Tests
53    /// use this to deterministically synchronise external side-effects
54    /// (e.g. cancellation) with agent progress, avoiding wall-clock sleeps.
55    pub fn with_on_complete(mut self, notify: std::sync::Arc<tokio::sync::Notify>) -> Self {
56        self.on_complete = Some(notify);
57        self
58    }
59
60    /// Attach a synchronous hook that runs once per `complete()` call, after
61    /// the completion is dequeued and immediately before it is returned.
62    pub fn with_on_complete_fn<F>(mut self, hook: F) -> Self
63    where
64        F: Fn() + Send + Sync + 'static,
65    {
66        self.on_complete_fn = Some(std::sync::Arc::new(hook));
67        self
68    }
69
70    /// Snapshot of the transcripts the agent has sent to this provider.
71    pub fn calls(&self) -> Vec<Vec<Message>> {
72        self.calls.lock().unwrap().clone()
73    }
74
75    /// Set the queue of structured responses for `complete_structured`.
76    /// Each call to `complete_structured` pops the next response.
77    /// If the queue is empty, it returns an error (fallback path).
78    pub fn with_structured_responses(self, responses: Vec<Result<serde_json::Value>>) -> Self {
79        *self.structured_responses.lock().unwrap() = responses;
80        self
81    }
82}
83
84#[async_trait]
85impl LlmProvider for MockProvider {
86    async fn complete(&self, messages: &[Message], _tools: &[ToolSpec]) -> Result<Completion> {
87        let span = tracing::info_span!("llm.complete", provider = "mock", model = "mock");
88        async move {
89            // Emit info log so tracing-test can capture the span
90            tracing::info!("mock llm call");
91            self.calls.lock().unwrap().push(messages.to_vec());
92            let mut queue = self.scripted.lock().unwrap();
93            if queue.is_empty() {
94                return Err(Error::Llm {
95                    provider: "mock".into(),
96                    message: "MockProvider: no scripted completions left".into(),
97                });
98            }
99            let completion = queue.remove(0);
100            drop(queue);
101            if let Some(ref hook) = self.on_complete_fn {
102                hook();
103            }
104            if let Some(ref notify) = self.on_complete {
105                notify.notify_one();
106            }
107            Ok(completion)
108        }
109        .instrument(span)
110        .await
111    }
112
113    async fn complete_structured(&self, _req: StructuredRequest) -> Result<serde_json::Value> {
114        let mut queue = self.structured_responses.lock().unwrap();
115        if queue.is_empty() {
116            // Default: return error to trigger fallback
117            return Err(Error::Config {
118                message: "MockProvider: no structured responses configured".into(),
119            });
120        }
121        queue.remove(0)
122    }
123
124    async fn stream(
125        &self,
126        messages: &[Message],
127        tools: &[ToolSpec],
128        stream_tx: Option<StreamSender>,
129    ) -> Result<Completion> {
130        // MockProvider: just delegate to complete and emit the full content
131        let completion = self.complete(messages, tools).await?;
132        if let Some(tx) = stream_tx {
133            if !completion.content.is_empty() {
134                let _ = tx.send(completion.content.clone());
135            }
136        }
137        Ok(completion)
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[tokio::test]
146    async fn returns_scripted_in_order_and_records_calls() {
147        let provider = MockProvider::new(vec![
148            Completion {
149                content: "one".into(),
150                tool_calls: vec![],
151                finish_reason: Some("stop".into()),
152                usage: None,
153                reasoning_content: None,
154            },
155            Completion {
156                content: "two".into(),
157                tool_calls: vec![],
158                finish_reason: Some("stop".into()),
159                usage: None,
160                reasoning_content: None,
161            },
162        ]);
163
164        let m1 = vec![Message::user("hi")];
165        let r1 = provider.complete(&m1, &[]).await.unwrap();
166        assert_eq!(r1.content, "one");
167
168        let m2 = vec![Message::user("bye")];
169        let r2 = provider.complete(&m2, &[]).await.unwrap();
170        assert_eq!(r2.content, "two");
171
172        assert_eq!(provider.calls().len(), 2);
173        assert_eq!(provider.calls()[0][0].content, "hi");
174    }
175
176    #[tokio::test]
177    async fn errors_when_queue_drained() {
178        let provider = MockProvider::new(vec![]);
179        let err = provider.complete(&[], &[]).await.unwrap_err();
180        assert!(matches!(err, Error::Llm { .. }));
181    }
182}
183
184#[cfg(test)]
185mod tracing_tests {
186    use super::*;
187    use crate::llm::TokenUsage;
188    use tracing_test::traced_test;
189
190    #[traced_test]
191    #[tokio::test]
192    async fn llm_complete_records_token_fields() {
193        let usage = TokenUsage {
194            prompt_tokens: 100,
195            completion_tokens: 50,
196            total_tokens: 150,
197            cache_hit_tokens: 0,
198            cache_miss_tokens: 0,
199        };
200        let provider = MockProvider::new(vec![Completion {
201            content: "response".into(),
202            tool_calls: vec![],
203            finish_reason: Some("stop".into()),
204            usage: Some(usage),
205            reasoning_content: None,
206        }]);
207
208        provider.complete(&[], &[]).await.unwrap();
209
210        // Should have created an llm.complete span - check for span name in output
211        assert!(logs_contain("llm.complete"));
212    }
213}