Skip to main content

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}