mermaid_cli/models/stream.rs
1//! Typed streaming events emitted by model adapters.
2//!
3//! Replaces the legacy `StreamCallback = Arc<dyn Fn(&str)>` text-only
4//! callback. The typed event surface lets future adapters (Anthropic,
5//! OpenAI, etc.) emit reasoning chunks, tool calls, and completion
6//! signals as first-class events instead of stuffing them into the text
7//! channel. Roo Code (`src/api/transform/stream.rs`) and OpenCode
8//! (`provider/processor.ts`) both validated this pattern as the way out
9//! of per-provider stream-shape sniffing.
10//!
11//! For Step 1 the only adapter is Ollama; Wave 3 wires it to emit these
12//! events. Earlier waves carry the legacy text callback through a default
13//! impl that synthesizes `Text` + `Done` events from the existing
14//! callback shape — see `Model::chat_typed` in `traits.rs`.
15
16use std::sync::Arc;
17
18use super::reasoning::ReasoningChunk;
19use super::tool_call::ToolCall;
20
21/// A single event emitted during a streaming model call.
22///
23/// Adapters MUST emit `Done` exactly once at the end of a successful
24/// stream. `Text` and `Reasoning` may interleave in any order. `ToolCall`
25/// events typically arrive at the end of generation but the contract is
26/// "before `Done`".
27#[derive(Debug, Clone)]
28pub enum StreamEvent {
29 /// Plain assistant content. Append to the response buffer.
30 Text(String),
31 /// Reasoning / thinking content. Render separately from regular text;
32 /// renderer decides whether to display or hide based on user prefs.
33 Reasoning(ReasoningChunk),
34 /// A tool/function call extracted from the model response.
35 ToolCall(ToolCall),
36 /// Out-of-band, user-visible plumbing notice (e.g. "Starting the local
37 /// Ollama server…"). NOT response content: surfaces as a transient /
38 /// system line, never appended to the assistant message. May arrive
39 /// before any `Text`.
40 Status(String),
41 /// Stream complete. Carries the total token usage if reported by the
42 /// provider; `0` if unavailable (still meaningful — the caller knows
43 /// generation finished).
44 Done { tokens: usize },
45}
46
47/// Callback invoked once per `StreamEvent` during a chat request.
48///
49/// `Send + Sync` so the callback can be shared across spawned tasks.
50pub type StreamCallback = Arc<dyn Fn(StreamEvent) + Send + Sync>;
51
52#[cfg(test)]
53mod tests {
54 use super::*;
55
56 #[test]
57 fn stream_event_clone() {
58 let ev = StreamEvent::Text("hello".to_string());
59 let cloned = ev.clone();
60 match (ev, cloned) {
61 (StreamEvent::Text(a), StreamEvent::Text(b)) => assert_eq!(a, b),
62 _ => panic!("clone should produce same variant"),
63 }
64 }
65
66 #[test]
67 fn stream_event_done_carries_tokens() {
68 let ev = StreamEvent::Done { tokens: 42 };
69 match ev {
70 StreamEvent::Done { tokens } => assert_eq!(tokens, 42),
71 _ => panic!("expected Done"),
72 }
73 }
74
75 #[test]
76 fn stream_event_reasoning_with_chunk() {
77 let chunk = ReasoningChunk {
78 text: "weighing options".to_string(),
79 signature: None,
80 };
81 let ev = StreamEvent::Reasoning(chunk.clone());
82 match ev {
83 StreamEvent::Reasoning(c) => {
84 assert_eq!(c.text, chunk.text);
85 assert_eq!(c.signature, chunk.signature);
86 },
87 _ => panic!("expected Reasoning"),
88 }
89 }
90
91 #[test]
92 fn callback_is_send_sync() {
93 // Compile-time: callback must satisfy Send + Sync to be carried
94 // through tokio::spawn boundaries in the agent loop.
95 fn assert_send_sync<T: Send + Sync>() {}
96 assert_send_sync::<StreamCallback>();
97 }
98}