# M2a task 05: the SSE reader **Branch:** `m2a` (run `git switch m2a`; `git status --short` must be empty, otherwise stop) **Commit subject:** `Add a server-sent-events reader to loopd` ## Goal Turn a streamed body into a sequence of `data:` payloads. `llama-server` streams a completion as server-sent events: one JSON object per `data:` line, and `data: [DONE]` at the end. ## Context ``` data: {"choices":[…]}\n \n : a comment line\n data: {"choices":[…]}\r\n \r\n data: [DONE]\n \n ``` Lines end in `\n` or `\r\n`. Blank lines, comment lines (starting with `:`) and any field other than `data` (`event:`, `id:`, …) carry nothing for us and are skipped. After `data:` one leading space is dropped if present, and only one. ## Files - Copy: `crates/loopd/tests/sse.rs` - Create: `crates/loopd/src/sse.rs` - Modify: `crates/loopd/src/lib.rs`, `docs/implementer-log.md` ## Interfaces Consumes: `loopd::http::is_timeout`. Produces, in `crates/loopd/src/sse.rs`: ```rust #[derive(Debug, Clone, PartialEq, Eq)] pub enum SseItem { Data(String), Done } // Data: the text after "data:", without the line ending #[derive(Debug)] pub enum SseError { Io(std::io::Error), Timeout, Truncated, LineTooLong, NotUtf8 } // Display, Error pub struct Events { /* private */ } impl Events { pub fn new(reader: R, max_line: usize) -> Self; /// The next item, or None when the stream has ended cleanly between lines. pub fn next_item(&mut self) -> Result, SseError>; } ``` Rules the tests check: 1. The result must not depend on how the bytes arrive. A reader that gives one byte per call must produce the same items, including for text with multi-byte characters: decode a line as UTF-8 only once the whole line is there. 2. A line longer than `max_line` bytes is `LineTooLong`. This applies **both** when the line is still arriving with no newline in sight **and** when it arrives whole in a single read. Check the length in both places. 3. When the reader ends (`read` returns 0): with nothing buffered, return `Ok(None)`, and keep returning it if asked again. With a partial line buffered: `Truncated`. 4. Reader errors: `is_timeout` gives `Timeout`; `UnexpectedEof` gives `Truncated` (the HTTP body reader reports a cut stream that way); `Interrupted` is retried; anything else is `Io`. 5. A line that is not valid UTF-8 is `NotUtf8`. ## Steps - [ ] **1. Copy.** `git switch m2a`, then `cp docs/plans/M2a/files/crates/loopd/tests/sse.rs crates/loopd/tests/` - [ ] **2. See the test fail.** `cargo test -p loopd --test sse`. Expected: it does not compile. - [ ] **3. Write `sse.rs`,** and add `pub mod sse;` to `lib.rs`. Run `cargo fmt --all`. - [ ] **4. See the test pass.** `cargo test -p loopd --test sse`. Expected: `7 passed`. The last test reads four recordings through the HTTP client from task 04. - [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`. - [ ] **6. Log and commit.** `git add crates/loopd docs/implementer-log.md && git commit` ## Done when - `cargo test -p loopd --test sse` reports 7 passed; `make gate` prints `gate: ok`. ## Stop and report if - `recorded_streams_have_the_expected_shape` fails although `cargo test -p loopd --test http` passes and the other six tests here pass.