diff --git a/docs/contracts/issues/1.contract.md b/docs/contracts/issues/1.contract.md index 13bcf12..dcc5ac8 100644 --- a/docs/contracts/issues/1.contract.md +++ b/docs/contracts/issues/1.contract.md @@ -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 diff --git a/docs/contracts/issues/3.contract.md b/docs/contracts/issues/3.contract.md index 562e4f4..3c9212c 100644 --- a/docs/contracts/issues/3.contract.md +++ b/docs/contracts/issues/3.contract.md @@ -427,6 +427,11 @@ ERROR_ROUTING: flow_control: abort state_recovery: none (server-side wire bug; surface honestly) exit_code: 22 + MalformedSseData: + local_handling: write `[malformed_sse_data] raw={exc.raw!r}` to stderr (issue #7) + flow_control: abort + state_recovery: none (server-side wire bug; payload corruption) + exit_code: 22 TurnIdFlip: local_handling: write `[turn_id_flip] expected={exc.established} got={exc.got}` to stderr flow_control: abort @@ -494,6 +499,8 @@ TESTS: sse_connect_failed_404 [error]: mock returns 404 before stream opens → returns 20; stderr "[sse_connect_failed] status=404 ..." connection_dropped [error]: mock raises RemoteProtocolError mid-stream → returns 21; stderr "[connection_dropped]" malformed_sse_id [error]: mock yields an event with `id: 42` (no seq) → returns 22; stderr "[malformed_sse_id]" + malformed_sse_data [error,issue#7]: mock yields text + event with `data: not-json` → returns 22; stderr contains literal "[malformed_sse_data] raw='not-json'" (exact label+raw shape per ERROR_ROUTING) + malformed_sse_data_truncation [security,issue#7]: mock yields text + event with 5000-char malformed data → returns 22; stderr "[malformed_sse_data]" present; full 5000-char payload NOT in stderr; truncated 200-char form IS present (verifies MalformedSseData.raw truncation carries through the presenter's repr() rendering) turn_id_flip [error]: mock yields events 42:1 then 99:2 → returns 22; stderr "[turn_id_flip] expected=42 got=99" sigint_before_first_event [scenario]: sigint_event set BEFORE the mock has yielded anything → returns 3; respx tracked zero cancel_turn POSTs (INV-008) sigint_mid_stream_drains_to_cancelled [scenario,tracer]: mock yields text(42:1) → caller sets sigint_event → mock yields cancelled(42:2) → returns 3; respx tracked exactly one POST /sessions/{id}/turns/42/cancel (INV-007) diff --git a/docs/contracts/issues/4.contract.md b/docs/contracts/issues/4.contract.md index cbecd5d..011a6a1 100644 --- a/docs/contracts/issues/4.contract.md +++ b/docs/contracts/issues/4.contract.md @@ -90,7 +90,7 @@ The shell is the load-bearing primary surface. Together with `--send`, it makes - **INV-005 [hard]**: Markdown rendering on agent output is default-on; `--raw` is the opt-out. With markdown enabled, `Text` event deltas stream as raw text appended to the RichLog as they arrive (no mid-stream markdown attempt — partial markdown like `**hel` would render ugly), and on `Done` a separator + the full markdown-rendered assistant message is appended below the streamed deltas. **This means the assistant's response visibly appears TWICE in the transcript by design — once as the streamed raw deltas, once as the post-Done markdown render — separated by a horizontal-rule separator.** This is the v1 accepted trade-off for streaming-visibility-without-mid-stream-markdown-ugliness; the cleaner Static-then-commit pattern (streaming into a replaceable widget, then committing the markdown version in place) is documented in `open_questions:` as the follow-up if the double-display proves empirically noisy. Implementers MUST NOT attempt the Static-then-commit pattern in this shell — it's deferred. With `--raw`, only the streamed deltas appear; no post-Done re-render; no double-display. - **INV-006 [hard]**: User-prompt echo in the transcript MUST visibly distinguish user input from assistant output. Format: `❯ ` for user lines (with a literal `❯` prefix); assistant lines have no prefix. The prefix is also a screen-reader-friendly affordance. - **INV-007 [hard]**: One `httpx.AsyncClient` per app lifetime — opened in `on_mount`, closed in `on_unmount` via the async-with context manager pattern. The client is NOT recreated per turn (would burn the TCP connection pool). -- **INV-008 [hard]**: Mid-session network/protocol errors (`SseConnectionDropped`, `SseConnectFailed`, `MalformedSseId`, `TurnIdFlip`) during a streaming turn render as error lines in the transcript and return the app to **idle** state — they do NOT exit the app. Only initial session-create errors exit (per Data flow exit codes). +- **INV-008 [hard]**: Mid-session network/protocol errors (`SseConnectionDropped`, `SseConnectFailed`, `MalformedSseId`, `MalformedSseData` (issue #7), `TurnIdFlip`) during a streaming turn render as error lines in the transcript and return the app to **idle** state — they do NOT exit the app. Only initial session-create errors exit (per Data flow exit codes). - **INV-009 [hard]**: No `core.*` / `worldtree.*` imports. The boundary smoke (`tests/test_no_worldtree_imports.py`) covers `src/ratatoskr/` as a whole including the new tui.py. ## Out of scope @@ -272,7 +272,7 @@ POST: [POST-002 side_effect] each event passed through _render_event_to_log exac POST: [POST-003 side_effect] for Done events with NOT args.raw: a separator line + the markdown-rendered Done.response appended to RichLog (INV-005) POST: [POST-004 state_change] active_turn_id is set to event.sse_id.turn_id on the FIRST yielded event (for cancel_turn use by action_interrupt) ERROR_ROUTING: - SseConnectFailed | SseConnectionDropped | MalformedSseId | TurnIdFlip: + SseConnectFailed | SseConnectionDropped | MalformedSseId | MalformedSseData | TurnIdFlip: local_handling: append `[