1use async_trait::async_trait;
9use futures::{Stream, StreamExt, stream};
10
11use crate::{
12 Chunk, CompletionRequest, LlmProvider, StopReason, Usage, error::DummyError, request::ToolCall,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum TurnStreamEvent {
22 TextDelta(String),
24 ReasoningDelta(String),
28 ToolStarted {
30 id: String,
32 name: String,
34 },
35}
36
37#[derive(Debug, Default, Clone)]
39pub struct TurnOutput {
40 pub text: String,
42 pub reasoning: String,
45 pub tool_calls: Vec<ToolCall>,
47 pub usage: Usage,
49 pub stop: Option<StopReason>,
51}
52
53pub async fn collect_turn<S, E>(stream: S) -> Result<TurnOutput, E>
63where
64 S: Stream<Item = Result<Chunk, E>> + Unpin,
65{
66 collect_turn_observed(stream, |_| {}).await
67}
68
69pub async fn collect_turn_observed<S, E, F>(mut stream: S, mut on_event: F) -> Result<TurnOutput, E>
79where
80 S: Stream<Item = Result<Chunk, E>> + Unpin,
81 F: FnMut(TurnStreamEvent),
82{
83 let mut out = TurnOutput::default();
84 let mut pending: Vec<ToolCall> = Vec::new();
91 while let Some(item) = stream.next().await {
92 match item? {
93 Chunk::TextDelta(s) => {
94 on_event(TurnStreamEvent::TextDelta(s.clone()));
95 out.text.push_str(&s);
96 }
97 Chunk::ReasoningDelta(s) => {
98 on_event(TurnStreamEvent::ReasoningDelta(s.clone()));
99 out.reasoning.push_str(&s);
100 }
101 Chunk::ToolCallStart {
102 id,
103 name,
104 signature,
105 } => {
106 on_event(TurnStreamEvent::ToolStarted {
107 id: id.clone(),
108 name: name.clone(),
109 });
110 pending.push(ToolCall {
111 id,
112 name,
113 args_json: String::new(),
114 signature,
115 });
116 }
117 Chunk::ToolCallArgsDelta {
118 id,
119 args_json_delta,
120 } => {
121 if let Some(tc) = pending.iter_mut().find(|tc| tc.id == id) {
122 tc.args_json.push_str(&args_json_delta);
123 }
124 }
125 Chunk::ToolCallEnd { id } => {
126 if let Some(pos) = pending.iter().position(|tc| tc.id == id) {
130 out.tool_calls.push(pending.remove(pos));
131 }
132 }
133 Chunk::Usage(u) => out.usage = u,
134 Chunk::Stop(r) => {
146 let keep_tool_use =
147 out.stop == Some(StopReason::ToolUse) && matches!(r, StopReason::EndTurn);
148 if !keep_tool_use {
149 out.stop = Some(r);
150 }
151 }
152 }
153 }
154 out.tool_calls.append(&mut pending);
158 Ok(out)
159}
160
161pub const STUB_TOOL_CALL_ENV: &str = "POLYCHROME_STUB_TOOL_CALL";
169
170fn stub_tool_name() -> Option<String> {
171 std::env::var(STUB_TOOL_CALL_ENV)
172 .ok()
173 .filter(|s| !s.is_empty())
174}
175
176#[derive(Clone, Copy, Default)]
187pub struct StubProvider;
188
189#[async_trait]
190impl LlmProvider for StubProvider {
191 type Error = DummyError;
192
193 async fn complete(
194 &self,
195 req: CompletionRequest,
196 ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error> {
197 if let Some(tool_name) = stub_tool_name() {
201 let saw_result = req.messages.iter().any(|m| {
202 m.content
203 .iter()
204 .any(|c| matches!(c, crate::Content::ToolResult(_)))
205 });
206 if !saw_result {
207 let chunks = vec![
208 Ok(Chunk::tool_call_start("stub-call-1", &tool_name)),
209 Ok(Chunk::tool_call_args_delta("stub-call-1", "{}")),
210 Ok(Chunk::tool_call_end("stub-call-1")),
211 Ok(Chunk::Stop(StopReason::ToolUse)),
212 ];
213 return Ok(stream::iter(chunks).boxed());
214 }
215 }
216 let chunks = vec![
217 Ok(Chunk::text_delta("Hello from the ")),
218 Ok(Chunk::text_delta("stub provider.")),
219 Ok(Chunk::Usage(Usage {
220 input_tokens: 5,
221 output_tokens: 4,
222 ..Default::default()
223 })),
224 Ok(Chunk::Stop(StopReason::EndTurn)),
225 ];
226 Ok(stream::iter(chunks).boxed())
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
233
234 use super::*;
235
236 #[tokio::test]
237 async fn stub_provider_collects_into_text() {
238 let stream = StubProvider
239 .complete(CompletionRequest::new("stub"))
240 .await
241 .expect("stream opens");
242 let out = collect_turn(stream).await.expect("collect");
243 assert_eq!(out.text, "Hello from the stub provider.");
244 assert!(out.tool_calls.is_empty());
245 assert_eq!(out.usage.output_tokens, 4);
246 assert_eq!(out.stop, Some(StopReason::EndTurn));
247 }
248
249 #[tokio::test]
250 async fn collect_assembles_tool_call_from_deltas() {
251 let chunks: Vec<Result<Chunk, DummyError>> = vec![
252 Ok(Chunk::text_delta("calling ")),
253 Ok(Chunk::tool_call_start("c1", "search")),
254 Ok(Chunk::tool_call_args_delta("c1", r#"{"q":"#)),
255 Ok(Chunk::tool_call_args_delta("c1", r#""rust"}"#)),
256 Ok(Chunk::tool_call_end("c1")),
257 Ok(Chunk::Stop(StopReason::ToolUse)),
258 ];
259 let out = collect_turn(stream::iter(chunks)).await.expect("collect");
260 assert_eq!(out.text, "calling ");
261 assert_eq!(out.tool_calls.len(), 1);
262 assert_eq!(out.tool_calls[0].name, "search");
263 assert_eq!(out.tool_calls[0].args_json, r#"{"q":"rust"}"#);
264 assert_eq!(out.stop, Some(StopReason::ToolUse));
265 }
266
267 #[tokio::test]
268 async fn collect_keeps_parallel_tool_calls_with_deferred_ends() {
269 let chunks: Vec<Result<Chunk, DummyError>> = vec![
274 Ok(Chunk::tool_call_start("c0", "search")),
275 Ok(Chunk::tool_call_args_delta("c0", r#"{"q":"a"}"#)),
276 Ok(Chunk::tool_call_start("c1", "fetch")),
277 Ok(Chunk::tool_call_args_delta("c1", r#"{"u":"b"}"#)),
278 Ok(Chunk::tool_call_end("c0")),
279 Ok(Chunk::tool_call_end("c1")),
280 Ok(Chunk::Stop(StopReason::ToolUse)),
281 ];
282 let out = collect_turn(stream::iter(chunks)).await.expect("collect");
283 assert_eq!(out.tool_calls.len(), 2, "both parallel calls preserved");
284 assert_eq!(out.tool_calls[0].id, "c0");
285 assert_eq!(out.tool_calls[0].name, "search");
286 assert_eq!(out.tool_calls[0].args_json, r#"{"q":"a"}"#);
287 assert_eq!(out.tool_calls[1].id, "c1");
288 assert_eq!(out.tool_calls[1].name, "fetch");
289 assert_eq!(out.tool_calls[1].args_json, r#"{"u":"b"}"#);
290 assert_eq!(out.stop, Some(StopReason::ToolUse));
291 }
292
293 #[tokio::test]
294 async fn collect_flushes_a_call_left_open_at_eof() {
295 let chunks: Vec<Result<Chunk, DummyError>> = vec![
298 Ok(Chunk::tool_call_start("c0", "search")),
299 Ok(Chunk::tool_call_args_delta("c0", r#"{"q":"a"}"#)),
300 Ok(Chunk::Stop(StopReason::ToolUse)),
301 ];
302 let out = collect_turn(stream::iter(chunks)).await.expect("collect");
303 assert_eq!(out.tool_calls.len(), 1);
304 assert_eq!(out.tool_calls[0].args_json, r#"{"q":"a"}"#);
305 }
306
307 #[tokio::test]
308 async fn tool_use_stop_is_sticky_against_later_end_turn() {
309 let chunks: Vec<Result<Chunk, DummyError>> = vec![
313 Ok(Chunk::tool_call_start("c1", "search")),
314 Ok(Chunk::tool_call_end("c1")),
315 Ok(Chunk::Stop(StopReason::ToolUse)),
316 Ok(Chunk::Stop(StopReason::EndTurn)),
317 ];
318 let out = collect_turn(stream::iter(chunks)).await.expect("collect");
319 assert_eq!(out.stop, Some(StopReason::ToolUse));
320 }
321
322 #[tokio::test]
323 async fn hard_stop_wins_over_earlier_tool_use() {
324 let chunks: Vec<Result<Chunk, DummyError>> = vec![
327 Ok(Chunk::tool_call_start("c1", "search")),
328 Ok(Chunk::tool_call_end("c1")),
329 Ok(Chunk::Stop(StopReason::ToolUse)),
330 Ok(Chunk::Stop(StopReason::MaxTokens)),
331 ];
332 let out = collect_turn(stream::iter(chunks)).await.expect("collect");
333 assert_eq!(out.stop, Some(StopReason::MaxTokens));
334 }
335
336 #[tokio::test]
337 async fn collect_folds_reasoning_separately_from_text() {
338 let chunks: Vec<Result<Chunk, DummyError>> = vec![
340 Ok(Chunk::reasoning_delta("first ")),
341 Ok(Chunk::reasoning_delta("thought")),
342 Ok(Chunk::text_delta("the answer")),
343 Ok(Chunk::Stop(StopReason::EndTurn)),
344 ];
345 let out = collect_turn(stream::iter(chunks)).await.expect("collect");
346 assert_eq!(out.reasoning, "first thought");
347 assert_eq!(out.text, "the answer");
348 }
349
350 #[tokio::test]
351 async fn observed_reasoning_deltas_are_emitted() {
352 let chunks: Vec<Result<Chunk, DummyError>> = vec![
353 Ok(Chunk::reasoning_delta("hmm")),
354 Ok(Chunk::text_delta("ok")),
355 Ok(Chunk::Stop(StopReason::EndTurn)),
356 ];
357 let mut events = Vec::new();
358 let out = collect_turn_observed(stream::iter(chunks), |e| events.push(e))
359 .await
360 .expect("collect");
361 assert_eq!(out.reasoning, "hmm");
362 assert!(events.contains(&TurnStreamEvent::ReasoningDelta("hmm".to_owned())));
363 assert!(events.contains(&TurnStreamEvent::TextDelta("ok".to_owned())));
364 }
365
366 #[tokio::test]
367 async fn collect_propagates_error() {
368 let chunks: Vec<Result<Chunk, DummyError>> = vec![
369 Ok(Chunk::text_delta("partial")),
370 Err(DummyError::Other("mid-stream fault".to_owned())),
371 ];
372 let res = collect_turn(stream::iter(chunks)).await;
373 assert!(res.is_err());
374 }
375}