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}