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("sig".to_string())),
146            stop_reason: Some(FinishReason::ToolUse),
147        };
148        match ev {
149            StreamEvent::Done {
150                usage,
151                provider_continuation,
152                stop_reason,
153            } => {
154                assert_eq!(usage.expect("usage").total_tokens(), 30);
155                assert!(matches!(
156                    provider_continuation,
157                    Some(ProviderContinuation::Anthropic { .. })
158                ));
159                assert_eq!(stop_reason, Some(FinishReason::ToolUse));
160            },
161            _ => panic!("expected Done"),
162        }
163    }
164
165    #[test]
166    fn stream_event_reasoning_with_chunk() {
167        let chunk = ReasoningChunk {
168            text: "weighing options".to_string(),
169            signature: None,
170        };
171        let ev = StreamEvent::Reasoning(chunk.clone());
172        match ev {
173            StreamEvent::Reasoning(c) => {
174                assert_eq!(c.text, chunk.text);
175                assert_eq!(c.signature, chunk.signature);
176            },
177            _ => panic!("expected Reasoning"),
178        }
179    }
180
181    #[test]
182    fn sink_is_send_sync() {
183        // Compile-time: the sink must satisfy Send + Sync to be carried
184        // through tokio::spawn boundaries in the agent loop.
185        fn assert_send_sync<T: Send + Sync>() {}
186        assert_send_sync::<StreamSink>();
187        assert_send_sync::<StatusNotify>();
188    }
189
190    #[tokio::test]
191    async fn emit_all_preserves_order_and_applies_backpressure() {
192        // The whole F2 guarantee, in one loop: a batch produced by sync wire
193        // parsing arrives in the order it was produced. The capacity-1 sink
194        // also pins the second half of the claim — `emit_all` cannot run
195        // ahead of a slow consumer, so a late `Done` can never overtake a
196        // still-queued `ToolCall`.
197        let (tx, mut rx) = mpsc::channel::<StreamEvent>(1);
198        let batch = vec![
199            StreamEvent::Text("a".to_string()),
200            StreamEvent::Reasoning(ReasoningChunk {
201                text: "r".to_string(),
202                signature: None,
203            }),
204            StreamEvent::Text("b".to_string()),
205            StreamEvent::Done {
206                usage: None,
207                provider_continuation: None,
208                stop_reason: None,
209            },
210        ];
211        let producer = tokio::spawn(async move { emit_all(Some(&tx), batch).await });
212
213        let mut seen = Vec::new();
214        while let Some(event) = rx.recv().await {
215            seen.push(match event {
216                StreamEvent::Text(s) => s,
217                StreamEvent::Reasoning(c) => c.text,
218                StreamEvent::Done { .. } => "done".to_string(),
219                StreamEvent::ToolCall(_) | StreamEvent::Status(_) => "other".to_string(),
220            });
221        }
222        producer.await.expect("join").expect("emit_all");
223        assert_eq!(seen, vec!["a", "r", "b", "done"]);
224    }
225
226    #[tokio::test]
227    async fn emit_stops_the_read_loop_once_the_receiver_is_gone() {
228        // A dropped receiver means the turn is over. Pulling more bytes off
229        // the wire for a response nobody will read is waste, so this is an
230        // error the adapter propagates rather than a silently skipped send.
231        let (tx, rx) = mpsc::channel::<StreamEvent>(4);
232        drop(rx);
233        let err = emit(Some(&tx), StreamEvent::Text("x".to_string()))
234            .await
235            .expect_err("closed receiver");
236        assert!(matches!(err, ModelError::StreamError(_)));
237    }
238
239    #[tokio::test]
240    async fn emit_without_a_sink_is_a_no_op() {
241        // `chat` with no sink is the non-streaming path; helpers shared with
242        // the streaming one must not have to branch on it themselves.
243        emit(None, StreamEvent::Text("x".to_string()))
244            .await
245            .expect("no sink");
246    }
247}