feat(sse_client): implement issue #1 contract via TDD

Implements docs/contracts/issues/1.contract.md. Four entry points
(stream_turn, reconnect_turn, cancel_turn, _parse_sse_id) + nine
typed Event variants + ten domain exceptions. 37 tests covering
every TESTS: entry verbatim, plus the boundary smoke test still
passes.

Tracer-bullet ordering per the contract's per-FN tracer tags:
_parse_sse_id (foundation; happy_simple) → stream_turn
(happy_one_text_done) → reconnect_turn (happy_resume_from_seq_3) →
cancel_turn (happy_cancel). Each FN's tracer went RED then GREEN
before its other tests landed.

Shared SSE-iteration logic (INV-002 sse_id presence + INV-003
turn_id stability + terminal-break) lives in private _iter_events
helper. expected_turn_id=None gives stream_turn's "establish from
first event" semantics; expected_turn_id=N gives reconnect_turn's
"first event is already a flip-candidate" semantics — the
two-entry-point distinction Volva surfaced during the paraphrase
round.

A few implementation choices worth recording:

- _parse_sse_id uses a `^-?\\d+$` regex pre-check to reject any
  whitespace before int() is called. Python's `int(" 3 ")` silently
  strips, which would have made the trailing_whitespace adversarial
  test pass for the wrong reason.

- The connection_drop test uses a custom httpx.AsyncByteStream
  subclass (_DropAfter) that yields chunks then raises
  RemoteProtocolError mid-stream. respx alone can't simulate
  mid-stream HTTP errors.

- ToolResult.result and ToolStart.arguments are typed as Any
  because the server's tool wire shape varies per tool; the spec
  doesn't pin a generic schema.

- Boundary smoke test (no core.* / worldtree.* imports under
  src/ratatoskr/) still GREEN — INV-005 holds.

Also: one E501 line-length fix in test_no_worldtree_imports.py
that ruff flagged once the new tests pulled it into scope.
This commit is contained in:
vh
2026-05-20 21:25:20 -07:00
parent 1526f0bc8e
commit 02f2a04b37
4 changed files with 1049 additions and 7 deletions
+8 -6
View File
@@ -30,8 +30,7 @@ separate dev team rather than an in-tree Worldtree tool.
## Current state / in-flight
**Status: scaffold verified + first contract authored.** Design is locked;
implementation begins next, TDD against `sse_client.contract.md`.
**Status: `ratatoskr.sse_client` implemented via TDD against issue #1's contract.** 38/38 tests GREEN; ruff clean; boundary smoke (`tests/test_no_worldtree_imports.py`) still passes.
What's in the repo:
- `docs/design-brief.md` — the locked design (copy from `brokkr-smithy/docs/ratatoskr-design-brief.md`).
@@ -41,11 +40,12 @@ What's in the repo:
- `docs/contracts/issues/1.contract.md` — **issue-scoped contract for issue #1** (https://gitea.phasefinal.com/vh/ratatoskr/issues/1). v2.1, complexity=high. `target_module: ratatoskr.sse_client`. `prd:` block pins to issue body SHA `abcbc49467e86f1d` at `2026-05-21T03:57:37+00:00`. Four FN blocks: `stream_turn`, `reconnect_turn`, `cancel_turn`, `_parse_sse_id`. Drift check (`scripts/contract_drift_check.py`) returns clean.
- `pyproject.toml` — Python 3.12, hatchling, uv-managed. Deps: httpx, httpx-sse, textual. Dev deps: pytest, pytest-asyncio, respx, ruff, mypy, textual-dev, pyyaml (consumed by `docs/contracts/contract_parser.py` + `scripts/contract_drift_check.py`).
- `src/ratatoskr/__init__.py` + `cli.py` — stubs.
- `src/ratatoskr/sse_client.py` — **implemented 2026-05-21** per `docs/contracts/issues/1.contract.md`. Four public entry points + nine typed Event variants + ten custom exceptions. Shared SSE-iteration logic (INV-002 + INV-003 + terminal-break) lives in private `_iter_events(event_source, *, expected_turn_id)` helper consumed by both `stream_turn` and `reconnect_turn` — `expected_turn_id=None` triggers "establish from first event" semantics, `expected_turn_id=N` triggers "first event is already a flip-candidate" semantics (the two-entry-point distinction Volva surfaced).
- `tests/test_sse_client.py` — 37 tests covering all four FN blocks' TESTS: entries verbatim (13 + 10 + 8 + 6). Real HTTP wire via respx mocks; SSE wire format constructed by helper `_sse_chunk`. Connection-drop test uses custom `httpx.AsyncByteStream` subclass that yields chunks then raises `RemoteProtocolError`.
- `tests/test_no_worldtree_imports.py` — boundary smoke test (passes; verified 2026-05-20).
- `tests/snapshots/README.md` — recording/replay convention for SSE snapshot tests.
What's NOT in the repo yet:
- Implementation of `ratatoskr.sse_client` — next move, TDD per the contract's tracer tests.
- Gitea remote — operator provided `git@gitea.phasefinal.com:vh/ratatoskr.git` on 2026-05-20; about to be added + first push at the same commit as this update.
- CLAUDE.md customization — currently using the canonical template's
generic CLAUDE.md. The dev team may want to add Ratatoskr-specific
@@ -54,9 +54,10 @@ What's NOT in the repo yet:
**Branch:** `main`. Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git` (added 2026-05-20).
**Next natural moves:**
1. Implement `ratatoskr.sse_client` via TDD per `docs/contracts/sse_client.contract.md`. Vertical slice — start with `_parse_sse_id` (the foundation; tracer test `happy_simple`), then `stream_turn` (tracer test `happy_one_text_done`), then `reconnect_turn` (tracer test `happy_resume_from_seq_3`), then `cancel_turn` (tracer test `happy_cancel`). Each FN's tracer test gets RED → GREEN before any other test in that FN.
2. Record real SSE snapshot fixtures from a running Worldtree (per `tests/snapshots/README.md`) once `stream_turn` is GREEN against mocks — gives version-skew detection per design-brief §2.
3. Build presenters: `--send` stdout presenter first (simplest consumer of `stream_turn`); Textual TUI second. Both consume the same `Event` iterator.
1. Record real SSE snapshot fixtures from a running Worldtree (per `tests/snapshots/README.md`). Current tests use respx mocks against hand-rolled SSE wire — recording against a real Worldtree exercises spec conformance and gives version-skew detection per design-brief §2.
2. Build the `--send` stdout presenter — simplest consumer of `stream_turn`, exercises the API path without TUI machinery. Useful as a tracer for `ratatoskr.cli` work.
3. Textual TUI app shell — second presenter; multi-pane observability dashboard per design-brief §5. Layout shape locked there (Horizontal split, left=chat, right=TabbedContent with persona/tools/admin/bifrost/server-log).
4. Run `/sleipnir-preflight 1` if any AFK dispatch is wanted for these follow-ups, but most of this is hands-on dev work.
## Recent decisions
@@ -77,6 +78,7 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-05-20]` **First contract: `ratatoskr.sse_client`.** Bundles `stream_turn` + `reconnect_turn` + `cancel_turn` + private `_parse_sse_id` into one module — the SSE-resume flow is coupled (cancel needs `turn_id` from the SSE wire `id:`, reconnect re-uses the same parsed `SseId`), so they share a contract. Hard invariant INV-002 makes the composite `{turn_id}:{seq}` `id:` parsing load-bearing — closes the foot-gun the design-brief §3 names (hand-rolled `data:`-only parsing silently drops the `id:`). v2.1 test categories `adversarial`/`scenario`/`trace` used freely; parser warns but format spec §2.1.E permits them.
- `[2026-05-21]` **Contract converted to issue-scoped (issue #1).** Moved `docs/contracts/sse_client.contract.md` → `docs/contracts/issues/1.contract.md`. Frontmatter shape switched from module-scoped (`module:`/`purpose:`) to issue-scoped (`target_module:`/`scope:`/`prd:`) per CONTRACT-FORMAT §2.1.I. `prd:` block pins to issue #1's body hash (`abcbc49467e86f1d`). `scripts/contract_drift_check.py` returns clean. **Known parser stale-ness**: `contract_parser.py --validate` ERRORs on issue-scoped frontmatter (missing `module:`/`purpose:`) — this is CONTRACT-FORMAT §2.1.L H10, a documented Brokkr-side follow-up. Parser is a canonical sync, so we do NOT patch it locally (would drift from canonical). Treat parser ERROR-on-issue-scoped as expected until the canonical bumps.
- `[2026-05-21]` **Default issue-tracker labels seeded** (17 total). Sleipnir gating (`ready-for-agent`, `blocked-needs-contract`, `blocked-needs-dependency`), triage (`needs-triage`, `needs-architect-decision`, `needs-info`), type (`bug`, `enhancement`, `task`, `documentation`), resolution (`duplicate`, `wontfix`, `invalid`), Ratatoskr-specific area (`sse-client`, `tui`, `cli`, `observability`).
- `[2026-05-21]` **`ratatoskr.sse_client` implemented via TDD against issue #1's contract.** 37 contract-listed tests authored + GREEN per the tracer-bullet vertical-slice ordering (`_parse_sse_id` → `stream_turn` → `reconnect_turn` → `cancel_turn`). Refactor pass extracted `_iter_events` helper to dedupe INV-002 + INV-003 + terminal-break logic across `stream_turn` and `reconnect_turn`; `expected_turn_id=None` vs `expected_turn_id=N` distinguishes the two entry-point semantics Volva surfaced. Notable choices made during implementation: (a) regex `^-?\d+$` pre-check in `_parse_sse_id` to reject whitespace before `int()` (Python's `int(" 3 ")` would silently strip — this kept the strict-no-whitespace test honest); (b) `_DropAfter` AsyncByteStream subclass in tests to simulate mid-stream `RemoteProtocolError`; (c) ToolResult.result and ToolStart.arguments typed as `Any` (server JSON varies); (d) ruff line-length=100 (per pyproject) forced some test docstrings to be tighter than v0 draft.
- `[2026-05-21]` **Volva paraphrase round on `docs/contracts/issues/1.contract.md`.** Volva flagged 5 ambiguities; operator approved amendments to 3 of them. (1) `reconnect_turn` STEP 2 punt resolved: signature now carries `content: str`; STEP 2 body is `json={"content": content}` matching spec §Reconnect flow example verbatim. Spec line 732 makes the agent's tools+LLM run "exactly once regardless of disconnects/reconnects" — the `content` is a wire-schema requirement, not re-processed server-side. (2) `_parse_sse_id` tightened: `turn_id ≥ 1` AND `seq ≥ 1` (was `≥ 0`); spec §SSE id format line 705 explicitly states `seq` starts at 1, and `turn_id` is SQLite autoincrement (≥1). Test `happy_zero_seq` flipped to `zero_seq [adversarial]`; new `zero_turn_id` + `negative_seq` adversarial tests added. (3) INV-003 clarified to spell out the two-entry-point semantics: `stream_turn` establishes `turn_id` from the first event (first event always yields); `reconnect_turn` parses the expected `turn_id` FROM `last_event_id` BEFORE the connection opens, so the first server event is already a flip-candidate and is NOT yielded on mismatch. Volva flags #3 (MalformedSseId-vs-ValueError split) and #5 (exactly-one-terminal as server-assumed) noted but kept as-is — deliberate distinctions. Drift check still clean against issue #1 (amending the contract doesn't touch the pinned issue body).
## Tried and abandoned