b907a7b8a5
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.
413 lines
17 KiB
Python
413 lines
17 KiB
Python
"""Unit tests for the worldtree-sdk adapter (`ratatoskr.wt`) — slice-1 foundation.
|
|
|
|
Covers the two foundation surfaces (issue #20 cutover contract, slice 1):
|
|
* `build_client` — construction wiring + injected-transport ownership (INV-CUT-1:
|
|
the SDK must never close ratatoskr's transport).
|
|
* `translate_error` — the § Error map DEFAULT (`ApiError` → `SessionApiFailed`)
|
|
plus discriminated-`WorldtreeError` passthrough (INV-CUT-2).
|
|
|
|
No ratatoskr surface (CLI / web / TUI) is exercised here — that wiring lands in
|
|
slice 2. These tests hit no network (WorldtreeClient does no I/O at construction).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, cast
|
|
|
|
import httpx
|
|
import pytest
|
|
import worldtree_sdk as wtsdk
|
|
from worldtree_sdk import ApiError, CancelResult, WorldtreeClient
|
|
|
|
from ratatoskr.sessions import (
|
|
AgentNotFound,
|
|
BifrostBinding,
|
|
BifrostConsumerKeyMissing,
|
|
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,
|
|
)
|
|
|
|
|
|
class _FakeSessions:
|
|
"""A stand-in for `WorldtreeClient.sessions` — records the last call and
|
|
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,
|
|
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:
|
|
self.calls.append((name, args, kwargs))
|
|
if self._error is not None:
|
|
raise self._error
|
|
return self._result
|
|
|
|
async def create(self, *args: Any, **kwargs: Any) -> Any:
|
|
return await self._dispatch("create", *args, **kwargs)
|
|
|
|
async def list(self, *args: Any, **kwargs: Any) -> Any:
|
|
return await self._dispatch("list", *args, **kwargs)
|
|
|
|
async def messages(self, *args: Any, **kwargs: Any) -> Any:
|
|
return await self._dispatch("messages", *args, **kwargs)
|
|
|
|
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:
|
|
self.sessions = sessions
|
|
|
|
|
|
def _wt(sessions: _FakeSessions) -> WorldtreeClient:
|
|
"""Cast the structural fake to the nominal client type the adapter is typed
|
|
against — the route functions only touch `client.sessions.*`, which the fake
|
|
provides. (No network; construction does no I/O.)"""
|
|
return cast(WorldtreeClient, _FakeClient(sessions))
|
|
|
|
|
|
class TestBuildClient:
|
|
async def test_constructs_worldtree_client(self) -> None:
|
|
transport = httpx.AsyncClient()
|
|
try:
|
|
client = build_client(
|
|
"https://wt.example:8081", api_key="ck-test", transport=transport
|
|
)
|
|
assert isinstance(client, WorldtreeClient)
|
|
assert client.base_url == "https://wt.example:8081"
|
|
finally:
|
|
await transport.aclose()
|
|
|
|
async def test_injected_transport_is_ratatoskr_owned(self) -> None:
|
|
# INV-CUT-1 [hard]: aclose() on the SDK client must NOT close ratatoskr's
|
|
# transport — ratatoskr owns the lifecycle exactly as it does today.
|
|
transport = httpx.AsyncClient()
|
|
client = build_client("https://wt.example", api_key="ck", transport=transport)
|
|
await client.aclose()
|
|
assert client.closed is True
|
|
assert transport.is_closed is False
|
|
await transport.aclose()
|
|
|
|
async def test_admin_key_optional(self) -> None:
|
|
transport = httpx.AsyncClient()
|
|
try:
|
|
# Absent admin_key → admin_auth=None; still constructs.
|
|
without_admin = build_client(
|
|
"https://wt.example", api_key="ck", transport=transport
|
|
)
|
|
assert isinstance(without_admin, WorldtreeClient)
|
|
# Present admin_key → constructs (admin surface available in later slices).
|
|
with_admin = build_client(
|
|
"https://wt.example", api_key="ck", admin_key="ak", transport=transport
|
|
)
|
|
assert isinstance(with_admin, WorldtreeClient)
|
|
finally:
|
|
await transport.aclose()
|
|
|
|
|
|
class TestTranslateError:
|
|
def test_apierror_maps_to_session_api_failed_default(self) -> None:
|
|
exc = ApiError("some_code", "boom", status=500, body="raw-body")
|
|
mapped = translate_error(exc)
|
|
assert isinstance(mapped, SessionApiFailed)
|
|
assert mapped.status == 500
|
|
assert mapped.error_code == "some_code"
|
|
assert mapped.body == "raw-body"
|
|
|
|
def test_apierror_with_no_body_maps_cleanly(self) -> None:
|
|
exc = ApiError("nope", "no body", status=404)
|
|
mapped = translate_error(exc)
|
|
assert isinstance(mapped, SessionApiFailed)
|
|
assert mapped.status == 404
|
|
assert mapped.error_code == "nope"
|
|
assert mapped.body is None
|
|
|
|
def test_discriminated_subclass_passes_through_unchanged(self) -> None:
|
|
# Discriminated WorldtreeError subclasses are already the right semantic
|
|
# 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:
|
|
exc = ValueError("unrelated")
|
|
assert translate_error(exc) is exc
|
|
|
|
|
|
class TestCreateSession:
|
|
async def test_happy_returns_sdk_dict_and_builds_body(self) -> None:
|
|
info = {"session_id": "s-1", "agent_id": "mimir", "created_at": "t", "last_active": "t"}
|
|
fake = _FakeSessions(result=info)
|
|
client = _wt(fake)
|
|
out = await create_session(client, "mimir", end_user_id="u-9")
|
|
assert out is info # open-world passthrough — no re-shaping
|
|
name, args, kwargs = fake.calls[-1]
|
|
assert name == "create"
|
|
assert args[0] == {"agent_id": "mimir", "end_user_id": "u-9"}
|
|
assert kwargs["consumer_key"] is None
|
|
|
|
async def test_config_passthrough(self) -> None:
|
|
fake = _FakeSessions(result={"session_id": "s"})
|
|
await create_session(
|
|
_wt(fake), "echo", config={"system_prompt": "be terse"}
|
|
)
|
|
assert fake.calls[-1][1][0] == {
|
|
"agent_id": "echo",
|
|
"config": {"system_prompt": "be terse"},
|
|
}
|
|
|
|
async def test_bifrost_bound_body_and_consumer_key(self) -> None:
|
|
fake = _FakeSessions(result={"session_id": "s"})
|
|
binding = BifrostBinding(endpoint_url="http://h:8391", scope=None)
|
|
await create_session(
|
|
_wt(fake), "sindra", bifrost=binding, consumer_key="ck-real"
|
|
)
|
|
_name, args, kwargs = fake.calls[-1]
|
|
assert args[0] == {
|
|
"agent_id": "sindra",
|
|
"bifrost": {"endpoint_url": "http://h:8391", "scope": None},
|
|
}
|
|
# INV-CUT: the consumer key rides the SDK's per-request auth, NOT a header.
|
|
assert kwargs["consumer_key"] == "ck-real"
|
|
|
|
async def test_bifrost_without_consumer_key_rejected_pre_http(self) -> None:
|
|
fake = _FakeSessions(result={"session_id": "s"})
|
|
binding = BifrostBinding(endpoint_url="http://h:8391", scope=None)
|
|
with pytest.raises(BifrostConsumerKeyMissing):
|
|
await create_session(_wt(fake), "sindra", bifrost=binding)
|
|
assert fake.calls == [] # never reached the SDK
|
|
|
|
async def test_404_maps_to_agent_not_found(self) -> None:
|
|
fake = _FakeSessions(error=ApiError("agent_not_found", "no", status=404))
|
|
with pytest.raises(AgentNotFound) as ei:
|
|
await create_session(_wt(fake), "ghost")
|
|
assert ei.value.agent_id == "ghost"
|
|
|
|
async def test_bound_502_maps_to_bifrost_handshake_failed(self) -> None:
|
|
body = '{"detail": {"bifrost_error": "bifrost.auth_rejected"}}'
|
|
fake = _FakeSessions(
|
|
error=ApiError("bifrost_handshake_failed", "boom", status=502, body=body)
|
|
)
|
|
binding = BifrostBinding(endpoint_url="http://h:8391", scope=None)
|
|
with pytest.raises(BifrostHandshakeFailed) as ei:
|
|
await create_session(
|
|
_wt(fake), "sindra", bifrost=binding, consumer_key="ck"
|
|
)
|
|
assert ei.value.bifrost_error == "bifrost.auth_rejected"
|
|
|
|
async def test_unbound_502_stays_session_api_failed(self) -> None:
|
|
fake = _FakeSessions(error=ApiError("upstream", "boom", status=502, body="x"))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await create_session(_wt(fake), "mimir")
|
|
assert ei.value.status == 502
|
|
|
|
async def test_default_error_maps_to_session_api_failed(self) -> None:
|
|
fake = _FakeSessions(error=ApiError("weird", "boom", status=418, body="teapot"))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await create_session(_wt(fake), "mimir")
|
|
assert ei.value.status == 418
|
|
assert ei.value.error_code == "weird"
|
|
|
|
|
|
class TestListSessions:
|
|
async def test_passes_params_and_returns_dict(self) -> None:
|
|
page: dict[str, Any] = {"items": [], "next_cursor": None}
|
|
fake = _FakeSessions(result=page)
|
|
out = await list_sessions(_wt(fake), limit=10, cursor="c1", include_archived=True)
|
|
assert out is page
|
|
kwargs = fake.calls[-1][2]
|
|
assert kwargs["limit"] == 10
|
|
assert kwargs["cursor"] == "c1"
|
|
assert kwargs["include_archived"] is True
|
|
|
|
async def test_422_cursor_invalid_maps_to_invalid_cursor(self) -> None:
|
|
fake = _FakeSessions(error=ApiError("cursor_invalid", "bad", status=422))
|
|
with pytest.raises(InvalidCursor) as ei:
|
|
await list_sessions(_wt(fake), cursor="bogus")
|
|
assert ei.value.raw == "bogus"
|
|
|
|
async def test_other_422_stays_session_api_failed(self) -> None:
|
|
fake = _FakeSessions(error=ApiError("validation_failed", "x", status=422))
|
|
with pytest.raises(SessionApiFailed):
|
|
await list_sessions(_wt(fake))
|
|
|
|
|
|
class TestReadPassthroughs:
|
|
async def test_messages_returns_dict(self) -> None:
|
|
data = {"session_id": "s", "items": []}
|
|
fake = _FakeSessions(result=data)
|
|
assert await get_session_messages(_wt(fake), "s") is data
|
|
assert fake.calls[-1][0] == "messages"
|
|
|
|
async def test_tools_returns_dict(self) -> None:
|
|
data = {"agent_id": "mimir", "builtin_tools": []}
|
|
fake = _FakeSessions(result=data)
|
|
assert await get_session_tools(_wt(fake), "s") is data
|
|
assert fake.calls[-1][0] == "tools"
|
|
|
|
async def test_messages_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_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)
|