bb158ae47d
First slice-2 increment: the presenter-independent sessions routes, additive and non-breaking (no surface rewired, no hand-rolled path deleted yet — the cli/web rewire + deletions + live smoke land in part 2). - create_session / list_sessions / get_session_messages / get_session_tools over WorldtreeClient.sessions.*, each building the request from ratatoskr's domain params and mapping the SDK's ApiError floor by ROUTE (INV-CUT-2): create 404 → AgentNotFound, bound 502 → BifrostHandshakeFailed, list 422 cursor_invalid → InvalidCursor, else the SessionApiFailed default. - Open-world reads returned VERBATIM (parity-pass posture): the routes return the SDK's open dicts, not ratatoskr's typed SessionInfo/SessionPage — those typed result shapes retire when the presenters are rewired to read mappings (adopt the dep's canonical open-world way, reference-impl doctrine). - Transitional: wt imports the caller-semantic exceptions + BifrostBinding from the retiring sessions module (one-way, no cycle); they relocate into the adapter as their call-sites are rewired. - Cancel + the resilient turn STREAM are deferred to part 2, where they wire into the async presenter loop and are validated by the live smoke. Suite 555 green (541 + 14); mypy strict + ruff clean. Patch (internal, additive).
263 lines
11 KiB
Python
263 lines
11 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
|
|
from worldtree_sdk import AgentNotAvailable, ApiError, WorldtreeClient
|
|
|
|
from ratatoskr.sessions import (
|
|
AgentNotFound,
|
|
BifrostBinding,
|
|
BifrostConsumerKeyMissing,
|
|
BifrostHandshakeFailed,
|
|
InvalidCursor,
|
|
)
|
|
from ratatoskr.wt import (
|
|
SessionApiFailed,
|
|
build_client,
|
|
create_session,
|
|
get_session_messages,
|
|
get_session_tools,
|
|
list_sessions,
|
|
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) -> None:
|
|
self._result = result
|
|
self._error = 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)
|
|
|
|
|
|
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 — the adapter passes them through by identity (no re-wrap).
|
|
exc = 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
|