Compare commits

..

1 Commits

Author SHA1 Message Date
vh 74d41eb559 fix(#20): heid-code-review fixups — INV-CUT-2 completeness on cancel/stream (slice-2)
Triaged the heid-code-review panel (Gróa + Hulda substantive, Regin zero=weak).

Adopted (genuine adds):
- cancel_turn + stream_turn gain a defensive `except ApiError -> SessionApiFailed`
  default after their discriminated branches. INV-CUT-2 ("every ApiError is mapped;
  default SessionApiFailed") now holds STRUCTURALLY on those routes, not by coupling
  to the SDK's internal guarantee that it maps them to discriminated types. + tests.
- get_session_tools error-path test (symmetric with messages).
- Contract § Error map amended: added the stream ProtocolError rows
  (Malformed*/TurnIdFlip -> ratatoskr same-named), clarified the cancel row (the SDK
  RAISES the typed races -> ratatoskr exceptions, only a 200/cancelled=False is a
  CancelResult; caller surface stays exception-based per DEC-2), and noted the
  ApiError default holds on stream+cancel too.

Rejected (category-5, wrong-grounding) — 2/3 arms flagged create's bound-502 as
"should gate on error_code like list's 422+cursor_invalid". Verified against the SDK
parser (not in the arms' file set): the bound-502 body is
{"error_code":"bifrost_handshake_failed","detail":{"bifrost_error":...}}, and the
SDK's envelope parser PREFERS the nested detail (which lacks error_code), so
ApiError.error_code resolves to "unknown" — gating would REGRESS handshake detection
(the cli/web integration tests caught it). INV-002 also makes the handshake the sole
bound-502 cause. Kept the any-bound-502 mapping; documented WHY in code + contract.

Accepted-as-is: create_session -> Mapping annotation (intentional open-world
passthrough, already documented in the route-map note; category 3).

Suite 493 green; wt.py mypy + ruff clean. Patch.
2026-07-19 06:57:52 -07:00
5 changed files with 47 additions and 7 deletions
@@ -158,18 +158,23 @@ others, they get their own row here — the default is NOT a general "any 404
| SDK `AgentNotAvailable` / `TurnLaunchUnavailable` / `SessionRetired` (stream-open) | ratatoskr `AgentNotAvailable` / `TurnLaunchUnavailable` / (retired → `SessionApiFailed`) — same names, passthrough |
| SDK `ConnectionDropped` (mid-stream) | `SseConnectionDropped` |
| SDK `ResumeError` subclasses (in resilient stream) | resilient `stream_turn` absorbs; terminal → `SseConnectFailed` |
| SDK `Cancel*` (cancel_turn) | folded into `CancelResult`; late-cancel race (B-CAN-3) returns `cancelled=False`, never raises |
| SDK `MalformedSseId` / `MalformedSseData` / `TurnIdFlip` (stream `ProtocolError`) | ratatoskr same-named types — same-name rewrap of the discriminated stream protocol errors |
| SDK `Cancel*` (cancel_turn) — the SDK RAISES the typed races | 404 `turn_not_found``CancelTurnNotFound`; 409 `turn_finished``CancelAlreadyCompleted`; other `CancelError``CancelFailed`. A 200 (incl. `cancelled=False`, the B-CAN-3 late-cancel no-op) returns a `CancelResult` — never raises. The caller surface stays exception-based (DEC-2; matches the pre-cutover CLI/web handlers). |
| `ApiError(404)` on `sessions.create` | `AgentNotFound` |
| `ApiError(404)` on `sessions.write_history` | `AuthoredHistoryUnavailable` (hide-existence) |
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` |
| `ApiError(502 bifrost_handshake_failed)` on bound `sessions.create` | `BifrostHandshakeFailed` |
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` (dual-key: status 422 AND error_code; the flat cursor body surfaces the code) |
| `ApiError(502)` on bound `sessions.create` | `BifrostHandshakeFailed` — NOT gated on error_code (unlike list's 422): INV-002, the synchronous handshake is the SOLE bound-502 cause; and the SDK's envelope parser prefers the nested `detail` (which carries `bifrost_error`, not `error_code`), so no distinguishing top-level `error_code` surfaces. The route+status IS the discriminator. |
| **`ApiError` (any other status/route) — the default** | `SessionApiFailed(status, error_code, body)` |
The default row is load-bearing: any `ApiError` not matched above surfaces as the
generic `SessionApiFailed` carrying the raw `status`/`error_code`/`body` — the
adapter does NOT invent per-route semantics the contract doesn't list, and does NOT
leave an `ApiError` un-mapped. Each slice adds/confirms its route's rows here before
the old path is deleted.
leave an `ApiError` un-mapped. **This default holds on EVERY route, including the
stream and cancel** (each carries a defensive `except ApiError → SessionApiFailed`
after its discriminated branches — the SDK maps those routes to discriminated types
today, but the default guarantees INV-CUT-2 structurally, not by SDK-internal
coupling). Each slice adds/confirms its route's rows here before the old path is
deleted.
## Slice plan (incremental, DEC-4)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.21.8"
version = "0.21.9"
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
readme = "README.md"
requires-python = ">=3.12"
+13
View File
@@ -197,6 +197,12 @@ async def create_session(
except ApiError as exc:
if exc.status == 404:
raise AgentNotFound(agent_id=agent_id) from exc
# NOT gated on error_code (unlike list's 422+cursor_invalid): INV-002 — a 502
# on a BOUND create IS the synchronous Bifrost handshake failing, the sole
# bound-502 cause; and the SDK does not surface a distinguishing top-level
# error_code here (its envelope parser prefers the nested `detail`, which
# carries `bifrost_error`, not `error_code`). The nested bifrost_error is
# extracted for the exception; the route+status is the discriminator.
if bifrost is not None and exc.status == 502:
raise BifrostHandshakeFailed(
bifrost_error=_bifrost_error_from_body(exc.body),
@@ -299,6 +305,10 @@ async def stream_turn(
raise MalformedSseData(raw=exc.raw) from exc
except wtsdk.TurnIdFlip as exc:
raise TurnIdFlip(established=exc.established, got=exc.got) from exc
except ApiError as exc:
# INV-CUT-2 default: an undiscriminated ApiError surfacing from the stream →
# SessionApiFailed (the discriminated stream errors are handled above).
raise translate_error(exc) from exc
async def cancel_turn(
@@ -323,3 +333,6 @@ async def cancel_turn(
raise CancelFailed(
status=0, body=(getattr(exc, "message", "") or str(exc)).encode()
) from exc
except ApiError as exc:
# INV-CUT-2 default: an undiscriminated ApiError on this route → SessionApiFailed.
raise translate_error(exc) from exc
+22
View File
@@ -299,6 +299,12 @@ class TestReadPassthroughs:
await get_session_messages(_wt(fake), "s")
assert ei.value.status == 401
async def test_tools_error_maps_to_session_api_failed(self) -> None:
fake = _FakeSessions(error=ApiError("auth_revoked", "no", status=401))
with pytest.raises(SessionApiFailed) as ei:
await get_session_tools(_wt(fake), "s")
assert ei.value.status == 401
async def _drain(aiter: Any) -> list[Any]:
out: list[Any] = []
@@ -376,6 +382,14 @@ class TestStreamTurn:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert (ei.value.established, ei.value.got) == (5, 7)
async def test_undiscriminated_api_error_maps_to_session_api_failed(self) -> None:
# INV-CUT-2 default: an undiscriminated ApiError surfacing from the stream
# (not a discriminated stream error) → SessionApiFailed.
fake = _FakeSessions(stream_error=ApiError("weird", "boom", status=500))
with pytest.raises(SessionApiFailed) as ei:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert ei.value.status == 500
class TestCancelTurn:
async def test_happy_returns_cancel_result(self) -> None:
@@ -410,3 +424,11 @@ class TestCancelTurn:
fake = _FakeSessions(error=wtsdk.CancelFailed(42, error_code="boom", message="failed"))
with pytest.raises(CancelFailed):
await cancel_turn(_wt(fake), "s", 42)
async def test_undiscriminated_api_error_maps_to_session_api_failed(self) -> None:
# INV-CUT-2 default: an undiscriminated ApiError on the cancel route (not a
# typed Cancel* race) → SessionApiFailed, never leaked as a bare ApiError.
fake = _FakeSessions(error=ApiError("weird", "boom", status=500))
with pytest.raises(SessionApiFailed) as ei:
await cancel_turn(_wt(fake), "s", 42)
assert ei.value.status == 500
Generated
+1 -1
View File
@@ -472,7 +472,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.21.8"
version = "0.21.9"
source = { editable = "." }
dependencies = [
{ name = "httpx" },