From bb158ae47d1664ac7bd15b4268b0ec123fffc061 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sun, 19 Jul 2026 00:19:33 -0700 Subject: [PATCH] =?UTF-8?q?feat(#20):=20sessions=20read/create=20adapter?= =?UTF-8?q?=20routes=20=E2=80=94=20ratatoskr.wt=20(slice-2,=20part=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- pyproject.toml | 2 +- src/ratatoskr/wt.py | 142 +++++++++++++++++++++++++++++++++++ tests/test_wt.py | 179 +++++++++++++++++++++++++++++++++++++++++++- uv.lock | 2 +- 4 files changed, 322 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0a512c5..45c8a25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.21.3" +version = "0.21.4" description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/wt.py b/src/ratatoskr/wt.py index 674da14..8e4142f 100644 --- a/src/ratatoskr/wt.py +++ b/src/ratatoskr/wt.py @@ -27,9 +27,25 @@ carries the SDK's parsed `error_code`). from __future__ import annotations +import json +from collections.abc import Mapping +from typing import Any + import httpx from worldtree_sdk import ApiError, AuthProvider, WorldtreeClient +# Transitional (slice-2): the caller-semantic exceptions + the BifrostBinding input +# type still live in the retiring `sessions` module; they relocate into this adapter +# as their call-sites are rewired in later slice-2 commits. wt → sessions is one-way +# (sessions never imports wt), so there is no cycle. +from .sessions import ( + AgentNotFound, + BifrostBinding, + BifrostConsumerKeyMissing, + BifrostHandshakeFailed, + InvalidCursor, +) + class SessionApiFailed(Exception): """The adapter's DEFAULT caller-semantic error (INV-CUT-2 default row): any SDK @@ -90,3 +106,129 @@ def translate_error(exc: BaseException) -> BaseException: status=exc.status, error_code=exc.error_code, body=exc.body ) return exc + + +# ── slice-2: sessions/turn adapter routes ──────────────────────────────────── +# Ratatoskr-semantic call surfaces over `WorldtreeClient.sessions.*`. Each builds +# the request from ratatoskr's domain params, delegates the HTTP to the SDK, and +# maps the SDK's `ApiError` floor by ROUTE (INV-CUT-2) — route-specific rows first, +# `translate_error`'s `SessionApiFailed` default otherwise. Open-world reads are +# returned verbatim (the parity-pass posture: presenters read them as mappings, +# tolerant of wire drift). The turn STREAM + cancel land alongside the presenter +# rewire in the next slice-2 commit. + + +def _bifrost_error_from_body(body: str | None) -> str | None: + """Pull the spec-level `bifrost_error` from a bound-create 502 body string. + + Tolerates both the FastAPI-nested `{"detail": {"bifrost_error": …}}` shape (the + real wire form) and a flat top-level `bifrost_error` — the same both-shape + unwrap the hand-rolled path used, adapted to the SDK's already-parsed str body. + """ + if not body: + return None + try: + err = json.loads(body) + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(err, dict): + return None + bifrost_error = err.get("bifrost_error") + if bifrost_error is None and isinstance(err.get("detail"), dict): + bifrost_error = err["detail"].get("bifrost_error") + return bifrost_error + + +async def create_session( + client: WorldtreeClient, + agent_id: str, + *, + end_user_id: str | None = None, + bifrost: BifrostBinding | None = None, + consumer_key: str | None = None, + config: Mapping[str, Any] | None = None, +) -> Mapping[str, Any]: + """Create a session (POST /sessions), returning the open-world create result. + + Body-building mirrors the hand-rolled path: `{agent_id}` plus `end_user_id` / + `config` / `bifrost` when set. A bound create authenticates with `consumer_key` + via the SDK's per-request auth (never a header, never the canary fallback — + INV-001); the key is required pre-HTTP. Error map (INV-CUT-2): 404 → + `AgentNotFound`; a bound 502 → `BifrostHandshakeFailed`; otherwise the + `SessionApiFailed` default. + """ + assert agent_id and isinstance(agent_id, str) + assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id) + assert config is None or isinstance(config, Mapping) + # Ephemeral config + Bifrost binding are mutually exclusive (server 422s). + assert not (config is not None and bifrost is not None) + # INV-001: a bound create REQUIRES a non-empty consumer key — enforced pre-HTTP + # so it never falls back to the canary bearer. + if bifrost is not None and not (isinstance(consumer_key, str) and consumer_key): + raise BifrostConsumerKeyMissing() + + body: dict[str, Any] = {"agent_id": agent_id} + if end_user_id is not None: + body["end_user_id"] = end_user_id + if config is not None: + body["config"] = dict(config) + if bifrost is not None: + body["bifrost"] = {"endpoint_url": bifrost.endpoint_url, "scope": bifrost.scope} + + try: + return await client.sessions.create(body, consumer_key=consumer_key) + except ApiError as exc: + if exc.status == 404: + raise AgentNotFound(agent_id=agent_id) from exc + if bifrost is not None and exc.status == 502: + raise BifrostHandshakeFailed( + bifrost_error=_bifrost_error_from_body(exc.body), + body=(exc.body or "").encode(), + ) from exc + raise translate_error(exc) from exc + + +async def list_sessions( + client: WorldtreeClient, + *, + include_archived: bool = False, + limit: int = 50, + cursor: str | None = None, +) -> Mapping[str, Any]: + """List sessions (GET /sessions), returning the open-world page verbatim. A 422 + `cursor_invalid` → `InvalidCursor`; otherwise the `SessionApiFailed` default.""" + assert 1 <= limit <= 200 + assert cursor is None or (isinstance(cursor, str) and cursor) + try: + return await client.sessions.list( + limit=limit, cursor=cursor, include_archived=include_archived or None + ) + except ApiError as exc: + if exc.status == 422 and exc.error_code == "cursor_invalid": + raise InvalidCursor(raw=cursor) from exc + raise translate_error(exc) from exc + + +async def get_session_messages( + client: WorldtreeClient, session_id: str +) -> Mapping[str, Any]: + """The session's message history (GET /sessions/{id}/messages), verbatim. Any + error → the `SessionApiFailed` default (owner-scoped; 404 hide-existence stays + generic here — messages is not a hide-existence-mapped route).""" + assert session_id and isinstance(session_id, str) + try: + return await client.sessions.messages(session_id) + except ApiError as exc: + raise translate_error(exc) from exc + + +async def get_session_tools( + client: WorldtreeClient, session_id: str +) -> Mapping[str, Any]: + """The owner-scoped tool inventory (GET /sessions/{id}/tools), verbatim. Any + error → the `SessionApiFailed` default.""" + assert session_id and isinstance(session_id, str) + try: + return await client.sessions.tools(session_id) + except ApiError as exc: + raise translate_error(exc) from exc diff --git a/tests/test_wt.py b/tests/test_wt.py index 8878c64..2da352a 100644 --- a/tests/test_wt.py +++ b/tests/test_wt.py @@ -12,10 +12,69 @@ 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.wt import SessionApiFailed, build_client, translate_error +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: @@ -83,3 +142,121 @@ class TestTranslateError: 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 diff --git a/uv.lock b/uv.lock index 602cf8d..ecc9aa1 100644 --- a/uv.lock +++ b/uv.lock @@ -472,7 +472,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.21.3" +version = "0.21.4" source = { editable = "." } dependencies = [ { name = "httpx" },