Skip to main content

polyc_llm/
turn.rs

1//! Turn helpers: a [`StubProvider`] for wiring/tests and [`collect_turn`],
2//! which folds a provider's [`Chunk`] stream into a single [`TurnOutput`].
3//!
4//! `collect_turn` is the output half of the bridge between this crate's
5//! streaming vocabulary and the message-granular wire types: the harness drains
6//! a provider stream into a `TurnOutput`, then maps that to wire `Message`s.
7
8use 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/// An incremental event observed while folding a turn, for live streaming.
16///
17/// Surfaces like Slack `chat.appendStream` or a streaming CLI consume these;
18/// the buffered [`TurnOutput`] is still returned in full — this is a side
19/// channel, not a replacement.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum TurnStreamEvent {
22    /// A freshly-generated piece of answer text (concatenate to reconstruct).
23    TextDelta(String),
24    /// A freshly-generated piece of model reasoning ("thinking") text, distinct
25    /// from the answer. Observers may render it as a collapsed thought; it is
26    /// never concatenated into the answer text.
27    ReasoningDelta(String),
28    /// The model has begun a tool call (`id` + `name` known up front).
29    ToolStarted {
30        /// Provider-assigned call id.
31        id: String,
32        /// Name of the tool being called.
33        name: String,
34    },
35}
36
37/// The fully-assembled result of one turn, folded from a [`Chunk`] stream.
38#[derive(Debug, Default, Clone)]
39pub struct TurnOutput {
40    /// Concatenated text deltas.
41    pub text: String,
42    /// Concatenated reasoning ("thinking") deltas, kept separate from `text`.
43    /// Empty for providers/models that don't expose reasoning.
44    pub reasoning: String,
45    /// Completed tool calls, in arrival order.
46    pub tool_calls: Vec<ToolCall>,
47    /// Final token accounting (last [`Chunk::Usage`] seen).
48    pub usage: Usage,
49    /// Why the turn ended, if the stream reported it.
50    pub stop: Option<StopReason>,
51}
52
53/// Drain a provider stream into a [`TurnOutput`].
54///
55/// Text deltas concatenate; a tool call accretes from its
56/// `ToolCallStart`/`ToolCallArgsDelta`/`ToolCallEnd` run (matched by `id`);
57/// usage and stop reason are taken from their chunks.
58///
59/// # Errors
60///
61/// Propagates the first `Err` item from the stream.
62pub 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, async |_| {}).await
67}
68
69/// Like [`collect_turn`], but observes each streamable event as it arrives.
70///
71/// Invokes `on_event` for each text delta / tool start while still folding and
72/// returning the complete [`TurnOutput`]. `on_event` is `async` and is
73/// `.await`ed in place before the next stream item is polled: a caller
74/// forwarding onto a bounded channel (`Sender::send`) genuinely applies
75/// backpressure here — a slow consumer on the other end stalls this fold
76/// (and, transitively, the provider stream poll loop) rather than letting
77/// events buffer without limit. Pass a caller-owned clone of a bounded sender
78/// (cloned ONCE outside this call, not per event — `futures::mpsc` grants
79/// every live sender its own reserved slot, so a fresh clone per event would
80/// silently defeat the bound).
81///
82/// # Errors
83///
84/// Propagates the first `Err` item from the stream.
85pub async fn collect_turn_observed<S, E, F>(mut stream: S, mut on_event: F) -> Result<TurnOutput, E>
86where
87    S: Stream<Item = Result<Chunk, E>> + Unpin,
88    F: AsyncFnMut(TurnStreamEvent),
89{
90    let mut out = TurnOutput::default();
91    // In-progress tool calls, kept in start order and matched by id. A provider
92    // may interleave several calls (OpenAI's `parallel_tool_calls` defaults to
93    // true) and/or defer all their `ToolCallEnd`s to the end of the stream, so a
94    // single `Option` would let a second `ToolCallStart` clobber the first and
95    // an `ToolCallEnd` close the wrong call. Matching by id throughout keeps
96    // every parallel call intact regardless of emission order.
97    let mut pending: Vec<ToolCall> = Vec::new();
98    while let Some(item) = stream.next().await {
99        match item? {
100            Chunk::TextDelta(s) => {
101                on_event(TurnStreamEvent::TextDelta(s.clone())).await;
102                out.text.push_str(&s);
103            }
104            Chunk::ReasoningDelta(s) => {
105                on_event(TurnStreamEvent::ReasoningDelta(s.clone())).await;
106                out.reasoning.push_str(&s);
107            }
108            Chunk::ToolCallStart {
109                id,
110                name,
111                signature,
112            } => {
113                on_event(TurnStreamEvent::ToolStarted {
114                    id: id.clone(),
115                    name: name.clone(),
116                })
117                .await;
118                pending.push(ToolCall {
119                    id,
120                    name,
121                    args_json: String::new(),
122                    signature,
123                });
124            }
125            Chunk::ToolCallArgsDelta {
126                id,
127                args_json_delta,
128            } => {
129                if let Some(tc) = pending.iter_mut().find(|tc| tc.id == id) {
130                    tc.args_json.push_str(&args_json_delta);
131                }
132            }
133            Chunk::ToolCallEnd { id } => {
134                // Move the matching call to the output in completion order. An
135                // unmatched id is ignored (defensive); calls still open at EOF
136                // are flushed after the loop so none are silently dropped.
137                if let Some(pos) = pending.iter().position(|tc| tc.id == id) {
138                    out.tool_calls.push(pending.remove(pos));
139                }
140            }
141            Chunk::Usage(u) => out.usage = u,
142            // A `ToolUse` stop is sticky against a *later* `EndTurn`. Some
143            // providers stream the tool call in one event and then a separate
144            // trailing terminator event carrying an end-of-turn finish reason;
145            // letting that later `EndTurn` overwrite the `ToolUse` stop would
146            // make the agent loop skip executing the tool and end the turn with
147            // no output.
148            //
149            // A *hard* stop (MaxTokens / Refusal / StopSequence) is the
150            // opposite: it means the turn was truncated or refused, so it must
151            // win over an earlier `ToolUse` — the tool call may be incomplete
152            // and must not be executed.
153            Chunk::Stop(r) => {
154                let keep_tool_use =
155                    out.stop == Some(StopReason::ToolUse) && matches!(r, StopReason::EndTurn);
156                if !keep_tool_use {
157                    out.stop = Some(r);
158                }
159            }
160        }
161    }
162    // Flush any call that started (and may have accreted args) but whose
163    // `ToolCallEnd` never arrived — a provider that omits the terminator must
164    // not lose the call.
165    out.tool_calls.append(&mut pending);
166    Ok(out)
167}
168
169/// Env var: emit a synthetic tool call for `<name>` on the stub provider.
170///
171/// First `complete()` of a turn emits a synthetic tool call for the named
172/// tool, subsequent calls (once a `tool_result` has landed in the
173/// transcript) fall back to canned `EndTurn` text. Empty / unset keeps the
174/// canned-text behaviour. Used by the HITL resume loopback verification to
175/// drive the data path without a real provider backend.
176pub const STUB_TOOL_CALL_ENV: &str = "POLYCHROME_STUB_TOOL_CALL";
177
178fn stub_tool_name() -> Option<String> {
179    std::env::var(STUB_TOOL_CALL_ENV)
180        .ok()
181        .filter(|s| !s.is_empty())
182}
183
184/// The stub's tool-call sequence: [`STUB_TOOL_CALL_ENV`] split on commas.
185///
186/// One name is the common case; a comma-separated list drives a multi-step
187/// turn (e.g. `read_tool,post_tool`), emitting the Nth tool after N tool
188/// results have landed. Whitespace around each name is trimmed.
189fn stub_tool_sequence() -> Vec<String> {
190    stub_tool_name()
191        .into_iter()
192        .flat_map(|s| {
193            s.split(',')
194                .map(str::trim)
195                .filter(|s| !s.is_empty())
196                .map(str::to_owned)
197                .collect::<Vec<_>>()
198        })
199        .collect()
200}
201
202/// The args-delta for the sequence tool `name` at step `idx`.
203///
204/// Contract, in precedence order:
205/// - [`STUB_TOOL_ARGS_ENV`] unset/empty → `"{}"`.
206/// - Single-tool sequence (`seq_len == 1`) → the whole env value verbatim
207///   (the original single-tool behavior).
208/// - Multi-tool sequence → the env value MUST be a JSON object keyed by tool
209///   name; return the entry for `name` serialized, or `"{}"` if absent.
210///
211/// Unvalidated scaffolding: whatever is returned reaches the tool as its args
212/// delta verbatim so the tool's own schema validation reports any mismatch.
213fn stub_tool_args_for(name: &str, seq_len: usize) -> String {
214    let Some(raw) = std::env::var(STUB_TOOL_ARGS_ENV)
215        .ok()
216        .filter(|s| !s.is_empty())
217    else {
218        return "{}".to_owned();
219    };
220    if seq_len <= 1 {
221        return raw;
222    }
223    match serde_json::from_str::<serde_json::Value>(&raw) {
224        Ok(serde_json::Value::Object(map)) => map
225            .get(name)
226            .map_or_else(|| "{}".to_owned(), ToString::to_string),
227        _ => "{}".to_owned(),
228    }
229}
230
231/// Env var: the JSON-object-literal args delta for the [`STUB_TOOL_CALL_ENV`]
232/// synthetic tool call.
233///
234/// This is wiring/test scaffolding, not a validated input: the value must be
235/// a JSON object literal matching the target tool's input schema. Invalid
236/// JSON is passed through verbatim as the args delta so the tool's own
237/// schema validation reports it downstream — this crate does no validation
238/// of its own. Empty / unset defaults to `"{}"`, matching a tool with no
239/// required arguments.
240pub const STUB_TOOL_ARGS_ENV: &str = "POLYCHROME_STUB_TOOL_ARGS";
241
242/// A canned [`LlmProvider`] for wiring and tests.
243///
244/// Emits two text deltas, a usage tally, and an end-of-turn stop. No
245/// network, no credentials.
246///
247/// When [`STUB_TOOL_CALL_ENV`] is set, the first `complete()` of a turn
248/// emits a synthetic tool call (id `stub-call-1`) for that tool name and
249/// the caller's function-calling loop drives the rest. Subsequent calls
250/// in the same turn fall back to the `EndTurn` text path. Used by the
251/// HITL resume loopback verification.
252///
253/// The call's args delta is `"{}"` unless [`STUB_TOOL_ARGS_ENV`] overrides
254/// it. This is wiring/test scaffolding: the override must be a JSON object
255/// literal matching the target tool's schema, and is passed through
256/// unvalidated.
257#[derive(Clone, Copy, Default)]
258pub struct StubProvider;
259
260#[async_trait]
261impl LlmProvider for StubProvider {
262    type Error = DummyError;
263
264    async fn complete(
265        &self,
266        req: CompletionRequest,
267    ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error> {
268        // If POLYCHROME_STUB_TOOL_CALL is set and we haven't yet seen a
269        // matching tool_result in the transcript, emit the synthetic tool
270        // call. Otherwise fall through to canned text.
271        let sequence = stub_tool_sequence();
272        if !sequence.is_empty() {
273            // Emit the Nth tool once N tool-results have landed, so a
274            // comma-separated sequence drives a multi-step turn deterministically
275            // (e.g. an open-world read that taints, then a gated egress). After
276            // the last tool's result, fall through to the canned end-turn text.
277            let results_seen = req
278                .messages
279                .iter()
280                .flat_map(|m| m.content.iter())
281                .filter(|c| matches!(c, crate::Content::ToolResult(_)))
282                .count();
283            if let Some(tool_name) = sequence.get(results_seen) {
284                let id = format!("stub-call-{}", results_seen + 1);
285                let chunks = vec![
286                    Ok(Chunk::tool_call_start(&id, tool_name)),
287                    Ok(Chunk::tool_call_args_delta(
288                        &id,
289                        stub_tool_args_for(tool_name, sequence.len()),
290                    )),
291                    Ok(Chunk::tool_call_end(&id)),
292                    Ok(Chunk::Stop(StopReason::ToolUse)),
293                ];
294                return Ok(stream::iter(chunks).boxed());
295            }
296        }
297        let chunks = vec![
298            Ok(Chunk::text_delta("Hello from the ")),
299            Ok(Chunk::text_delta("stub provider.")),
300            Ok(Chunk::Usage(Usage {
301                input_tokens: 5,
302                output_tokens: 4,
303                ..Default::default()
304            })),
305            Ok(Chunk::Stop(StopReason::EndTurn)),
306        ];
307        Ok(stream::iter(chunks).boxed())
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
314
315    use super::*;
316
317    #[tokio::test]
318    async fn stub_provider_collects_into_text() {
319        let stream = StubProvider
320            .complete(CompletionRequest::new("stub"))
321            .await
322            .expect("stream opens");
323        let out = collect_turn(stream).await.expect("collect");
324        assert_eq!(out.text, "Hello from the stub provider.");
325        assert!(out.tool_calls.is_empty());
326        assert_eq!(out.usage.output_tokens, 4);
327        assert_eq!(out.stop, Some(StopReason::EndTurn));
328    }
329
330    // With STUB_TOOL_ARGS_ENV set, the synthetic tool call's args delta is
331    // exactly that value — useless-against-a-strict-schema "{}" is only the
332    // fallback, not the only option.
333    #[tokio::test]
334    async fn stub_provider_emits_the_configured_tool_args() {
335        temp_env::async_with_vars(
336            [
337                (STUB_TOOL_CALL_ENV, Some("search")),
338                (STUB_TOOL_ARGS_ENV, Some(r#"{"q":"rust"}"#)),
339            ],
340            async {
341                let stream = StubProvider
342                    .complete(CompletionRequest::new("stub"))
343                    .await
344                    .expect("stream opens");
345                let out = collect_turn(stream).await.expect("collect");
346                assert_eq!(out.tool_calls.len(), 1);
347                assert_eq!(out.tool_calls[0].args_json, r#"{"q":"rust"}"#);
348            },
349        )
350        .await;
351    }
352
353    // With STUB_TOOL_ARGS_ENV unset, the synthetic tool call's args delta
354    // falls back to "{}" — today's behavior, preserved.
355    #[tokio::test]
356    async fn stub_provider_defaults_tool_args_to_empty_object() {
357        temp_env::async_with_vars(
358            [
359                (STUB_TOOL_CALL_ENV, Some("search")),
360                (STUB_TOOL_ARGS_ENV, None),
361            ],
362            async {
363                let stream = StubProvider
364                    .complete(CompletionRequest::new("stub"))
365                    .await
366                    .expect("stream opens");
367                let out = collect_turn(stream).await.expect("collect");
368                assert_eq!(out.tool_calls.len(), 1);
369                assert_eq!(out.tool_calls[0].args_json, "{}");
370            },
371        )
372        .await;
373    }
374
375    // With STUB_TOOL_CALL_ENV unset, the canned-text path is unaffected by
376    // the new args knob.
377    #[tokio::test]
378    async fn stub_provider_canned_text_path_is_unchanged_by_the_args_knob() {
379        temp_env::async_with_vars(
380            [
381                (STUB_TOOL_CALL_ENV, None),
382                (STUB_TOOL_ARGS_ENV, Some(r#"{"q":"rust"}"#)),
383            ],
384            async {
385                let stream = StubProvider
386                    .complete(CompletionRequest::new("stub"))
387                    .await
388                    .expect("stream opens");
389                let out = collect_turn(stream).await.expect("collect");
390                assert_eq!(out.text, "Hello from the stub provider.");
391                assert!(out.tool_calls.is_empty());
392            },
393        )
394        .await;
395    }
396
397    #[tokio::test]
398    async fn collect_assembles_tool_call_from_deltas() {
399        let chunks: Vec<Result<Chunk, DummyError>> = vec![
400            Ok(Chunk::text_delta("calling ")),
401            Ok(Chunk::tool_call_start("c1", "search")),
402            Ok(Chunk::tool_call_args_delta("c1", r#"{"q":"#)),
403            Ok(Chunk::tool_call_args_delta("c1", r#""rust"}"#)),
404            Ok(Chunk::tool_call_end("c1")),
405            Ok(Chunk::Stop(StopReason::ToolUse)),
406        ];
407        let out = collect_turn(stream::iter(chunks)).await.expect("collect");
408        assert_eq!(out.text, "calling ");
409        assert_eq!(out.tool_calls.len(), 1);
410        assert_eq!(out.tool_calls[0].name, "search");
411        assert_eq!(out.tool_calls[0].args_json, r#"{"q":"rust"}"#);
412        assert_eq!(out.stop, Some(StopReason::ToolUse));
413    }
414
415    #[tokio::test]
416    async fn collect_keeps_parallel_tool_calls_with_deferred_ends() {
417        // Two interleaved calls whose `ToolCallEnd`s are both deferred to the
418        // end of the stream (the OpenAI-compatible provider's shape). A single
419        // `Option` would drop call 0 and close the survivor with the wrong end;
420        // id-matching must preserve both, in completion order.
421        let chunks: Vec<Result<Chunk, DummyError>> = vec![
422            Ok(Chunk::tool_call_start("c0", "search")),
423            Ok(Chunk::tool_call_args_delta("c0", r#"{"q":"a"}"#)),
424            Ok(Chunk::tool_call_start("c1", "fetch")),
425            Ok(Chunk::tool_call_args_delta("c1", r#"{"u":"b"}"#)),
426            Ok(Chunk::tool_call_end("c0")),
427            Ok(Chunk::tool_call_end("c1")),
428            Ok(Chunk::Stop(StopReason::ToolUse)),
429        ];
430        let out = collect_turn(stream::iter(chunks)).await.expect("collect");
431        assert_eq!(out.tool_calls.len(), 2, "both parallel calls preserved");
432        assert_eq!(out.tool_calls[0].id, "c0");
433        assert_eq!(out.tool_calls[0].name, "search");
434        assert_eq!(out.tool_calls[0].args_json, r#"{"q":"a"}"#);
435        assert_eq!(out.tool_calls[1].id, "c1");
436        assert_eq!(out.tool_calls[1].name, "fetch");
437        assert_eq!(out.tool_calls[1].args_json, r#"{"u":"b"}"#);
438        assert_eq!(out.stop, Some(StopReason::ToolUse));
439    }
440
441    #[tokio::test]
442    async fn collect_flushes_a_call_left_open_at_eof() {
443        // A provider that omits the terminal `ToolCallEnd` must not lose the
444        // call — it is flushed when the stream ends.
445        let chunks: Vec<Result<Chunk, DummyError>> = vec![
446            Ok(Chunk::tool_call_start("c0", "search")),
447            Ok(Chunk::tool_call_args_delta("c0", r#"{"q":"a"}"#)),
448            Ok(Chunk::Stop(StopReason::ToolUse)),
449        ];
450        let out = collect_turn(stream::iter(chunks)).await.expect("collect");
451        assert_eq!(out.tool_calls.len(), 1);
452        assert_eq!(out.tool_calls[0].args_json, r#"{"q":"a"}"#);
453    }
454
455    #[tokio::test]
456    async fn tool_use_stop_is_sticky_against_later_end_turn() {
457        // Provider streams the tool call (ToolUse) then a trailing terminator
458        // event (EndTurn). The terminator must NOT clobber ToolUse, else the
459        // agent loop skips the tool.
460        let chunks: Vec<Result<Chunk, DummyError>> = vec![
461            Ok(Chunk::tool_call_start("c1", "search")),
462            Ok(Chunk::tool_call_end("c1")),
463            Ok(Chunk::Stop(StopReason::ToolUse)),
464            Ok(Chunk::Stop(StopReason::EndTurn)),
465        ];
466        let out = collect_turn(stream::iter(chunks)).await.expect("collect");
467        assert_eq!(out.stop, Some(StopReason::ToolUse));
468    }
469
470    #[tokio::test]
471    async fn hard_stop_wins_over_earlier_tool_use() {
472        // A later MaxTokens (truncation) MUST override an earlier ToolUse so
473        // the agent doesn't execute a tool call with truncated arguments.
474        let chunks: Vec<Result<Chunk, DummyError>> = vec![
475            Ok(Chunk::tool_call_start("c1", "search")),
476            Ok(Chunk::tool_call_end("c1")),
477            Ok(Chunk::Stop(StopReason::ToolUse)),
478            Ok(Chunk::Stop(StopReason::MaxTokens)),
479        ];
480        let out = collect_turn(stream::iter(chunks)).await.expect("collect");
481        assert_eq!(out.stop, Some(StopReason::MaxTokens));
482    }
483
484    #[tokio::test]
485    async fn collect_folds_reasoning_separately_from_text() {
486        // Reasoning deltas accumulate into `reasoning`, never into `text`.
487        let chunks: Vec<Result<Chunk, DummyError>> = vec![
488            Ok(Chunk::reasoning_delta("first ")),
489            Ok(Chunk::reasoning_delta("thought")),
490            Ok(Chunk::text_delta("the answer")),
491            Ok(Chunk::Stop(StopReason::EndTurn)),
492        ];
493        let out = collect_turn(stream::iter(chunks)).await.expect("collect");
494        assert_eq!(out.reasoning, "first thought");
495        assert_eq!(out.text, "the answer");
496    }
497
498    #[tokio::test]
499    async fn observed_reasoning_deltas_are_emitted() {
500        let chunks: Vec<Result<Chunk, DummyError>> = vec![
501            Ok(Chunk::reasoning_delta("hmm")),
502            Ok(Chunk::text_delta("ok")),
503            Ok(Chunk::Stop(StopReason::EndTurn)),
504        ];
505        let mut events = Vec::new();
506        let out = collect_turn_observed(stream::iter(chunks), async |e| events.push(e))
507            .await
508            .expect("collect");
509        assert_eq!(out.reasoning, "hmm");
510        assert!(events.contains(&TurnStreamEvent::ReasoningDelta("hmm".to_owned())));
511        assert!(events.contains(&TurnStreamEvent::TextDelta("ok".to_owned())));
512    }
513
514    #[tokio::test]
515    async fn collect_propagates_error() {
516        let chunks: Vec<Result<Chunk, DummyError>> = vec![
517            Ok(Chunk::text_delta("partial")),
518            Err(DummyError::Other("mid-stream fault".to_owned())),
519        ];
520        let res = collect_turn(stream::iter(chunks)).await;
521        assert!(res.is_err());
522    }
523
524    /// `#251`: `collect_turn_observed` folding onto a bounded channel must
525    /// genuinely stall when the channel is full and undrained — the whole
526    /// point of switching `on_event` to `AsyncFnMut` is that a caller's
527    /// `Sender::send(..).await` blocks the fold (and, transitively, stops
528    /// polling the underlying provider stream) instead of buffering without
529    /// limit. Proven two ways: the wrapped stream's poll count plateaus while
530    /// the channel is full, and the fold only completes after the channel
531    /// drains.
532    #[tokio::test]
533    async fn collect_turn_observed_backpressures_on_a_full_bounded_channel() {
534        use std::pin::Pin;
535        use std::sync::Arc;
536        use std::sync::atomic::{AtomicUsize, Ordering};
537        use std::task::{Context, Poll};
538
539        use futures::SinkExt;
540
541        /// Counts every `poll_next` call on the wrapped stream, so the test
542        /// can observe that the fold has stopped driving the stream forward
543        /// (not merely that the spawned task hasn't been scheduled yet).
544        struct CountingStream<S> {
545            inner: S,
546            polls: Arc<AtomicUsize>,
547        }
548
549        impl<S: Stream + Unpin> Stream for CountingStream<S> {
550            type Item = S::Item;
551            fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
552                self.polls.fetch_add(1, Ordering::SeqCst);
553                let this = self.get_mut();
554                Pin::new(&mut this.inner).poll_next(cx)
555            }
556        }
557
558        let chunks: Vec<Result<Chunk, DummyError>> = vec![
559            Ok(Chunk::text_delta("a")),
560            Ok(Chunk::text_delta("b")),
561            Ok(Chunk::text_delta("c")),
562            Ok(Chunk::Stop(StopReason::EndTurn)),
563        ];
564        let polls = Arc::new(AtomicUsize::new(0));
565        let stream = CountingStream {
566            inner: stream::iter(chunks),
567            polls: polls.clone(),
568        };
569
570        // Capacity 1 with a single, never-cloned sender: `futures::mpsc`
571        // grants every live `Sender` a guaranteed slot on top of the shared
572        // buffer, so with exactly one sender the channel absorbs 2 events
573        // before a 3rd send blocks. The plan for this change is explicit that
574        // cloning the sender per event (instead of once, reused) would give
575        // each clone its own slot and silently defeat the bound — this test's
576        // closure captures `tx` by move and reuses the same instance.
577        let (tx, mut rx) = futures::channel::mpsc::channel::<TurnStreamEvent>(1);
578        let handle = tokio::spawn(async move {
579            let mut tx = tx;
580            collect_turn_observed(stream, async move |ev| {
581                let _ = tx.send(ev).await;
582            })
583            .await
584        });
585
586        // Let the fold run until it genuinely stalls on the full channel.
587        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
588        assert!(
589            !handle.is_finished(),
590            "fold must not complete while the channel is full and undrained"
591        );
592        let stalled_at = polls.load(Ordering::SeqCst);
593        assert!(
594            stalled_at < 4,
595            "stream must not have been fully drained while the channel is full"
596        );
597        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
598        assert_eq!(
599            polls.load(Ordering::SeqCst),
600            stalled_at,
601            "poll count must plateau while the channel is full — proof the stall is real"
602        );
603
604        // Draining unblocks the stalled send, letting the fold resume and finish.
605        let mut texts = Vec::new();
606        while texts.len() < 3 {
607            match rx.next().await {
608                Some(TurnStreamEvent::TextDelta(s)) => texts.push(s),
609                Some(_) => {}
610                None => break,
611            }
612        }
613        let out = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
614            .await
615            .expect("fold must complete once the channel drains")
616            .expect("task join")
617            .expect("collect");
618        assert_eq!(out.text, "abc");
619        assert_eq!(texts, vec!["a".to_owned(), "b".to_owned(), "c".to_owned()]);
620    }
621}