Skip to main content

locode_provider/
mock.rs

1//! A scripted [`Provider`] — the zero-API-spend test seam for the loop (ADR-0007).
2
3use std::collections::VecDeque;
4use std::sync::{Mutex, PoisonError};
5
6use async_trait::async_trait;
7
8use crate::completion::{Completion, CompletionDelta};
9use crate::provider::{Provider, ProviderError};
10use crate::request::ConversationRequest;
11
12/// A provider that replays a fixed script of results, one per `complete()` call.
13///
14/// This is a real, shippable provider (the `--provider mock` mode runs it in CI
15/// without an API key), not test-only. It ignores the request and returns the next
16/// scripted [`Completion`] (or [`ProviderError`]) in order — letting a test drive the
17/// loop through a tool-call turn, a final-text turn, or a scripted error, with zero
18/// network. Calling `complete()` more times than scripted **panics** (a loud signal
19/// that the loop ran an unexpected extra turn), per the agreed contract.
20pub struct MockProvider {
21    script: Mutex<VecDeque<Result<Completion, ProviderError>>>,
22}
23
24impl MockProvider {
25    /// A mock scripted with a sequence of successful completions.
26    #[must_use]
27    pub fn new(script: Vec<Completion>) -> Self {
28        Self {
29            script: Mutex::new(script.into_iter().map(Ok).collect()),
30        }
31    }
32
33    /// A mock scripted with a sequence of results (to inject errors as well).
34    #[must_use]
35    pub fn with_results(script: Vec<Result<Completion, ProviderError>>) -> Self {
36        Self {
37            script: Mutex::new(script.into_iter().collect()),
38        }
39    }
40}
41
42#[async_trait]
43impl Provider for MockProvider {
44    // The trait ties `&str` to `&self` so real wires return a stored id; the mock's
45    // is a literal.
46    #[allow(clippy::unnecessary_literal_bound)]
47    fn api_schema(&self) -> &str {
48        "mock"
49    }
50
51    async fn complete(&self, _request: &ConversationRequest) -> Result<Completion, ProviderError> {
52        // Recover from a poisoned lock rather than unwrap/expect (denied lints); the
53        // mutex only guards a cursor and is never held across an await.
54        let mut script = self.script.lock().unwrap_or_else(PoisonError::into_inner);
55        match script.pop_front() {
56            Some(result) => result,
57            None => panic!(
58                "MockProvider script exhausted: complete() was called more times than scripted"
59            ),
60        }
61    }
62
63    /// Streams the next scripted completion as **word-ish text deltas** (the chunks
64    /// concatenate back to the completion's text exactly), then returns the same
65    /// whole `Completion` — a realistic multi-delta stream for engine/TUI tests
66    /// without a network.
67    async fn stream(
68        &self,
69        request: &ConversationRequest,
70        on_delta: &mut (dyn FnMut(CompletionDelta) + Send),
71    ) -> Result<Completion, ProviderError> {
72        let completion = self.complete(request).await?;
73        if let Some(text) = completion.text() {
74            for chunk in chunk_text(&text) {
75                on_delta(CompletionDelta::Text(chunk));
76            }
77        }
78        Ok(completion)
79    }
80}
81
82/// Split `text` into word-ish chunks (each a run of non-space chars plus its
83/// trailing spaces) that concatenate back to `text` byte-for-byte.
84fn chunk_text(text: &str) -> Vec<String> {
85    let mut chunks = Vec::new();
86    let mut cur = String::new();
87    let mut in_word = false;
88    for ch in text.chars() {
89        if ch.is_whitespace() {
90            cur.push(ch);
91            in_word = false;
92        } else {
93            if !in_word && !cur.is_empty() {
94                chunks.push(std::mem::take(&mut cur));
95            }
96            cur.push(ch);
97            in_word = true;
98        }
99    }
100    if !cur.is_empty() {
101        chunks.push(cur);
102    }
103    chunks
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::request::{CacheHint, SamplingArgs};
110    use locode_protocol::{ContentBlock, Usage};
111
112    use crate::completion::StopReason;
113
114    fn req() -> ConversationRequest {
115        ConversationRequest {
116            messages: vec![],
117            tools: vec![],
118            sampling_args: SamplingArgs::default(),
119            cache_hint: CacheHint::default(),
120        }
121    }
122
123    fn text(t: &str) -> Completion {
124        Completion {
125            content: vec![ContentBlock::Text { text: t.into() }],
126            usage: Usage::default(),
127            stop: StopReason::EndTurn,
128        }
129    }
130
131    #[test]
132    fn chunk_text_concatenates_back_to_input_exactly() {
133        for input in [
134            "",
135            "hello",
136            "hello world",
137            "  leading and  double  spaces ",
138            "line one\nline two\n",
139            "tabs\tand\nnewlines here",
140            "unicode → café ☕ done",
141        ] {
142            let joined: String = chunk_text(input).concat();
143            assert_eq!(joined, input, "chunk_text must reconstruct {input:?}");
144        }
145    }
146
147    #[test]
148    fn chunk_text_splits_on_word_boundaries() {
149        assert_eq!(chunk_text("all done"), vec!["all ", "done"]);
150        assert_eq!(chunk_text("one two three"), vec!["one ", "two ", "three"]);
151        assert_eq!(chunk_text("solo"), vec!["solo"]);
152        assert!(chunk_text("").is_empty());
153    }
154
155    #[tokio::test]
156    async fn mock_stream_chunks_text_and_returns_the_whole_completion() {
157        let mock = MockProvider::new(vec![text("hello there world")]);
158        let mut deltas = Vec::new();
159        let completion = mock
160            .stream(&req(), &mut |d| deltas.push(d))
161            .await
162            .expect("mock stream ok");
163        // Multiple text deltas that concatenate back to the full text.
164        assert!(deltas.len() > 1, "expected multiple chunks: {deltas:?}");
165        let joined: String = deltas
166            .iter()
167            .map(|d| match d {
168                CompletionDelta::Text(t) => t.as_str(),
169                other => panic!("mock should only emit Text: {other:?}"),
170            })
171            .collect();
172        assert_eq!(joined, "hello there world");
173        // The returned Completion is the whole scripted turn, unchanged.
174        assert_eq!(completion.text().as_deref(), Some("hello there world"));
175        assert_eq!(completion.stop, StopReason::EndTurn);
176    }
177
178    #[tokio::test]
179    async fn mock_stream_with_no_text_emits_no_deltas() {
180        // A tool-only turn has no text → no deltas, same Completion back.
181        let tool = Completion {
182            content: vec![ContentBlock::ToolUse {
183                id: "c1".into(),
184                name: "echo".into(),
185                input: serde_json::json!({}),
186            }],
187            usage: Usage::default(),
188            stop: StopReason::ToolUse,
189        };
190        let mock = MockProvider::new(vec![tool]);
191        let mut deltas = Vec::new();
192        let completion = mock
193            .stream(&req(), &mut |d| deltas.push(d))
194            .await
195            .expect("ok");
196        assert!(
197            deltas.is_empty(),
198            "tool-only turn streams no text: {deltas:?}"
199        );
200        assert!(completion.has_tool_calls());
201    }
202}