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 /// Stream complete. Carries the total token usage if reported by the
37 /// provider; `0` if unavailable (still meaningful — the caller knows
38 /// generation finished).
39 Done { tokens: usize },
40}
41
42/// Callback invoked once per `StreamEvent` during a chat request.
43///
44/// `Send + Sync` so the callback can be shared across spawned tasks.
45pub type StreamCallback = Arc<dyn Fn(StreamEvent) + Send + Sync>;
46
47#[cfg(test)]
48mod tests {
49 use super::*;
50
51 #[test]
52 fn stream_event_clone() {
53 let ev = StreamEvent::Text("hello".to_string());
54 let cloned = ev.clone();
55 match (ev, cloned) {
56 (StreamEvent::Text(a), StreamEvent::Text(b)) => assert_eq!(a, b),
57 _ => panic!("clone should produce same variant"),
58 }
59 }
60
61 #[test]
62 fn stream_event_done_carries_tokens() {
63 let ev = StreamEvent::Done { tokens: 42 };
64 match ev {
65 StreamEvent::Done { tokens } => assert_eq!(tokens, 42),
66 _ => panic!("expected Done"),
67 }
68 }
69
70 #[test]
71 fn stream_event_reasoning_with_chunk() {
72 let chunk = ReasoningChunk {
73 text: "weighing options".to_string(),
74 signature: None,
75 };
76 let ev = StreamEvent::Reasoning(chunk.clone());
77 match ev {
78 StreamEvent::Reasoning(c) => {
79 assert_eq!(c.text, chunk.text);
80 assert_eq!(c.signature, chunk.signature);
81 },
82 _ => panic!("expected Reasoning"),
83 }
84 }
85
86 #[test]
87 fn callback_is_send_sync() {
88 // Compile-time: callback must satisfy Send + Sync to be carried
89 // through tokio::spawn boundaries in the agent loop.
90 fn assert_send_sync<T: Send + Sync>() {}
91 assert_send_sync::<StreamCallback>();
92 }
93}