Skip to main content

embacle_server/uhp/
streaming.rs

1// ABOUTME: Server-sent events for a streaming task, in the protocol's event taxonomy
2// ABOUTME: sequence_number starts at 0 with no gaps; exactly one terminal event ends the stream
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Streaming a task.
8//!
9//! The ordering rules are the whole of this module. `sequence_number` starts at
10//! **0** and increases by exactly one per event, so a client can detect a
11//! dropped event rather than silently rendering a gap. The first event is
12//! `response.created`. Exactly one terminal event — `response.completed`,
13//! `response.incomplete` or `response.failed` — ends the stream, and it carries
14//! the complete response object, so a client that watched nothing in between
15//! still ends up with the same result the non-streaming path returns.
16
17use std::convert::Infallible;
18
19use axum::response::sse::{Event, Sse};
20use axum::response::IntoResponse;
21use futures::stream::Stream;
22use futures::StreamExt;
23use serde::Serialize;
24use serde_json::Value;
25
26use super::error::{ErrorType, UhpError};
27use super::state::UhpState;
28use super::tasks::{self, Prepared, Response};
29
30/// One streamed event.
31#[derive(Debug, Clone, Serialize)]
32pub struct StreamEvent {
33    /// Event type, e.g. `response.created`.
34    #[serde(rename = "type")]
35    pub kind: String,
36    /// Starts at 0, increases by exactly 1.
37    pub sequence_number: u64,
38    /// Present on `response.created` and on the terminal event.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub response: Option<Response>,
41    /// Present on output-item events.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub item: Option<Value>,
44    /// The text this event adds, on a delta event.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub delta: Option<String>,
47}
48
49impl StreamEvent {
50    /// An event carrying the response object.
51    fn with_response(kind: &str, sequence_number: u64, response: Response) -> Self {
52        Self {
53            kind: kind.to_owned(),
54            sequence_number,
55            response: Some(response),
56            item: None,
57            delta: None,
58        }
59    }
60
61    /// A chunk of generated text.
62    fn delta(sequence_number: u64, text: &str) -> Self {
63        Self {
64            kind: "response.output_text.delta".to_owned(),
65            sequence_number,
66            response: None,
67            item: None,
68            delta: Some(text.to_owned()),
69        }
70    }
71}
72
73/// The terminal event type for a finished task's status.
74///
75/// `cancelled` streams as `response.incomplete`, because the taxonomy has no
76/// cancelled event and reporting it as `failed` is explicitly forbidden — a
77/// client asked for the stop, so it is not a failure.
78const fn terminal_kind(status: &str) -> &'static str {
79    match status.as_bytes() {
80        b"completed" => "response.completed",
81        b"failed" => "response.failed",
82        _ => "response.incomplete",
83    }
84}
85
86/// Stream a task as it runs, in a well-ordered event sequence.
87///
88/// The events are produced as the runner produces them, not assembled after it
89/// finishes: `response.created` goes out before the harness is even asked, each
90/// chunk becomes a `response.output_text.delta`, and the terminal event carries
91/// the assembled response. A client that watched nothing in between still ends
92/// with what the non-streaming path would have returned.
93pub fn sse(state: UhpState, prepared: Prepared, previous: Option<String>) -> impl IntoResponse {
94    Sse::new(run_stream(state, prepared, previous))
95}
96
97/// The event sequence for one streaming task.
98fn run_stream(
99    state: UhpState,
100    prepared: Prepared,
101    previous: Option<String>,
102) -> impl Stream<Item = Result<Event, Infallible>> {
103    async_stream::stream! {
104        let mut seq = 0_u64;
105        let session_id = prepared.session_id.clone();
106        let created = tasks::pending_response(&prepared, previous.clone());
107        state.store.put(&session_id, created.clone());
108
109        yield Ok(emit(StreamEvent::with_response("response.created", seq, created)));
110        seq += 1;
111
112        let mut text = String::new();
113        let outcome = prepared.runner.complete_stream(&prepared.chat).await;
114
115        let failure = match outcome {
116            Ok(mut chunks) => {
117                let mut err = None;
118                while let Some(next) = chunks.next().await {
119                    match next {
120                        Ok(chunk) => {
121                            if !chunk.delta.is_empty() {
122                                text.push_str(&chunk.delta);
123                                yield Ok(emit(StreamEvent::delta(seq, &chunk.delta)));
124                                seq += 1;
125                            }
126                        }
127                        Err(e) => {
128                            err = Some(e.message);
129                            break;
130                        }
131                    }
132                }
133                err
134            }
135            Err(e) => Some(e.message),
136        };
137
138        let model = prepared.model.clone();
139        let response = match failure {
140            None => tasks::completed_response(prepared, previous, text, None, model),
141            Some(message) => {
142                let mut r = tasks::pending_response(&prepared, previous);
143                r.status = "failed";
144                r.error = Some(UhpError {
145                    kind: ErrorType::HarnessError,
146                    code: "harness_error".to_owned(),
147                    message,
148                    detail: None,
149                });
150                r
151            }
152        };
153
154        state.store.settle(response.clone());
155        let kind = terminal_kind(response.status);
156        yield Ok(emit(StreamEvent::with_response(kind, seq, response)));
157    }
158}
159
160/// Serialise one event onto the wire.
161///
162/// A failure to serialise is answered with an empty object rather than by
163/// tearing down a stream the client is already reading, so this cannot fail;
164/// the stream wraps it in the [`Infallible`] `Result` its item type requires.
165fn emit(event: StreamEvent) -> Event {
166    let json = serde_json::to_string(&event).unwrap_or_else(|_| "{}".to_owned());
167    Event::default().event(event.kind).data(json)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use serde_json::Map;
174
175    fn response(status: &'static str) -> Response {
176        Response {
177            id: "resp_x".to_owned(),
178            object: "response",
179            created_at: 1,
180            status,
181            error: None,
182            previous_response_id: None,
183            model: "m".to_owned(),
184            output: Vec::new(),
185            store: true,
186            usage: None,
187            metadata: Map::new(),
188        }
189    }
190
191    #[test]
192    fn a_cancelled_task_never_streams_as_failed() {
193        // The specification is explicit: cancelled must never be reported as
194        // failed, because the client asked for the stop.
195        assert_eq!(terminal_kind("cancelled"), "response.incomplete");
196        assert_eq!(terminal_kind("incomplete"), "response.incomplete");
197        assert_eq!(terminal_kind("completed"), "response.completed");
198        assert_eq!(terminal_kind("failed"), "response.failed");
199    }
200
201    #[test]
202    fn the_event_sequence_obeys_the_ordering_rules() {
203        // The rules the suite checks, asserted on the sequence this module
204        // builds: starts at 0, no gaps, created first, exactly one terminal
205        // event, and it is last and carries the response.
206        let evs = [
207            StreamEvent::with_response("response.created", 0, response("in_progress")),
208            StreamEvent::delta(1, "ok"),
209            StreamEvent::with_response("response.completed", 2, response("completed")),
210        ];
211
212        let seqs: Vec<u64> = evs.iter().map(|e| e.sequence_number).collect();
213        assert_eq!(
214            seqs,
215            vec![0, 1, 2],
216            "starts at 0 and increases by exactly one"
217        );
218        assert_eq!(
219            evs[0].kind, "response.created",
220            "the first event is created"
221        );
222
223        let terminal = [
224            "response.completed",
225            "response.incomplete",
226            "response.failed",
227        ];
228        assert_eq!(
229            evs.iter()
230                .filter(|e| terminal.contains(&e.kind.as_str()))
231                .count(),
232            1,
233            "exactly one terminal event"
234        );
235        assert!(
236            evs.last()
237                .is_some_and(|e| terminal.contains(&e.kind.as_str())),
238            "and it is last"
239        );
240        assert!(
241            evs.last().and_then(|e| e.response.as_ref()).is_some(),
242            "the terminal event carries the complete response"
243        );
244        assert_eq!(
245            evs[1].delta.as_deref(),
246            Some("ok"),
247            "a delta event carries the text it adds, and no response object"
248        );
249        assert!(evs[1].response.is_none());
250    }
251}