1use 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
12pub struct MockProvider {
21 script: Mutex<VecDeque<Result<Completion, ProviderError>>>,
22}
23
24impl MockProvider {
25 #[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 #[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 #[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 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 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
82fn 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 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 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 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}