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}