Skip to main content

phi_core/provider/
mock.rs

1//! Mock provider for testing. No real API calls.
2/*
3ARCHITECTURE: MockProvider — test double for StreamProvider
4
5In tests, we don't want to make real HTTP calls to Anthropic or OpenAI.
6`MockProvider` is a "test double" (specifically a "stub"): it has the same
7interface as a real provider but returns pre-scripted responses.
8
9Usage pattern in tests:
10  let provider = MockProvider::texts(vec!["Hello", "World"]);
11  // first agent loop call → "Hello"
12  // second agent loop call → "World"
13  // third call → "(no more mock responses)" fallback
14
15RUST QUIRK: `std::sync::Mutex<Vec<MockResponse>>` — interior mutability for shared state
16
17`MockProvider` must implement `StreamProvider` which requires `Sync` (shareable
18between threads). But `stream()` takes `&self` (shared reference) and needs to
19MUTATE the response queue (remove the next response).
20
21The problem: `&self` is a shared reference — by default it's read-only.
22Solution: wrap the queue in `Mutex<T>`, which provides "interior mutability":
23  - `Mutex::lock()` gives an exclusive `MutexGuard<T>` — exclusive borrow at runtime
24  - Other threads must wait for the lock before accessing the queue
25  - This satisfies `Sync` because all accesses are serialized through the Mutex
26
27Python analogy: `threading.Lock()` protecting a shared list.
28
29Why `std::sync::Mutex` (not `tokio::sync::Mutex`)?
30  `std::sync::Mutex` is a blocking mutex — it uses the OS thread scheduler.
31  `tokio::sync::Mutex` is an async-aware mutex — it yields the tokio task instead.
32  Here we lock only briefly (just to pop from the Vec), so blocking is fine.
33  If we held the lock across an `await` point, we'd need `tokio::sync::Mutex`.
34*/
35
36use super::traits::*;
37use crate::types::*;
38use async_trait::async_trait;
39use tokio::sync::mpsc;
40
41/// A mock response: either plain text or a set of tool calls.
42/*
43RUST QUIRK: Tuple variant vs struct variant
44  `MockResponse::Text(String)` — tuple variant with one unnamed field
45  `MockResponse::ToolCalls(Vec<MockToolCall>)` — tuple variant with one unnamed field
46  Both hold their value by ownership (the String / Vec is moved into the enum).
47
48  Access the inner value with pattern matching:
49    `match response { MockResponse::Text(text) => ... }`
50  Python analogy: tagged unions / sum types via dataclasses.
51*/
52#[derive(Debug, Clone)]
53pub enum MockResponse {
54    /// The LLM replies with this text string.
55    Text(String),
56    /// The LLM calls these tools (arguments are pre-specified, not generated).
57    ToolCalls(Vec<MockToolCall>),
58}
59
60/// A single mock tool call (pre-scripted name + arguments).
61#[derive(Debug, Clone)]
62pub struct MockToolCall {
63    pub name: String,
64    pub arguments: serde_json::Value,
65}
66
67/// Mock LLM provider for tests. Supply a sequence of responses.
68pub struct MockProvider {
69    /// Queue of responses to return, in order. Protected by a Mutex for interior mutability.
70    responses: std::sync::Mutex<Vec<MockResponse>>,
71}
72
73impl MockProvider {
74    /// Create a provider from a sequence of responses.
75    pub fn new(responses: Vec<MockResponse>) -> Self {
76        Self {
77            responses: std::sync::Mutex::new(responses),
78        }
79    }
80
81    /// Convenience: provider that always returns the same text.
82    pub fn text(text: impl Into<String>) -> Self {
83        Self::new(vec![MockResponse::Text(text.into())])
84    }
85
86    /// Convenience: provider that returns a sequence of text responses, one per call.
87    pub fn texts(texts: Vec<impl Into<String>>) -> Self {
88        /*
89        RUST QUIRK: `.into_iter().map(...).collect()` — consuming iterator chain
90
91        `texts.into_iter()` — MOVES the Vec into an iterator (transfers ownership)
92        `.map(|t| MockResponse::Text(t.into()))` — transforms each item
93          `t.into()` converts `impl Into<String>` → `String` (calls `Into::into()`)
94          Then wraps in `MockResponse::Text(...)`
95        `.collect()` — gathers into a `Vec<MockResponse>`
96        The return type is inferred from `Self::new(responses: Vec<MockResponse>)`.
97        */
98        Self::new(
99            texts
100                .into_iter()
101                .map(|t| MockResponse::Text(t.into()))
102                .collect(),
103        )
104    }
105}
106
107#[async_trait]
108impl StreamProvider for MockProvider {
109    fn provider_id(&self) -> &str {
110        "mock"
111    }
112
113    async fn stream(
114        &self,
115        _config: StreamConfig, // IGNORED — test double; real config not used (responses are pre-set)
116        tx: mpsc::UnboundedSender<StreamEvent>, // OBSERVER — receives synthetic events built from the next MockResponse
117        cancel: tokio_util::sync::CancellationToken, // ABORT — honored (returns Cancelled if triggered before events are sent)
118    ) -> Result<Message, ProviderError> {
119        /*
120        RUST QUIRK: `{ let mut guard = self.responses.lock().unwrap(); ... }`
121        The block `{ ... }` creates a scope. The `MutexGuard` (returned by `.lock()`)
122        is dropped when the block ends — releasing the lock.
123
124        `.lock()` returns `Result<MutexGuard, PoisonError>`. A Mutex is "poisoned"
125        if another thread panicked while holding the lock. `.unwrap()` propagates
126        the panic in that unlikely scenario.
127
128        `.remove(0)` removes and returns the first element, shifting everything else
129        left. O(n) but fine for small test queues.
130        This is the standard Mutex pattern: lock briefly, extract data, drop lock.
131        Python analogy: `with lock: response = queue.pop(0)`
132        */
133        let response = {
134            let mut responses = self.responses.lock().unwrap(); // acquire lock
135            if responses.is_empty() {
136                // Fallback: tests that run more turns than responses get a safe default
137                MockResponse::Text("(no more mock responses)".into())
138            } else {
139                responses.remove(0) // pop the front response
140            }
141            // MutexGuard dropped here — lock released
142        };
143
144        if cancel.is_cancelled() {
145            return Err(ProviderError::Cancelled);
146        }
147
148        let _ = tx.send(StreamEvent::Start);
149
150        /*
151        RUST QUIRK: `match response { ... }` — consuming a moved value
152
153        `response` was moved out of the Mutex (via `.remove(0)`).
154        This `match` consumes it: each arm can move fields out of the variant.
155        After the match, `response` is gone. The `message` local is the result.
156        */
157        let message = match response {
158            MockResponse::Text(text) => {
159                // Emit a single TextDelta for the full text (simplified vs real streaming)
160                let _ = tx.send(StreamEvent::TextDelta {
161                    content_index: 0,
162                    delta: text.clone(),
163                });
164                Message::Assistant {
165                    content: vec![Content::Text { text }],
166                    stop_reason: StopReason::Stop,
167                    model: "mock".into(),
168                    provider: "mock".into(),
169                    usage: Usage::default(),
170                    timestamp: now_ms(),
171                    error_message: None,
172                }
173            }
174            MockResponse::ToolCalls(calls) => {
175                /*
176                RUST QUIRK: `.enumerate()` — pairing each element with its index
177
178                `.iter().enumerate()` transforms `Iterator<Item=T>` →
179                `Iterator<Item=(usize, &T)>`, providing the index alongside each item.
180                Here: `(i, call)` where `i` is 0, 1, 2, ... and `call` is `&MockToolCall`.
181                Python analogy: `for i, call in enumerate(calls):`
182                */
183                let content: Vec<Content> = calls
184                    .iter()
185                    .enumerate()
186                    .map(|(i, call)| {
187                        let id = format!("mock-tool-{}", i);
188                        // Notify the channel that a tool call started and immediately ended
189                        // (mock: no streaming of arguments — they're fully known upfront)
190                        let _ = tx.send(StreamEvent::ToolCallStart {
191                            content_index: i,
192                            id: id.clone(),
193                            name: call.name.clone(),
194                        });
195                        let _ = tx.send(StreamEvent::ToolCallEnd { content_index: i });
196                        Content::ToolCall {
197                            id,
198                            name: call.name.clone(),
199                            arguments: call.arguments.clone(),
200                        }
201                    })
202                    .collect();
203
204                Message::Assistant {
205                    content,
206                    stop_reason: StopReason::ToolUse,
207                    model: "mock".into(),
208                    provider: "mock".into(),
209                    usage: Usage::default(),
210                    timestamp: now_ms(),
211                    error_message: None,
212                }
213            }
214        };
215
216        // Signal stream completion — both on the channel and as the return value
217        let _ = tx.send(StreamEvent::Done {
218            message: message.clone(),
219        });
220        Ok(message)
221    }
222}