Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb158ae47d |
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user