feat(sse_client,cli,tui): implement issue #7 — empty-data skip + MalformedSseData
Bundles initial TDD impl + Volva-code-review F1/F3 amendments. sse_client.py: - New MalformedSseData(raw) exception; truncates raw to 200 chars at __init__ (mirrors MalformedSseId.raw[:64] precedent). - _iter_events gains `if sse.data == '': continue` BEFORE _parse_sse_id. Empty-data frames are silently skipped per issue #7 INV-001 (keepalive semantics). Empty-data + bad-id is still a keepalive; intentional ordering, don't reorder. - _iter_events json.loads(sse.data) now wrapped — JSONDecodeError → MalformedSseData(raw=sse.data). cli.py: - Imports MalformedSseData; _run_turn ERROR_ROUTING gains the case → stderr `[malformed_sse_data] raw={exc.raw!r}` + exit 22 (protocol- failure bucket, same as MalformedSseId/TurnIdFlip). tui.py: - Imports MalformedSseData; _stream_turn_worker ERROR_ROUTING gains the case → transcript label; finally block restores state→idle per INV-008 (mid-session errors don't exit the app). Tests (6 new): - test_sse_client.py: empty_data_skipped (tracer — 4 frames in, 3 events out), malformed_data_raises, whitespace_data_raises, malformed_data_truncation, AND empty_data_skip_preserves_last_seen_sse_id (F1 from Volva code-review — drop-after-empty probes internal last_sse_id non-advancement via SseConnectionDropped.last_seen_sse_id). - test_cli.py: malformed_sse_data (tightened to assert exact `[malformed_sse_data] raw='not-json'` shape per F3), malformed_sse_data_truncation (5000-char payload — verifies truncation carries through presenter rendering, F3). - test_tui.py: malformed_sse_data_returns_to_idle (state→idle per INV-008; app does NOT exit). Smoke validation (2026-05-22): the original crashing prompt ("what about system 1 and system 2 framing?") now completes cleanly end-to-end. mimir streamed 3193 tokens (50 seconds, 374980-token context), `[done] turn_id=96 duration_ms=50436`. Empty-data frames somewhere in the stream silently skipped; no crash. 172/172 tests GREEN; ruff clean; all 5 issue contracts (#1, #3, #4, #5, #7) drift-check clean. Persistent-memory updated per the commit-along rule: status reflects v0+#7 milestone; new dated decisions for #5/#6/#7 filing + #7 implementation; foot-gun entry for unguarded json.loads(sse.data).
This commit is contained in:
@@ -531,6 +531,49 @@ class TestRunTurn:
|
||||
assert exit_code == 22
|
||||
assert "[malformed_sse_id]" in stderr.getvalue()
|
||||
|
||||
@respx.mock
|
||||
async def test_malformed_sse_data(self) -> None:
|
||||
"""malformed_sse_data [error]: text + non-JSON → exit 22; [malformed_sse_data] raw='...'."""
|
||||
stream = (
|
||||
_sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
+ b"id: 42:2\ndata: not-json\n\n"
|
||||
)
|
||||
respx.post("https://w.example/sessions/s-1/messages").mock(
|
||||
return_value=_sse_resp(stream)
|
||||
)
|
||||
sigint = asyncio.Event()
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||
assert exit_code == 22
|
||||
out = stderr.getvalue()
|
||||
# Exact label + `raw='X'` shape — the stderr format the contract specifies
|
||||
assert "[malformed_sse_data] raw='not-json'" in out
|
||||
|
||||
@respx.mock
|
||||
async def test_malformed_sse_data_truncation(self) -> None:
|
||||
"""malformed_sse_data_truncation [security]: 5000-char bad data → raw truncated."""
|
||||
huge_bad = "x" * 5000
|
||||
stream = (
|
||||
_sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
+ f"id: 42:2\ndata: {huge_bad}\n\n".encode()
|
||||
)
|
||||
respx.post("https://w.example/sessions/s-1/messages").mock(
|
||||
return_value=_sse_resp(stream)
|
||||
)
|
||||
sigint = asyncio.Event()
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||
assert exit_code == 22
|
||||
out = stderr.getvalue()
|
||||
assert "[malformed_sse_data]" in out
|
||||
# MalformedSseData.raw was truncated to 200 chars at the exception layer;
|
||||
# presenter's `repr()` rendering of that 200-char string carries through.
|
||||
# Full 5000-char payload MUST NOT appear in stderr.
|
||||
assert "x" * 5000 not in out
|
||||
assert "x" * 200 in out # the truncated form IS in the rendered raw='...'
|
||||
|
||||
@respx.mock
|
||||
async def test_turn_id_flip(self) -> None:
|
||||
"""turn_id_flip [error]: …"""
|
||||
|
||||
Reference in New Issue
Block a user