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}