Skip to main content

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}