Compare commits

..

1 Commits

Author SHA1 Message Date
vh bb158ae47d feat(#20): sessions read/create adapter routes — ratatoskr.wt (slice-2, part 1)
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).
2026-07-19 00:19:33 -07:00
4 changed files with 322 additions and 3 deletions
+1 -1
View File
@@ -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"
+142
View File
@@ -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
+178 -1
View File
@@ -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
Generated
+1 -1
View File
@@ -472,7 +472,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.21.3"
version = "0.21.4"
source = { editable = "." }
dependencies = [
{ name = "httpx" },