feat(#20): stream + cancel adapter routes complete the wt surface (slice-2, part 2a)
Completes the adapter's session/turn surface, still additive and non-breaking (no surface rewired, no hand-rolled path deleted — the cli/web rewire + deletions + live smoke are part 2b). - stream_turn: drives the SDK's resilient stream (auto-resume absorbs the old reconnect_turn) and yields SDK TurnEvents, re-wrapping the stream's TERMINAL SDK errors into ratatoskr's caller-semantic exceptions per DEC-2 (SessionRetired → SessionApiFailed; AgentNotAvailable / TurnLaunchUnavailable / MalformedSse* / TurnIdFlip → ratatoskr's same-named types; ConnectionDropped → SseConnectionDropped; ConnectFailed / terminal ResumeError → SseConnectFailed). The presenter keeps catching ratatoskr types (part 2b aligns the except clauses). - cancel_turn: returns the SDK CancelResult (a 200 cancelled=False is the benign late-cancel race, B-CAN-3), mapping the typed cancel races onto ratatoskr's CancelTurnNotFound / CancelAlreadyCompleted / CancelFailed. - SseConnectionDropped.last_seen_sse_id widened to SseId | str | None: the SDK's resume cursor is a raw composite-id str (the cutover's target form); the hand-rolled path's SseId stays accepted until it is deleted. The one live reader (stream_turn_resilient) generalizes cleanly — a str cursor is already the id. Suite 570 green (555 + 15); wt.py + sse_client.py mypy + ruff clean. Patch.
This commit is contained in:
+154
-4
@@ -16,7 +16,8 @@ from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from worldtree_sdk import AgentNotAvailable, ApiError, WorldtreeClient
|
||||
import worldtree_sdk as wtsdk
|
||||
from worldtree_sdk import ApiError, CancelResult, WorldtreeClient
|
||||
|
||||
from ratatoskr.sessions import (
|
||||
AgentNotFound,
|
||||
@@ -25,13 +26,27 @@ from ratatoskr.sessions import (
|
||||
BifrostHandshakeFailed,
|
||||
InvalidCursor,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
AgentNotAvailable,
|
||||
CancelAlreadyCompleted,
|
||||
CancelFailed,
|
||||
CancelTurnNotFound,
|
||||
MalformedSseData,
|
||||
MalformedSseId,
|
||||
SseConnectFailed,
|
||||
SseConnectionDropped,
|
||||
TurnIdFlip,
|
||||
TurnLaunchUnavailable,
|
||||
)
|
||||
from ratatoskr.wt import (
|
||||
SessionApiFailed,
|
||||
build_client,
|
||||
cancel_turn,
|
||||
create_session,
|
||||
get_session_messages,
|
||||
get_session_tools,
|
||||
list_sessions,
|
||||
stream_turn,
|
||||
translate_error,
|
||||
)
|
||||
|
||||
@@ -41,9 +56,18 @@ class _FakeSessions:
|
||||
returns a canned result or raises a canned error. Lets the adapter's
|
||||
body-building + error-mapping be unit-tested without any SDK HTTP."""
|
||||
|
||||
def __init__(self, *, result: Any = None, error: BaseException | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
result: Any = None,
|
||||
error: BaseException | None = None,
|
||||
events: list[Any] | None = None,
|
||||
stream_error: BaseException | None = None,
|
||||
) -> None:
|
||||
self._result = result
|
||||
self._error = error
|
||||
self._events = events or []
|
||||
self._stream_error = stream_error
|
||||
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
|
||||
|
||||
async def _dispatch(self, name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
@@ -64,6 +88,19 @@ class _FakeSessions:
|
||||
async def tools(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._dispatch("tools", *args, **kwargs)
|
||||
|
||||
def stream_turn(self, *args: Any, **kwargs: Any) -> Any:
|
||||
self.calls.append(("stream_turn", args, kwargs))
|
||||
return self._astream()
|
||||
|
||||
async def _astream(self) -> Any:
|
||||
for event in self._events:
|
||||
yield event
|
||||
if self._stream_error is not None:
|
||||
raise self._stream_error
|
||||
|
||||
async def cancel_turn(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._dispatch("cancel_turn", *args, **kwargs)
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, sessions: _FakeSessions) -> None:
|
||||
@@ -135,8 +172,9 @@ class TestTranslateError:
|
||||
|
||||
def test_discriminated_subclass_passes_through_unchanged(self) -> None:
|
||||
# Discriminated WorldtreeError subclasses are already the right semantic
|
||||
# type — the adapter passes them through by identity (no re-wrap).
|
||||
exc = AgentNotAvailable("agent_not_available", "gone", status=409)
|
||||
# type at the REST layer — translate_error passes them through by identity
|
||||
# (the stream routes re-wrap them; that is stream_turn's job, not this one).
|
||||
exc = wtsdk.AgentNotAvailable("agent_not_available", "gone", status=409)
|
||||
assert translate_error(exc) is exc
|
||||
|
||||
def test_non_worldtree_error_passes_through_unchanged(self) -> None:
|
||||
@@ -260,3 +298,115 @@ class TestReadPassthroughs:
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await get_session_messages(_wt(fake), "s")
|
||||
assert ei.value.status == 401
|
||||
|
||||
|
||||
async def _drain(aiter: Any) -> list[Any]:
|
||||
out: list[Any] = []
|
||||
async for ev in aiter:
|
||||
out.append(ev)
|
||||
return out
|
||||
|
||||
|
||||
class TestStreamTurn:
|
||||
async def test_yields_events_verbatim(self) -> None:
|
||||
e1, e2 = object(), object()
|
||||
fake = _FakeSessions(events=[e1, e2])
|
||||
got = await _drain(stream_turn(_wt(fake), "s-1", "hello"))
|
||||
assert got == [e1, e2]
|
||||
assert fake.calls[-1] == ("stream_turn", ("s-1", "hello"), {})
|
||||
|
||||
async def test_session_retired_maps_to_session_api_failed(self) -> None:
|
||||
fake = _FakeSessions(
|
||||
stream_error=wtsdk.SessionRetired("session_retired", "gone", status=410)
|
||||
)
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
||||
assert ei.value.status == 410
|
||||
assert ei.value.error_code == "session_retired"
|
||||
|
||||
async def test_agent_not_available_rewraps_to_ratatoskr(self) -> None:
|
||||
fake = _FakeSessions(
|
||||
stream_error=wtsdk.AgentNotAvailable("agent_not_available", "no agent", status=409)
|
||||
)
|
||||
with pytest.raises(AgentNotAvailable) as ei:
|
||||
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
||||
assert ei.value.error_code == "agent_not_available"
|
||||
assert ei.value.status == 409
|
||||
|
||||
async def test_turn_launch_unavailable_rewraps_to_ratatoskr(self) -> None:
|
||||
fake = _FakeSessions(
|
||||
stream_error=wtsdk.TurnLaunchUnavailable("not_ready", "busy", status=503)
|
||||
)
|
||||
with pytest.raises(TurnLaunchUnavailable) as ei:
|
||||
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
||||
assert ei.value.retryable is True
|
||||
|
||||
async def test_generic_connect_failed_maps_to_sse_connect_failed(self) -> None:
|
||||
fake = _FakeSessions(stream_error=wtsdk.ConnectFailed("connect_failed", "boom", status=500))
|
||||
with pytest.raises(SseConnectFailed) as ei:
|
||||
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
||||
assert ei.value.status == 500
|
||||
|
||||
async def test_connection_dropped_maps_and_carries_cursor(self) -> None:
|
||||
fake = _FakeSessions(stream_error=wtsdk.ConnectionDropped("12:3"))
|
||||
with pytest.raises(SseConnectionDropped) as ei:
|
||||
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
||||
assert ei.value.last_seen_sse_id == "12:3"
|
||||
|
||||
async def test_resume_error_maps_to_sse_connect_failed(self) -> None:
|
||||
fake = _FakeSessions(stream_error=wtsdk.ResumeError("resume_failed", "boom", status=412))
|
||||
with pytest.raises(SseConnectFailed) as ei:
|
||||
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
||||
assert ei.value.status == 412
|
||||
|
||||
async def test_malformed_sse_id_passes_through_as_ratatoskr(self) -> None:
|
||||
fake = _FakeSessions(stream_error=wtsdk.MalformedSseId("bad-id"))
|
||||
with pytest.raises(MalformedSseId) as ei:
|
||||
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
||||
assert ei.value.raw == "bad-id"
|
||||
|
||||
async def test_malformed_sse_data_passes_through_as_ratatoskr(self) -> None:
|
||||
fake = _FakeSessions(stream_error=wtsdk.MalformedSseData("not json"))
|
||||
with pytest.raises(MalformedSseData):
|
||||
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
||||
|
||||
async def test_turn_id_flip_carries_established_and_got(self) -> None:
|
||||
fake = _FakeSessions(stream_error=wtsdk.TurnIdFlip(5, 7))
|
||||
with pytest.raises(TurnIdFlip) as ei:
|
||||
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
||||
assert (ei.value.established, ei.value.got) == (5, 7)
|
||||
|
||||
|
||||
class TestCancelTurn:
|
||||
async def test_happy_returns_cancel_result(self) -> None:
|
||||
res = CancelResult(turn_id=42, cancelled=True, reason=None, partial_message_id=None)
|
||||
fake = _FakeSessions(result=res)
|
||||
out = await cancel_turn(_wt(fake), "s-1", 42, persist_partial=True)
|
||||
assert out is res
|
||||
assert fake.calls[-1] == ("cancel_turn", ("s-1", 42), {"persist_partial": True})
|
||||
|
||||
async def test_late_cancel_race_is_a_result_not_an_error(self) -> None:
|
||||
# B-CAN-3: a 200 with cancelled=False is the benign late-cancel no-op.
|
||||
res = CancelResult(turn_id=42, cancelled=False, reason=None, partial_message_id=None)
|
||||
out = await cancel_turn(_wt(_FakeSessions(result=res)), "s", 42)
|
||||
assert out.cancelled is False
|
||||
|
||||
async def test_turn_not_found_maps_to_ratatoskr(self) -> None:
|
||||
fake = _FakeSessions(
|
||||
error=wtsdk.CancelTurnNotFound(42, error_code="turn_not_found", message="gone")
|
||||
)
|
||||
with pytest.raises(CancelTurnNotFound) as ei:
|
||||
await cancel_turn(_wt(fake), "s", 42)
|
||||
assert ei.value.turn_id == 42
|
||||
|
||||
async def test_turn_finished_maps_to_ratatoskr(self) -> None:
|
||||
fake = _FakeSessions(
|
||||
error=wtsdk.CancelAlreadyCompleted(42, error_code="turn_finished", message="done")
|
||||
)
|
||||
with pytest.raises(CancelAlreadyCompleted):
|
||||
await cancel_turn(_wt(fake), "s", 42)
|
||||
|
||||
async def test_other_cancel_failure_maps_to_cancel_failed(self) -> None:
|
||||
fake = _FakeSessions(error=wtsdk.CancelFailed(42, error_code="boom", message="failed"))
|
||||
with pytest.raises(CancelFailed):
|
||||
await cancel_turn(_wt(fake), "s", 42)
|
||||
|
||||
Reference in New Issue
Block a user