Skip to main content

mermaid_model/models/
stream.rs

1//! Typed streaming events emitted by model adapters.
2//!
3//! The typed event surface is what lets adapters emit reasoning chunks,
4//! tool calls, and completion signals as first-class events instead of
5//! stuffing them into a text channel. Roo Code
6//! (`src/api/transform/stream.rs`) and OpenCode (`provider/processor.ts`)
7//! both validated this pattern as the way out of per-provider stream-shape
8//! sniffing.
9//!
10//! This is the ONE stream event type, and `providers::ctx` re-exports it
11//! rather than defining a second. A twin with a poorer `Done` needs a
12//! translation layer to reach the effect layer, and a translation layer
13//! cannot invent what its input never carried — which is how an opaque
14//! provider continuation ends up as `None` and extended thinking stops
15//! continuing across turns. `Done` here carries the whole terminal
16//! payload, so there is nothing to translate.
17//!
18//! Events reach the turn through [`StreamSink`] — the effect layer's own
19//! bounded `mpsc::Sender`, handed to the adapter as-is. There is no callback
20//! and no staging channel in between: an adapter's read loop is already
21//! `await`ing `stream.next()`, so it can `await` the send on the line below.
22
23use tokio::sync::mpsc;
24
25use super::error::{ModelError, Result};
26use super::reasoning::ReasoningChunk;
27use super::tool_call::ToolCall;
28use super::types::{FinishReason, ProviderContinuation, TokenUsage};
29
30/// A single event emitted during a streaming model call.
31///
32/// Exactly one `Done` ends a successful stream. `Text` and `Reasoning` may
33/// interleave in any order. `ToolCall` events typically arrive at the end
34/// of generation but the contract is "before `Done`".
35///
36/// Adapters themselves never emit `Done` — the provider wrapper builds the
37/// authoritative one from the returned `ModelResponse`, which is where the
38/// usage and the provider continuation actually live (F3).
39#[derive(Debug, Clone)]
40pub enum StreamEvent {
41    /// Plain assistant content. Append to the response buffer.
42    Text(String),
43    /// Reasoning / thinking content. Render separately from regular text;
44    /// renderer decides whether to display or hide based on user prefs.
45    Reasoning(ReasoningChunk),
46    /// A tool/function call extracted from the model response.
47    ToolCall(ToolCall),
48    /// Out-of-band, user-visible plumbing notice (e.g. "Starting the local
49    /// Ollama server…"). NOT response content: surfaces as a transient /
50    /// system line, never appended to the assistant message. May arrive
51    /// before any `Text`.
52    Status(String),
53    /// Stream complete. Carries final token usage (`None` when the provider
54    /// never reported any, so the reducer keeps its estimate rather than
55    /// resetting the gauge to zero), any opaque provider continuation state
56    /// to round-trip on the next request, and why generation stopped (so the
57    /// reducer can flag truncation or a content block).
58    Done {
59        usage: Option<TokenUsage>,
60        provider_continuation: Option<ProviderContinuation>,
61        stop_reason: Option<FinishReason>,
62    },
63}
64
65/// Where a streaming chat's events go: the turn's bounded channel, owned by
66/// the effect layer.
67///
68/// Bounded on purpose. `await`ing the send between reads is the backpressure
69/// — a consumer that falls behind stalls the adapter's read loop, and the
70/// provider's TCP window fills instead of a queue growing in memory.
71pub type StreamSink = mpsc::Sender<StreamEvent>;
72
73/// Best-effort out-of-band notice, for the one place that has to report
74/// before a stream exists: Ollama's local-server autostart, which can block
75/// ~15s behind an otherwise bare spinner.
76///
77/// A `&str` and not a [`StreamEvent`] because the notice has exactly one
78/// shape and two destinations — the turn's sink during a chat, stderr on the
79/// pre-TUI console paths — and neither wants the other's plumbing. Same shape
80/// [`crate::models::adapters::ollama::LocalServerRecovery`] already uses.
81pub type StatusNotify = std::sync::Arc<dyn Fn(&str) + Send + Sync>;
82
83/// Forward one event to the turn's sink, if the caller supplied one.
84///
85/// # Errors
86///
87/// [`ModelError::StreamError`] once the receiver is gone — the turn was
88/// cancelled or the runner is shutting down. Reading further bytes for a
89/// response nobody will see is waste, so this is a stop and not a skip; the
90/// wrapper's `select!` on the cancellation token reports the common case as
91/// [`ModelError::Cancelled`] before this can fire.
92pub async fn emit(sink: Option<&StreamSink>, event: StreamEvent) -> Result<()> {
93    let Some(sink) = sink else {
94        return Ok(());
95    };
96    sink.send(event)
97        .await
98        .map_err(|_| ModelError::StreamError("stream receiver closed".to_string()))
99}
100
101/// [`emit`] for a batch, in order.
102///
103/// The ordering guarantee the adapters need is this loop and nothing else:
104/// events are produced into a `Vec` by synchronous wire parsing and drained
105/// here. The predecessor spent an unbounded staging channel, a spawned relay
106/// task and an abort guard per turn to get the same property back after a
107/// `tokio::spawn` per event had taken it away (F2).
108///
109/// # Errors
110///
111/// [`emit`]'s, on the first event the closed receiver rejects.
112pub async fn emit_all(
113    sink: Option<&StreamSink>,
114    events: impl IntoIterator<Item = StreamEvent>,
115) -> Result<()> {
116    for event in events {
117        emit(sink, event).await?;
118    }
119    Ok(())
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn stream_event_clone() {
128        let ev = StreamEvent::Text("hello".to_string());
129        let cloned = ev.clone();
130        match (ev, cloned) {
131            (StreamEvent::Text(a), StreamEvent::Text(b)) => assert_eq!(a, b),
132            _ => panic!("clone should produce same variant"),
133        }
134    }
135
136    #[test]
137    fn stream_event_done_carries_the_whole_terminal_payload() {
138        // The reason this type is shared rather than mapped: `Done` has to
139        // reach the reducer with the continuation intact. The adapter-side
140        // `Done` used to carry a bare `tokens: usize`, so anything mapped
141        // from it lost all three of these, and the wrapper had to route its
142        // authoritative `Done` around the mapping to compensate.
143        let ev = StreamEvent::Done {
144            usage: Some(TokenUsage::provider(10, 20)),
145            provider_continuation: Some(ProviderContinuation::Anthropic {
146                signature: "sig".to_string(),
147            }),
148            stop_reason: Some(FinishReason::ToolUse),
149        };
150        match ev {
151            StreamEvent::Done {
152                usage,
153                provider_continuation,
154                stop_reason,
155            } => {
156                assert_eq!(usage.expect("usage").total_tokens(), 30);
157                assert!(matches!(
158                    provider_continuation,
159                    Some(ProviderContinuation::Anthropic { .. })
160                ));
161                assert_eq!(stop_reason, Some(FinishReason::ToolUse));
162            },
163            _ => panic!("expected Done"),
164        }
165    }
166
167    #[test]
168    fn stream_event_reasoning_with_chunk() {
169        let chunk = ReasoningChunk {
170            text: "weighing options".to_string(),
171            signature: None,
172        };
173        let ev = StreamEvent::Reasoning(chunk.clone());
174        match ev {
175            StreamEvent::Reasoning(c) => {
176                assert_eq!(c.text, chunk.text);
177                assert_eq!(c.signature, chunk.signature);
178            },
179            _ => panic!("expected Reasoning"),
180        }
181    }
182
183    #[test]
184    fn sink_is_send_sync() {
185        // Compile-time: the sink must satisfy Send + Sync to be carried
186        // through tokio::spawn boundaries in the agent loop.
187        fn assert_send_sync<T: Send + Sync>() {}
188        assert_send_sync::<StreamSink>();
189        assert_send_sync::<StatusNotify>();
190    }
191
192    #[tokio::test]
193    async fn emit_all_preserves_order_and_applies_backpressure() {
194        // The whole F2 guarantee, in one loop: a batch produced by sync wire
195        // parsing arrives in the order it was produced. The capacity-1 sink
196        // also pins the second half of the claim — `emit_all` cannot run
197        // ahead of a slow consumer, so a late `Done` can never overtake a
198        // still-queued `ToolCall`.
199        let (tx, mut rx) = mpsc::channel::<StreamEvent>(1);
200        let batch = vec![
201            StreamEvent::Text("a".to_string()),
202            StreamEvent::Reasoning(ReasoningChunk {
203                text: "r".to_string(),
204                signature: None,
205            }),
206            StreamEvent::Text("b".to_string()),
207            StreamEvent::Done {
208                usage: None,
209                provider_continuation: None,
210                stop_reason: None,
211            },
212        ];
213        let producer = tokio::spawn(async move { emit_all(Some(&tx), batch).await });
214
215        let mut seen = Vec::new();
216        while let Some(event) = rx.recv().await {
217            seen.push(match event {
218                StreamEvent::Text(s) => s,
219                StreamEvent::Reasoning(c) => c.text,
220                StreamEvent::Done { .. } => "done".to_string(),
221                StreamEvent::ToolCall(_) | StreamEvent::Status(_) => "other".to_string(),
222            });
223        }
224        producer.await.expect("join").expect("emit_all");
225        assert_eq!(seen, vec!["a", "r", "b", "done"]);
226    }
227
228    #[tokio::test]
229    async fn emit_stops_the_read_loop_once_the_receiver_is_gone() {
230        // A dropped receiver means the turn is over. Pulling more bytes off
231        // the wire for a response nobody will read is waste, so this is an
232        // error the adapter propagates rather than a silently skipped send.
233        let (tx, rx) = mpsc::channel::<StreamEvent>(4);
234        drop(rx);
235        let err = emit(Some(&tx), StreamEvent::Text("x".to_string()))
236            .await
237            .expect_err("closed receiver");
238        assert!(matches!(err, ModelError::StreamError(_)));
239    }
240
241    #[tokio::test]
242    async fn emit_without_a_sink_is_a_no_op() {
243        // `chat` with no sink is the non-streaming path; helpers shared with
244        // the streaming one must not have to branch on it themselves.
245        emit(None, StreamEvent::Text("x".to_string()))
246            .await
247            .expect("no sink");
248    }
249}