dwara_core/ai/
sse.rs

1//! Hand-rolled SSE framing (DW-075).
2//!
3//! The locked M4 dependency decision is in-house framing: the
4//! `text/event-stream` wire format is small enough (a line grammar over
5//! a byte stream) that pulling an SSE crate — and the transitive
6//! futures-stack it would drag into the AI domain — buys nothing. This
7//! decoder implements the parts the provider dialects actually use:
8//!
9//! - events are separated by a blank line (`\n\n` or `\r\n\r\n`),
10//! - `data:` lines carry the payload (multiple `data:` lines in one
11//!   event join with `\n`, per the WHATWG spec),
12//! - `event:` names the event (Anthropic sends `event: message_start`
13//!   alongside the data payload),
14//! - `id:` and `retry:` lines are ignored, and `:`-prefixed comment
15//!   lines are ignored,
16//! - leading whitespace after the field name is stripped once.
17//!
18//! What it deliberately does NOT do: charset negotiation,
19//! `Last-Event-ID` resumption (providers do not support it), or BOM
20//! sniffing — none of the shipped dialects use any of that.
21
22/// One decoded SSE event: its optional name and its data payload.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct SseFrame {
25    pub event: Option<String>,
26    pub data: String,
27}
28
29/// An incremental SSE decoder: feed it raw bytes as they arrive, take
30/// complete frames back. Call [`SseDecoder::finish`] at stream end to
31/// recover a final frame not terminated by a blank line (tolerant
32/// providers exist; a well-formed stream yields nothing there).
33#[derive(Debug, Default)]
34pub struct SseDecoder {
35    buf: String,
36}
37
38impl SseDecoder {
39    pub fn new() -> Self {
40        SseDecoder { buf: String::new() }
41    }
42
43    /// Feed raw bytes; returns every frame completed by this chunk.
44    pub fn push(&mut self, bytes: &[u8]) -> Vec<SseFrame> {
45        // Provider streams are ASCII JSON; lossy conversion only ever
46        // replaces bytes on a malformed (non-UTF-8) stream, where the
47        // JSON parse downstream fails loudly anyway.
48        self.buf.push_str(&String::from_utf8_lossy(bytes));
49        self.drain()
50    }
51
52    /// Flush any buffered partial frame at stream end.
53    pub fn finish(&mut self) -> Vec<SseFrame> {
54        let rest = std::mem::take(&mut self.buf);
55        if rest.trim().is_empty() {
56            Vec::new()
57        } else {
58            parse_frame(&rest).into_iter().collect()
59        }
60    }
61
62    /// Split every COMPLETE frame (terminated by a blank line) off the
63    /// buffer.
64    fn drain(&mut self) -> Vec<SseFrame> {
65        let mut frames = Vec::new();
66        while let Some((text_end, term_len)) = find_event_end(&self.buf) {
67            let frame_text = self.buf[..text_end].to_string();
68            self.buf.drain(..text_end + term_len);
69            // A blank separator with no event text (or a comment-only
70            // keepalive block) produces no frame.
71            if let Some(frame) = parse_frame(&frame_text) {
72                frames.push(frame);
73            }
74        }
75        frames
76    }
77}
78
79/// Find the first COMPLETE event terminator in `s`.
80///
81/// Returns `(event_text_end, terminator_len)`: the event text is
82/// `s[..event_text_end]` and the terminator is the `terminator_len`
83/// bytes starting there. Recognized blank-line spellings (a data line
84/// ending plus an empty line, in any LF/CRLF mix):
85///
86/// - `\n\n` — event text ends at the first `\n`, width 2.
87/// - `\r\n\r\n` — event text ends before the first `\r`, width 4.
88/// - `\n\r\n` — an LF-ended data line followed by a CRLF blank line:
89///   event text ends at the `\n`, width 3.
90///
91/// A buffer that ends mid-terminator (`... \n\r`, possibly the first
92/// half of a `\r\n\r\n` or `\n\r\n`) matches NOTHING: the caller must
93/// wait for more bytes rather than split on a partial terminator.
94fn find_event_end(s: &str) -> Option<(usize, usize)> {
95    let b = s.as_bytes();
96    for i in 0..b.len() {
97        if b[i] != b'\n' {
98            continue;
99        }
100        // "\n\n": the event text ends at i.
101        if i + 1 < b.len() && b[i + 1] == b'\n' {
102            return Some((i, 2));
103        }
104        // A "\r\n" blank line follows. Where the event text ends
105        // depends on how the DATA line ended: "\r\n\r\n" puts a "\r"
106        // just before i (it belongs to the terminator); "\n\r\n" does
107        // not.
108        if i + 2 < b.len() && b[i + 1] == b'\r' && b[i + 2] == b'\n' {
109            return if i >= 1 && b[i - 1] == b'\r' {
110                Some((i - 1, 4))
111            } else {
112                Some((i, 3))
113            };
114        }
115    }
116    None
117}
118
119/// Parse one event's text into a frame. Comment-only events (keepalive
120/// `:` pings) return None — they carry no data.
121fn parse_frame(text: &str) -> Option<SseFrame> {
122    let mut event = None;
123    let mut data_lines: Vec<String> = Vec::new();
124    for line in text.split('\n') {
125        let line = line.strip_suffix('\r').unwrap_or(line);
126        if line.is_empty() || line.starts_with(':') {
127            continue;
128        }
129        let (name, value) = match line.split_once(':') {
130            Some((n, v)) => (n, v.strip_prefix(' ').unwrap_or(v)),
131            None => (line, ""),
132        };
133        match name {
134            "data" => data_lines.push(value.to_string()),
135            "event" => event = Some(value.to_string()),
136            _ => {}
137        }
138    }
139    if data_lines.is_empty() {
140        return None;
141    }
142    Some(SseFrame {
143        event,
144        data: data_lines.join("\n"),
145    })
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    // White-box: the decoder's frame grammar is private behavior not
153    // yet exercised through a public caller (the gateway streaming
154    // path is DW-077); these stay here with that justification and are
155    // additionally replayed through adapter stream tests in
156    // tests/ai_adapters.rs.
157
158    fn datas(dec: &mut SseDecoder, chunk: &str) -> Vec<String> {
159        dec.push(chunk.as_bytes())
160            .into_iter()
161            .map(|f| f.data)
162            .collect()
163    }
164
165    #[test]
166    fn splits_two_events_in_one_chunk() {
167        let mut dec = SseDecoder::new();
168        let out = datas(&mut dec, "event: x\ndata: one\n\ndata: two\n\n");
169        assert_eq!(out, vec!["one".to_string(), "two".to_string()]);
170    }
171
172    #[test]
173    fn assembles_event_split_across_chunks() {
174        let mut dec = SseDecoder::new();
175        assert!(datas(&mut dec, "data: par").is_empty());
176        assert!(datas(&mut dec, "tial\n").is_empty());
177        assert_eq!(datas(&mut dec, "\n"), vec!["partial".to_string()]);
178    }
179
180    #[test]
181    fn crlf_line_endings_and_comments() {
182        let mut dec = SseDecoder::new();
183        let out = dec.push(b": keepalive\r\n\r\nevent: m\r\ndata: {\"a\":1}\r\n\r\n");
184        assert_eq!(out.len(), 1);
185        assert_eq!(out[0].event.as_deref(), Some("m"));
186        assert_eq!(out[0].data, "{\"a\":1}");
187    }
188
189    #[test]
190    fn waits_on_partial_crlf_terminator() {
191        let mut dec = SseDecoder::new();
192        // "...\n\r" could be the first half of an "\r\n\r\n"
193        // terminator — must NOT emit yet.
194        assert!(datas(&mut dec, "data: x\n\r").is_empty());
195        assert_eq!(datas(&mut dec, "\n"), vec!["x".to_string()]);
196    }
197
198    #[test]
199    fn multiple_data_lines_join_with_newline() {
200        let mut dec = SseDecoder::new();
201        assert_eq!(
202            datas(&mut dec, "data: a\ndata: b\n\n"),
203            vec!["a\nb".to_string()]
204        );
205    }
206
207    #[test]
208    fn finish_recovers_unterminated_frame() {
209        let mut dec = SseDecoder::new();
210        assert!(datas(&mut dec, "data: tail").is_empty());
211        let out = dec.finish();
212        assert_eq!(out.len(), 1);
213        assert_eq!(out[0].data, "tail");
214    }
215}
216
217#[cfg(test)]
218mod dw077_regressions {
219    use super::*;
220
221    // Regression (found building the DW-077 e2e suite): a frame split
222    // MID-JSON across body writes must assemble — the buffer is pure
223    // bytes to the decoder and JSON structure is irrelevant to it.
224
225    #[test]
226    fn frame_split_mid_json_assembles() {
227        let mut dec = SseDecoder::new();
228        assert!(dec.push(b"data: {\"delta\":{\"content\":\"Hel").is_empty());
229        let out = dec.push(
230            b"lo\"}}
231
232",
233        );
234        assert_eq!(out.len(), 1, "frames: {out:?}");
235        assert_eq!(out[0].data, "{\"delta\":{\"content\":\"Hello\"}}");
236    }
237
238    #[test]
239    fn two_choice_frame_yields_two_events() {
240        // The multi-choice chunk shape providers send: one provider
241        // frame can carry several deltas.
242        let payload = "{\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello \"}},{\"index\":0,\"delta\":{\"content\":\"wor\"}}]}";
243        let v: serde_json::Value = serde_json::from_str(payload).expect("valid json");
244        let adapter = crate::ai::adapter::adapter_for(crate::config::ai::AiProviderKind::Openai);
245        let events = adapter.parse_stream_event(&v).expect("parses");
246        assert_eq!(events.len(), 2, "events: {events:?}");
247    }
248}