Files
boxmaker/docs/plans/M2a/05-loopd-sse.md
T
kyleandClaude Fable 5.1 76ccc251cd Add M2a plan: thirteen tasks, tests, fake server and recordings
The tasks build the inference path: emsha-backed SHA-256, inferproxy,
config, a hand-written HTTP and SSE client, request building, delta
assembly, the chat state machine, the thinking cap, the slot gate with
retry, the startup self-test and on-device verification.

Everything the tasks copy in was checked against a private reference
implementation: the gate passes after each task in order, the timing
tests pass repeatedly under CPU load, and the reference passes the
self-test and all four device checks on straylight. Expected results
for the recorded streams were derived by a separate script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 13:34:11 -07:00

3.2 KiB

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:

#[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<R> { /* private */ }
impl<R: std::io::Read> Events<R> {
    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<Option<SseItem>, 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.