rpi_ai/event_stream.rs
1//! Mirrors `packages/ai/src/utils/event-stream.ts` — the SPSC async event
2//! stream that carries `AssistantMessageEvent`s from a provider to the agent
3//! loop, plus the `result()` future resolving to the final `AssistantMessage`.
4//!
5//! The TS `EventStream<T,R>` is a single-producer/single-consumer async queue
6//! with an attached final-result promise. The Rust port models it as:
7//! - an `mpsc::unbounded_channel::<AssistantMessageEvent>` for the events;
8//! - a `oneshot::channel::<AssistantMessage>` for the terminal result.
9//!
10//! `StreamFn` returns synchronously (no `await`); the producer spawns its own
11//! task that pushes events and — on the first `Done`/`Error` — sends the
12//! carried `AssistantMessage` via the oneshot (first wins; later sends are
13//! dropped). Failures are encoded as `Error` events, never panics.
14
15use crate::types::{AssistantMessage, AssistantMessageEvent};
16use tokio::sync::{mpsc, oneshot};
17
18/// The consumer side of an `AssistantMessageEventStream`. Mirrors the
19/// `AssistantMessageEventStream` class: an async iterator over
20/// `AssistantMessageEvent` plus a `result()` future.
21///
22/// Cloneable so multiple subscribers can drain independently via
23/// `tokio::sync::broadcast` at a higher layer; the raw channel is SPSC but the
24/// agent loop wraps it so that's rarely needed.
25pub struct AssistantMessageEventStream {
26 rx: mpsc::UnboundedReceiver<AssistantMessageEvent>,
27 result_rx: oneshot::Receiver<AssistantMessage>,
28 /// Set true once a terminal `Done`/`Error` event is delivered. Mirrors the
29 /// TS `done` flag: subsequent `next()` calls return `None` immediately, so a
30 /// still-alive producer can't keep the consumer's `recv()`Blocked forever.
31 done: bool,
32}
33
34impl AssistantMessageEventStream {
35 /// Asynchronously pull the next event, or `None` once the stream is
36 /// exhausted (after the terminal `Done`/`Error`). Mirrors the TS
37 /// async-iterator's `next()`.
38 pub async fn next(&mut self) -> Option<AssistantMessageEvent> {
39 if self.done {
40 return None;
41 }
42 let event = self.rx.recv().await?;
43 if event.is_terminal() {
44 self.done = true;
45 }
46 Some(event)
47 }
48
49 /// Resolve to the final `AssistantMessage` — the message carried by the
50 /// terminal `Done` (success) or `Error` (failure). Mirrors TS `result()`.
51 ///
52 /// Cancellation / producer-drop surfaces as a `RecvError`, mapped to a
53 /// terminal error message. A well-behaved producer always sends one
54 /// terminal event, so the happy path never hits that branch.
55 pub async fn result(self) -> Result<AssistantMessage, RecvError> {
56 self.result_rx.await.map_err(|_| RecvError)
57 }
58
59 /// Borrow both channels for ad-hoc awaiting (used by the agent loop when
60 /// it needs to race the event queue against a cancellation token).
61 pub fn split(
62 self,
63 ) -> (
64 mpsc::UnboundedReceiver<AssistantMessageEvent>,
65 oneshot::Receiver<AssistantMessage>,
66 ) {
67 (self.rx, self.result_rx)
68 }
69}
70
71/// Failure to receive the terminal result: the producer dropped its `result`
72/// sender without ever pushing a `Done`/`Error` event (task panic / bug).
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct RecvError;
75
76impl std::fmt::Display for RecvError {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 write!(
79 f,
80 "assistant-message event stream ended without a terminal Done/Error event"
81 )
82 }
83}
84
85impl std::error::Error for RecvError {}
86
87/// The producer side. Mirrors the TS pusher: `push(ev)` until a terminal event,
88/// at which point the result oneshot is fulfilled (first terminal wins; `push`
89/// after a terminal event is a no-op matching `if (this.done) return`).
90pub struct AssistantMessageEventStreamProducer {
91 tx: mpsc::UnboundedSender<AssistantMessageEvent>,
92 result_tx: Option<oneshot::Sender<AssistantMessage>>,
93 /// Mirrors the TS `done` flag: flipped true once a terminal `Done`/`Error`
94 /// has been PUSHED. Subsequent `push` calls short-circuit (no delivery, no
95 /// overwrite of the already-fulfilled result oneshot).
96 done: bool,
97}
98
99impl AssistantMessageEventStreamProducer {
100 /// Push an event. Returns `false` if the consumer has dropped the stream
101 /// (back-pressure / cancellation). After a terminal `Done`/`Error` is
102 /// pushed, further `push` calls are no-ops returning `true` (the stream is
103 /// "done" but alive), mirroring TS `push` semantics. The terminal event
104 /// itself is always delivered (so the consumer sees it) before the flag
105 /// takes effect.
106 pub fn push(&mut self, event: AssistantMessageEvent) -> bool {
107 // TS: `if (this.done) return;` — a post-terminal push is a no-op.
108 if self.done {
109 return true;
110 }
111
112 // TS: on a terminal event, set `done` + resolve the result oneshot
113 // FIRST (the terminal event is still delivered below).
114 if event.is_terminal() {
115 self.done = true;
116 if let AssistantMessageEvent::Done { message, .. } = &event {
117 self.fulfill_result(message.clone());
118 } else if let AssistantMessageEvent::Error { error, .. } = &event {
119 self.fulfill_result(error.clone());
120 }
121 }
122
123 match self.tx.send(event) {
124 Ok(()) => true,
125 Err(_) => false,
126 }
127 }
128
129 fn fulfill_result(&mut self, message: AssistantMessage) {
130 if let Some(rx) = self.result_tx.take() {
131 // First terminal wins; later `take()` yields None so subsequent
132 // terminal events cannot overwrite the result (matches the TS
133 // one-shot `resolveFinalResult`).
134 let _ = rx.send(message);
135 }
136 }
137
138 pub fn is_done(&self) -> bool {
139 self.done
140 }
141
142 /// Drop the producer without delivering a terminal event. The consumer's
143 /// `next()` returns `None` and `result()` yields `RecvError`. Providers
144 /// should push `Error` instead of relying on this; it exists for the
145 /// "producer task panicked" safety net.
146 pub fn close(self) {
147 // Drop closes the mpsc sender; result_tx drop makes result() error.
148 drop(self);
149 }
150}
151
152/// Create a connected producer/consumer pair. Mirrors TS
153/// `createAssistantMessageEventStream()`.
154pub fn create_assistant_message_event_stream() -> (
155 AssistantMessageEventStreamProducer,
156 AssistantMessageEventStream,
157) {
158 let (tx, rx) = mpsc::unbounded_channel::<AssistantMessageEvent>();
159 let (result_tx, result_rx) = oneshot::channel::<AssistantMessage>();
160 (
161 AssistantMessageEventStreamProducer {
162 tx,
163 result_tx: Some(result_tx),
164 done: false,
165 },
166 AssistantMessageEventStream {
167 rx,
168 result_rx,
169 done: false,
170 },
171 )
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::types::{Api, DoneReason};
178 use std::sync::Arc;
179
180 fn empty_partial() -> Arc<AssistantMessage> {
181 Arc::new(AssistantMessage::empty(Api::Faux, "faux", "faux", 0))
182 }
183
184 #[tokio::test]
185 async fn drain_deltas_then_done() {
186 let (mut prod, mut stream) = create_assistant_message_event_stream();
187
188 let partial = empty_partial();
189 prod.push(AssistantMessageEvent::Start {
190 partial: partial.clone(),
191 });
192 prod.push(AssistantMessageEvent::TextStart {
193 content_index: 0,
194 partial: partial.clone(),
195 });
196 prod.push(AssistantMessageEvent::TextDelta {
197 content_index: 0,
198 delta: "hi".into(),
199 partial: partial.clone(),
200 });
201 prod.push(AssistantMessageEvent::TextEnd {
202 content_index: 0,
203 content: "hi".into(),
204 partial: partial.clone(),
205 });
206
207 let mut final_msg = (*partial).clone();
208 final_msg.stop_reason = crate::types::StopReason::Stop;
209 prod.push(AssistantMessageEvent::Done {
210 reason: DoneReason::Stop,
211 message: final_msg.clone(),
212 });
213
214 let mut tags = Vec::new();
215 while let Some(ev) = stream.next().await {
216 tags.push(ev.type_tag());
217 }
218 assert_eq!(
219 tags,
220 vec!["start", "text_start", "text_delta", "text_end", "done"]
221 );
222
223 let result = stream.result().await.unwrap();
224 assert!(matches!(result.stop_reason, crate::types::StopReason::Stop));
225 }
226
227 #[tokio::test]
228 async fn error_path_resolves_to_error_message() {
229 let (mut prod, stream) = create_assistant_message_event_stream();
230 let err_msg = AssistantMessage::terminal(
231 Api::Faux,
232 "faux",
233 "faux",
234 crate::types::StopReason::Aborted,
235 "cancelled",
236 0,
237 );
238 prod.push(AssistantMessageEvent::Error {
239 reason: crate::types::ErrorReason::Aborted,
240 error: err_msg.clone(),
241 });
242 let result = stream.result().await.unwrap();
243 assert!(matches!(
244 result.stop_reason,
245 crate::types::StopReason::Aborted
246 ));
247 assert_eq!(result.error_message.as_deref(), Some("cancelled"));
248 }
249
250 #[tokio::test]
251 async fn push_after_terminal_is_noop() {
252 let (mut prod, mut stream) = create_assistant_message_event_stream();
253 let msg = AssistantMessage::terminal(
254 Api::Faux,
255 "faux",
256 "faux",
257 crate::types::StopReason::Stop,
258 "",
259 0,
260 );
261 prod.push(AssistantMessageEvent::Done {
262 reason: DoneReason::Stop,
263 message: msg,
264 });
265 // Post-terminal push should not deliver.
266 prod.push(AssistantMessageEvent::Start {
267 partial: empty_partial(),
268 });
269
270 let first = stream.next().await.unwrap();
271 assert!(matches!(first, AssistantMessageEvent::Done { .. }));
272 // Stream ends after the terminal event (producer won't send more that land).
273 assert!(stream.next().await.is_none());
274 }
275}