contract(issue#7): author + amend #1/#3/#4 for empty-skip + MalformedSseData
Issue #7: mid-stream robustness fix discovered via 2026-05-22 crash. Long mimir TUI conversation (turn 93, 1077 events consumed) crashed on event 1078 with JSONDecodeError("Expecting value: line 1 column 1 (char 0)") from json.loads('') on an empty-data SSE frame. _iter_events unconditionally called json.loads on every dispatched event; when httpx_sse surfaces a frame with id: present but data: empty/missing (a known library-vs-spec divergence), parsing fails and propagates. Two-rule fix in _iter_events: - Empty sse.data (exact `== ''`): SKIP silently per SSE spec (keepalive semantics). Don't yield, don't advance last_sse_id, don't set terminal_seen. ORDERING: skip fires BEFORE _parse_sse_id, so a keepalive with a malformed id is still a keepalive (intentional). - Non-empty sse.data that fails json.loads: raise new MalformedSseData (sibling to MalformedSseId, mirrors raw[:200] truncation pattern). Wire-level protocol error; presenters route to [malformed_sse_data] + exit 22 in cli, transcript label + state→idle in tui (INV-008). Volva paraphrase round: 4 ambiguities, all amended: 1. INV-001 prose tightened — exact `sse.data == ''` rule made prominent; "keepalive" framing demoted to intent-not-rule; whitespace-only data explicitly listed as malformed (not skipped); specific state names (last_sse_id, terminal_seen) instead of vague "any counter". 2. STEPS pseudocode spells out the ordering — empty-skip happens BEFORE _parse_sse_id; empty-data with bad id is silently swallowed. 3. empty_data_skipped test description fixed (had off-by-one count + wrong wording around last_sse_id intermediate state). 4. (paired with #1 above). Volva code-review post-implementation: 3 findings, all addressed: F1 (test-gap): empty_data_skipped proves yielded events but not internal last_sse_id non-advancement. New empty_data_skip_preserves_last_seen_sse_id test probes via SseConnectionDropped.last_seen_sse_id after a drop following the skipped frame — if the skip had transiently advanced last_sse_id, the exception payload would carry the wrong value. F2 (precision, contract amend): MalformedSseData ERROR_ROUTING said "log truncated raw" but stream_turn doesn't log — sse_client is a library, presenters own observability. Amended to "propagate to caller (no logging at sse_client layer); presenters log exc.raw." F3 (test-gap): cli malformed_sse_data test asserted label but not `raw='X'` shape and not truncation. Tightened existing test + added malformed_sse_data_truncation with 5000-char payload — verifies MalformedSseData.raw truncation carries through the presenter's repr() rendering. **Calibration milestone**: issue #7 is the first issue with ZERO drift findings from Volva code-review. TDD caught all runtime behavior cleanly. The 3 findings were assertion-precision and architectural-correctness-of-wording, not behavioral. Hypothesis: tighter contract spec + smaller code surface shifts Volva's role from "catch behavioral drift" to "tighten observability + wording". Cumulative calibration table: #1 (4 findings, 3 drift + 1 test-gap), #2 (3, 1+1+1 precision), #3 (5, 3+1+1), #4 (8, 5+2+1), #7 (3, 0 drift + 2 test-gap + 1 precision). Contracts touched (all drift-check clean): - docs/contracts/issues/7.contract.md (new): the coordinating record. - docs/contracts/issues/1.contract.md: _iter_events STEP 3.0 empty-skip + ordering note; STEP 3.c JSONDecodeError → MalformedSseData; new MalformedSseData ERROR_ROUTING (propagate-to-caller wording per F2); 4 new TESTS entries including F1's last-seen probe. - docs/contracts/issues/3.contract.md: _run_turn ERROR_ROUTING + malformed_sse_data tests (incl. F3 truncation). - docs/contracts/issues/4.contract.md: INV-008 mentions MalformedSseData; _stream_turn_worker ERROR_ROUTING + new TEST.
This commit is contained in:
@@ -117,6 +117,11 @@ ERROR_ROUTING:
|
||||
local_handling: log truncated raw `id:` value; re-raise to caller
|
||||
flow_control: abort
|
||||
state_recovery: none (stream is corrupt; cannot resume safely)
|
||||
MalformedSseData:
|
||||
local_handling: propagate to caller (no logging at sse_client layer — sse_client is a library, not a logging sink; presenters own observability). The exception carries `exc.raw` (already truncated to 200 chars at MalformedSseData.__init__) for presenters to render.
|
||||
flow_control: abort
|
||||
state_recovery: none (payload corruption; can't decode; consumer's call to reconnect)
|
||||
note: empty `sse.data == ''` is SKIPPED, not raised — see _iter_events STEP 3.0 (issue #7 INV-001). Only non-empty-but-malformed-JSON raises MalformedSseData.
|
||||
TurnIdFlip:
|
||||
local_handling: log both turn_ids; re-raise to caller
|
||||
flow_control: abort
|
||||
@@ -127,6 +132,13 @@ STEPS:
|
||||
ON httpx.HTTPStatusError before stream opens:
|
||||
RAISE SseConnectFailed
|
||||
3. [loop, flexibility=prescriptive] FOR EACH sse_event in event_source.aiter_sse():
|
||||
0. [branch, flexibility=prescriptive] IF sse_event.data == "":
|
||||
# Issue #7 INV-001: empty-data frames are treated as keepalives.
|
||||
# ORDERING: this check fires BEFORE _parse_sse_id; an empty-data event
|
||||
# with a malformed id is silently swallowed (a keepalive with a bad id
|
||||
# is still a keepalive). Skip means: don't yield, don't update last_sse_id,
|
||||
# don't set terminal_seen, don't raise. CONTINUE to next sse_event.
|
||||
CONTINUE
|
||||
a. [sequential] CALL _parse_sse_id(sse_event.id) → (turn_id, seq)
|
||||
ON ValueError:
|
||||
RAISE MalformedSseId(raw=sse_event.id[:64])
|
||||
@@ -135,6 +147,10 @@ STEPS:
|
||||
ELSE:
|
||||
SET established_turn_id = turn_id (on first event)
|
||||
c. [sequential] Parse sse_event.data as JSON → body dict
|
||||
ON json.JSONDecodeError:
|
||||
# Issue #7 INV-002: non-empty data that fails json.loads is a protocol
|
||||
# error (server emitted malformed JSON). Surface as named exception.
|
||||
RAISE MalformedSseData(raw=sse_event.data) # raw truncated to 200 in __init__
|
||||
d. [sequential] CALL _envelope_for_type(body, sse_id=(turn_id, seq)) → typed Event
|
||||
e. [sequential] YIELD event
|
||||
f. [branch] IF isinstance(event, (Done, Error, Cancelled)):
|
||||
@@ -151,6 +167,11 @@ TESTS:
|
||||
turn_id_flip [adversarial]: mock emits text events with ids `42:1` then `99:2` → consumer raises TurnIdFlip; only the first event was yielded
|
||||
connection_drop [error]: mock raises httpx.RemoteProtocolError after one text event → consumer raises SseConnectionDropped(last_seen_sse_id=(42, 1))
|
||||
no_text_aggregation [trace]: assert consumer does NOT concatenate text deltas internally — each text event yields as its own Text envelope; aggregation is the caller's job
|
||||
empty_data_skipped [trace,issue#7]: stream yields 4 frames — text(42:1), empty-data(42:2), text(42:3), done(42:4) — consumer yields exactly 3 events (Text, Text, Done); empty-data frame invisible. Intermediate state after the second Text: last_sse_id == (42, 3) (proves skip did not advance through 42:2).
|
||||
empty_data_skip_preserves_last_seen_sse_id [trace,issue#7]: stream yields text(42:1), empty-data(42:2), then RemoteProtocolError → consumer raises SseConnectionDropped with `last_seen_sse_id == SseId(42, 1)`. Directly probes INV-003 via the exception payload — if the skip had transiently advanced last_sse_id through 42:2, the exception would carry (42, 2) and the test would fail.
|
||||
malformed_data_raises [error,issue#7]: stream yields text(42:1) + event with `data: not-json` → consumer yields one Text then raises MalformedSseData(raw="not-json"); iteration aborts.
|
||||
whitespace_data_raises [adversarial,issue#7]: stream yields event with `data:` followed by single space → MalformedSseData (whitespace is non-empty per INV-001 exact-equality skip rule).
|
||||
malformed_data_truncation [security,issue#7]: stream yields event with 5000-char malformed data → MalformedSseData.raw is exactly first 200 chars; exception message truncated too.
|
||||
```
|
||||
|
||||
```contract
|
||||
|
||||
Reference in New Issue
Block a user