deab7627eb
Slice-5 of the worldtree-sdk cutover: migrate the remaining consumer READS + transient-character CRUD off the hand-rolled httpx wrappers onto the `ratatoskr.wt` adapter over the SDK, and delete the retired path. Adapter (`wt.py`): add `get_me` / `get_capabilities` / `list_character_models` / `create_character` / `get_character_state` / `delete_character` over `client.me` / `client.capabilities` / `client.models` / `client.characters.*`. All six are open-world reads/acks returned verbatim; none carries a discriminated SDK error, so each maps any `ApiError` → the `SessionApiFailed` default (INV-CUT-2) — exact parity with the retired path. No new Error-map rows. Decisions (contract § slice-5 notes): `create_character` omits `state` when None (SDK-idiomatic inline literal, server-equivalent to the retired explicit null); `delete_character` returns the SDK's open ACK verbatim (`-> Mapping|None`, not normalized to None). CLI rewire (`cli.py`): `--whoami` (me + capabilities) and `--characters` (models → create → state → delete) build a `wt.build_client` over the injected probe transport and catch `wt.SessionApiFailed` + `ConnectFailed`. Open-world degrade-not-crash carried (cumulative cutover foot-gun): `_characters_probe` reads `items` null-safe and extracts `character_id` defensively (clean abort, no hard-index KeyError); `_format_whoami` widened to `Mapping`. Deleted the six hand-rolled `sessions.py` wrappers (net -5 mypy no-any-return); `endpoint_for_plane` + `get_session_bifrost` (slice-6) + the exception classes stay. Retired the corresponding `test_sessions.py` classes; added the slice-5 adapter tests + a CLI malformed-create-abort test. LIVE SMOKE (:8081, b128) — INV-CUT-5 / DEC-4 cleared: `--whoami` rendered real identity + capabilities; `--characters` drove the full lifecycle end-to-end (char-rp catalog → created char_8c00006e… → PAD read-back → deleted). Suite 483 green; ruff clean; mypy at the 2 pre-existing baseline errors. Patch bump 0.21.15 → 0.21.16 (the cutover MINOR is deferred to slice-7, DEC-6).
1069 lines
46 KiB
Python
1069 lines
46 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, PadState, WorldtreeClient
|
|
|
|
from ratatoskr.sessions import (
|
|
AgentNotAvailable as PersonaAgentNotAvailable,
|
|
)
|
|
from ratatoskr.sessions import (
|
|
AgentNotFound,
|
|
AuthoredHistoryUnavailable,
|
|
AuthScopeDenied,
|
|
BifrostBinding,
|
|
BifrostConsumerKeyMissing,
|
|
BifrostHandshakeFailed,
|
|
InvalidCursor,
|
|
PersonaNotConfigured,
|
|
Tier3AgentNotFound,
|
|
Tier3FieldNotMutable,
|
|
Tier3LayerDeferred,
|
|
Tier3QuotaExceeded,
|
|
Tier3UserIdUnsupported,
|
|
)
|
|
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_character,
|
|
create_session,
|
|
define_agent,
|
|
delete_agent,
|
|
delete_character,
|
|
get_capabilities,
|
|
get_character_state,
|
|
get_me,
|
|
get_persona_state,
|
|
get_session_messages,
|
|
get_session_tools,
|
|
list_agents,
|
|
list_character_models,
|
|
list_sessions,
|
|
patch_agent,
|
|
set_persona_state,
|
|
stream_turn,
|
|
translate_error,
|
|
write_authored_history,
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
async def set_persona_state(self, *args: Any, **kwargs: Any) -> Any:
|
|
return await self._dispatch("set_persona_state", *args, **kwargs)
|
|
|
|
async def write_history(self, *args: Any, **kwargs: Any) -> Any:
|
|
return await self._dispatch("write_history", *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_unbound_create_drops_consumer_key(self) -> None:
|
|
# A consumer_key must NOT reach the SDK on an UNBOUND create — the SDK's
|
|
# credential precedence would otherwise auth as the Bifrost consumer instead
|
|
# of the default bearer (heid-bug-hunt Gróa#4 / Regin#4).
|
|
fake = _FakeSessions(result={"session_id": "s"})
|
|
await create_session(_wt(fake), "mimir", consumer_key="ck-should-be-dropped")
|
|
assert fake.calls[-1][2]["consumer_key"] is None
|
|
|
|
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 test_tools_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_tools(_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)
|
|
|
|
async def test_undiscriminated_api_error_maps_to_session_api_failed(self) -> None:
|
|
# INV-CUT-2 default: an undiscriminated ApiError surfacing from the stream
|
|
# (not a discriminated stream error) → SessionApiFailed.
|
|
fake = _FakeSessions(stream_error=ApiError("weird", "boom", status=500))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
|
assert ei.value.status == 500
|
|
|
|
|
|
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)
|
|
|
|
async def test_undiscriminated_api_error_maps_to_session_api_failed(self) -> None:
|
|
# INV-CUT-2 default: an undiscriminated ApiError on the cancel route (not a
|
|
# typed Cancel* race) → SessionApiFailed, never leaked as a bare ApiError.
|
|
fake = _FakeSessions(error=ApiError("weird", "boom", status=500))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await cancel_turn(_wt(fake), "s", 42)
|
|
assert ei.value.status == 500
|
|
|
|
|
|
class TestSetPersonaState:
|
|
"""slice-3: set_persona_state → SDK sessions.set_persona_state(PadState). The
|
|
adapter builds the canonical PadState (the SDK owns the {"pad": {...}} wire
|
|
shape); no error row beyond the § Error map default (SessionApiFailed)."""
|
|
|
|
async def test_happy_builds_padstate_and_returns_none(self) -> None:
|
|
fake = _FakeSessions(result=None) # SDK resolves the 204 to None
|
|
out = await set_persona_state(
|
|
_wt(fake), "s-1", pleasure=0.4, arousal=0.1, dominance=-0.2
|
|
)
|
|
assert out is None
|
|
name, args, _kwargs = fake.calls[-1]
|
|
assert name == "set_persona_state"
|
|
assert args[0] == "s-1"
|
|
pad = args[1]
|
|
assert isinstance(pad, PadState)
|
|
assert (pad.pleasure, pad.arousal, pad.dominance) == (0.4, 0.1, -0.2)
|
|
|
|
async def test_falsy_zero_pad_preserved(self) -> None:
|
|
# A 0.0 axis must survive verbatim (not be dropped as falsy).
|
|
fake = _FakeSessions(result=None)
|
|
await set_persona_state(_wt(fake), "s", pleasure=0.0, arousal=0.0, dominance=0.0)
|
|
pad = fake.calls[-1][1][1]
|
|
assert (pad.pleasure, pad.arousal, pad.dominance) == (0.0, 0.0, 0.0)
|
|
|
|
async def test_error_maps_to_session_api_failed(self) -> None:
|
|
fake = _FakeSessions(error=ApiError("upstream", "boom", status=500, body="x"))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await set_persona_state(_wt(fake), "s", pleasure=0.0, arousal=0.0, dominance=0.0)
|
|
assert ei.value.status == 500
|
|
|
|
async def test_non_finite_pad_rejected_at_the_chokepoint(self) -> None:
|
|
# The finite-PAD invariant is enforced at the adapter (not only the CLI): a
|
|
# non-finite axis would serialize to null and corrupt the injection, so a
|
|
# direct caller is rejected pre-SDK — never a leaked SDK ConfigurationError.
|
|
fake = _FakeSessions(result=None)
|
|
for bad in (float("nan"), float("inf"), float("-inf")):
|
|
with pytest.raises(AssertionError):
|
|
await set_persona_state(_wt(fake), "s", pleasure=bad, arousal=0.0, dominance=0.0)
|
|
assert fake.calls == [] # never reached the SDK
|
|
|
|
|
|
class TestWriteAuthoredHistory:
|
|
"""slice-3: write_authored_history → SDK sessions.write_history. Builds the
|
|
v1 authored-write entry (author="assistant", the only accepted author);
|
|
404 → AuthoredHistoryUnavailable (hide-existence); else the default."""
|
|
|
|
async def test_happy_builds_entry_and_returns_dict(self) -> None:
|
|
ack = {"seq": 0, "phase": "seeded", "turn_id": "t1", "content_chars": 3}
|
|
fake = _FakeSessions(result=ack)
|
|
out = await write_authored_history(
|
|
_wt(fake), "s-1", content="hi!", idempotency_key="k1"
|
|
)
|
|
assert out is ack # open-world passthrough
|
|
name, args, _kwargs = fake.calls[-1]
|
|
assert name == "write_history"
|
|
assert args[0] == "s-1"
|
|
assert args[1] == {
|
|
"author": "assistant",
|
|
"content": "hi!",
|
|
"idempotency_key": "k1",
|
|
}
|
|
|
|
async def test_404_maps_to_authored_history_unavailable(self) -> None:
|
|
# Hide-existence: the ROUTE is the discriminator (never the body) — any 404
|
|
# on write_history → AuthoredHistoryUnavailable, no capability-probe.
|
|
fake = _FakeSessions(error=ApiError("session_not_found", "no", status=404))
|
|
with pytest.raises(AuthoredHistoryUnavailable) as ei:
|
|
await write_authored_history(_wt(fake), "s-1", content="hi", idempotency_key="k")
|
|
assert ei.value.session_id == "s-1"
|
|
|
|
async def test_other_error_maps_to_session_api_failed(self) -> None:
|
|
# 409 generation_active (retryable) is NOT a hide-existence 404 → default.
|
|
fake = _FakeSessions(error=ApiError("generation_active", "busy", status=409))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await write_authored_history(_wt(fake), "s", content="hi", idempotency_key="k")
|
|
assert ei.value.status == 409
|
|
|
|
|
|
# ── slice-4: agents (Tier-3) adapter routes ──────────────────────────────────
|
|
|
|
|
|
class _FakeAgents:
|
|
"""Stand-in for `WorldtreeClient.agents` — records the last call and returns a
|
|
canned result or raises a canned error. Same shape as `_FakeSessions`."""
|
|
|
|
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 list(self, *args: Any, **kwargs: Any) -> Any:
|
|
return await self._dispatch("list", *args, **kwargs)
|
|
|
|
async def persona_state(self, *args: Any, **kwargs: Any) -> Any:
|
|
return await self._dispatch("persona_state", *args, **kwargs)
|
|
|
|
async def define(self, *args: Any, **kwargs: Any) -> Any:
|
|
return await self._dispatch("define", *args, **kwargs)
|
|
|
|
async def patch(self, *args: Any, **kwargs: Any) -> Any:
|
|
return await self._dispatch("patch", *args, **kwargs)
|
|
|
|
async def delete(self, *args: Any, **kwargs: Any) -> Any:
|
|
return await self._dispatch("delete", *args, **kwargs)
|
|
|
|
|
|
class _FakeAgentsClient:
|
|
def __init__(self, agents: _FakeAgents) -> None:
|
|
self.agents = agents
|
|
|
|
|
|
def _wta(agents: _FakeAgents) -> WorldtreeClient:
|
|
"""Cast the structural agents-fake to the nominal client type (the agent route
|
|
functions only touch `client.agents.*`)."""
|
|
return cast(WorldtreeClient, _FakeAgentsClient(agents))
|
|
|
|
|
|
class TestListAgents:
|
|
"""slice-4: list_agents → SDK agents.list(); open-world array verbatim."""
|
|
|
|
async def test_happy_returns_array_verbatim(self) -> None:
|
|
data = [{"agent_id": "mimir", "name": "Mimir", "description": "k"}]
|
|
fake = _FakeAgents(result=data)
|
|
out = await list_agents(_wta(fake))
|
|
assert out is data # open-world passthrough, no AgentInfo normalization
|
|
assert fake.calls[-1][0] == "list"
|
|
|
|
async def test_error_maps_to_session_api_failed(self) -> None:
|
|
fake = _FakeAgents(error=ApiError("upstream", "boom", status=500))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await list_agents(_wta(fake))
|
|
assert ei.value.status == 500
|
|
|
|
|
|
class TestGetPersonaState:
|
|
"""slice-4: get_persona_state → SDK agents.persona_state(id); dict verbatim.
|
|
404 sub-codes + 403 auth_scope_denied map by (status, error_code)."""
|
|
|
|
async def test_happy_returns_dict_verbatim(self) -> None:
|
|
snap = {"pad": {}, "dominant_emotion": "curiosity"}
|
|
fake = _FakeAgents(result=snap)
|
|
out = await get_persona_state(_wta(fake), "mimir")
|
|
assert out is snap
|
|
assert fake.calls[-1] == ("persona_state", ("mimir",), {})
|
|
|
|
async def test_persona_not_configured(self) -> None:
|
|
fake = _FakeAgents(error=ApiError("persona_not_configured", "no", status=404))
|
|
with pytest.raises(PersonaNotConfigured) as ei:
|
|
await get_persona_state(_wta(fake), "domari")
|
|
assert ei.value.agent_id == "domari"
|
|
|
|
async def test_agent_not_available(self) -> None:
|
|
fake = _FakeAgents(error=ApiError("agent_not_available", "no", status=404))
|
|
with pytest.raises(PersonaAgentNotAvailable) as ei:
|
|
await get_persona_state(_wta(fake), "bogus")
|
|
assert ei.value.agent_id == "bogus"
|
|
|
|
async def test_auth_scope_denied(self) -> None:
|
|
fake = _FakeAgents(error=ApiError("auth_scope_denied", "no", status=403))
|
|
with pytest.raises(AuthScopeDenied) as ei:
|
|
await get_persona_state(_wta(fake), "mimir")
|
|
assert ei.value.scope == "persona.read"
|
|
|
|
async def test_other_404_without_code_maps_to_default(self) -> None:
|
|
# A 404 whose error_code is neither persona sub-code → generic default,
|
|
# NOT a spurious PersonaNotConfigured (the code is the discriminator).
|
|
fake = _FakeAgents(error=ApiError("weird", "no", status=404))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await get_persona_state(_wta(fake), "mimir")
|
|
assert ei.value.status == 404
|
|
|
|
async def test_empty_agent_id_asserts(self) -> None:
|
|
fake = _FakeAgents(result={})
|
|
with pytest.raises(AssertionError):
|
|
await get_persona_state(_wta(fake), "")
|
|
assert fake.calls == []
|
|
|
|
|
|
class TestDefineAgent:
|
|
"""slice-4: define_agent → SDK agents.define(); open-world DefinedAgent dict
|
|
(echoes `role` post-b128). Slug validated client-side; tier3 error rows."""
|
|
|
|
async def test_happy_builds_body_and_returns_dict(self) -> None:
|
|
resp = {"agent_id": "ratatoskr:wizard", "role": "thoughtful-character"}
|
|
fake = _FakeAgents(result=resp)
|
|
out = await define_agent(
|
|
_wta(fake), agent_name="wizard", system_prompt="You are a wizard.",
|
|
role="thoughtful-character",
|
|
)
|
|
assert out is resp # open-world passthrough (no Tier3AgentInfo)
|
|
name, args, _kwargs = fake.calls[-1]
|
|
assert name == "define"
|
|
# AgentDefineInput body: exactly the three keys, no layer fields.
|
|
assert args[0] == {
|
|
"agent_name": "wizard",
|
|
"role": "thoughtful-character",
|
|
"system_prompt": "You are a wizard.",
|
|
}
|
|
|
|
async def test_quota_exceeded_defaults_retry_after_zero(self) -> None:
|
|
# The SDK's ApiError floor drops the Retry-After header; spec §2675 pins it
|
|
# to 0, so the adapter defaults retry_after=0.
|
|
fake = _FakeAgents(error=ApiError("agent_quota_exceeded", "full", status=429))
|
|
with pytest.raises(Tier3QuotaExceeded) as ei:
|
|
await define_agent(_wta(fake), agent_name="overflow", system_prompt="x", role="m")
|
|
assert ei.value.retry_after == 0
|
|
|
|
async def test_user_id_unsupported(self) -> None:
|
|
fake = _FakeAgents(error=ApiError("tier3_user_id_unsupported", "no", status=403))
|
|
with pytest.raises(Tier3UserIdUnsupported):
|
|
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
|
|
|
async def test_layer_deferred_field_parsed_from_body(self) -> None:
|
|
# `field` is not on ApiError — the adapter body-parses detail.field.
|
|
fake = _FakeAgents(error=ApiError(
|
|
"layer_deferred", "no", status=422,
|
|
body='{"detail": {"error_code": "layer_deferred", "field": "persona"}}',
|
|
))
|
|
with pytest.raises(Tier3LayerDeferred) as ei:
|
|
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
|
assert ei.value.field == "persona"
|
|
|
|
async def test_layer_deferred_flat_field_body(self) -> None:
|
|
# _error_field_from_body also handles a flat top-level `field` (both-shape
|
|
# unwrap) — locks the contract's "detail.field / flat field" claim.
|
|
fake = _FakeAgents(error=ApiError(
|
|
"layer_deferred", "no", status=422, body='{"field": "valence"}',
|
|
))
|
|
with pytest.raises(Tier3LayerDeferred) as ei:
|
|
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
|
assert ei.value.field == "valence"
|
|
|
|
async def test_layer_deferred_non_string_field_is_none(self) -> None:
|
|
# A non-string `field` value collapses to None — the exception surface is
|
|
# `field: str | None` and the CLI prints it (heid-bug-hunt hardening).
|
|
fake = _FakeAgents(error=ApiError(
|
|
"layer_deferred", "no", status=422, body='{"detail": {"field": {"x": 1}}}',
|
|
))
|
|
with pytest.raises(Tier3LayerDeferred) as ei:
|
|
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
|
assert ei.value.field is None
|
|
|
|
async def test_403_wrong_code_maps_to_default(self) -> None:
|
|
# Dual-key negative: a 403 whose code is NOT tier3_user_id_unsupported →
|
|
# generic default, not a spurious Tier3UserIdUnsupported (INV-CUT-2).
|
|
fake = _FakeAgents(error=ApiError("auth_revoked", "no", status=403))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
|
assert ei.value.status == 403
|
|
|
|
async def test_422_wrong_code_maps_to_default(self) -> None:
|
|
# Dual-key negative: a 422 whose code is NOT layer_deferred → default.
|
|
fake = _FakeAgents(error=ApiError("validation_failed", "no", status=422))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
|
assert ei.value.status == 422
|
|
|
|
async def test_bad_slug_asserts_no_call(self) -> None:
|
|
fake = _FakeAgents(result={})
|
|
with pytest.raises(AssertionError):
|
|
await define_agent(_wta(fake), agent_name="Wizard", system_prompt="x", role="m")
|
|
assert fake.calls == []
|
|
|
|
async def test_short_slug_asserts_no_call(self) -> None:
|
|
fake = _FakeAgents(result={})
|
|
with pytest.raises(AssertionError):
|
|
await define_agent(_wta(fake), agent_name="ab", system_prompt="x", role="m")
|
|
assert fake.calls == []
|
|
|
|
async def test_empty_prompt_asserts(self) -> None:
|
|
fake = _FakeAgents(result={})
|
|
with pytest.raises(AssertionError):
|
|
await define_agent(_wta(fake), agent_name="wizard", system_prompt="", role="m")
|
|
assert fake.calls == []
|
|
|
|
async def test_empty_role_asserts(self) -> None:
|
|
fake = _FakeAgents(result={})
|
|
with pytest.raises(AssertionError):
|
|
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="")
|
|
assert fake.calls == []
|
|
|
|
async def test_other_5xx_maps_to_default(self) -> None:
|
|
fake = _FakeAgents(error=ApiError("upstream", "out", status=503))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
|
assert ei.value.status == 503
|
|
|
|
|
|
class TestPatchAgent:
|
|
"""slice-4: patch_agent → SDK agents.patch(id, changes); open PatchedAgent dict."""
|
|
|
|
async def test_happy_both_fields(self) -> None:
|
|
resp = {"agent_id": "ratatoskr:wizard", "role": "different"}
|
|
fake = _FakeAgents(result=resp)
|
|
out = await patch_agent(
|
|
_wta(fake), "ratatoskr:wizard", system_prompt="new", role="different"
|
|
)
|
|
assert out is resp
|
|
name, args, _kwargs = fake.calls[-1]
|
|
assert name == "patch"
|
|
assert args[0] == "ratatoskr:wizard"
|
|
assert args[1] == {"system_prompt": "new", "role": "different"}
|
|
|
|
async def test_happy_single_field_omits_none(self) -> None:
|
|
fake = _FakeAgents(result={})
|
|
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="only this")
|
|
assert fake.calls[-1][1][1] == {"system_prompt": "only this"}
|
|
|
|
async def test_404_maps_to_agent_not_found(self) -> None:
|
|
fake = _FakeAgents(error=ApiError("not_found", "no", status=404))
|
|
with pytest.raises(Tier3AgentNotFound) as ei:
|
|
await patch_agent(_wta(fake), "ratatoskr:ghost", system_prompt="x")
|
|
assert ei.value.agent_id == "ratatoskr:ghost"
|
|
|
|
async def test_field_not_mutable_field_parsed(self) -> None:
|
|
fake = _FakeAgents(error=ApiError(
|
|
"field_not_mutable", "no", status=422,
|
|
body='{"detail": {"error_code": "field_not_mutable", "field": "agent_name"}}',
|
|
))
|
|
with pytest.raises(Tier3FieldNotMutable) as ei:
|
|
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x")
|
|
assert ei.value.field == "agent_name"
|
|
|
|
async def test_422_wrong_code_maps_to_default(self) -> None:
|
|
# Dual-key negative: a 422 whose code is NOT field_not_mutable → default,
|
|
# not a spurious Tier3FieldNotMutable (INV-CUT-2).
|
|
fake = _FakeAgents(error=ApiError("validation_failed", "no", status=422))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x")
|
|
assert ei.value.status == 422
|
|
|
|
async def test_no_fields_asserts_no_call(self) -> None:
|
|
fake = _FakeAgents(result={})
|
|
with pytest.raises(AssertionError):
|
|
await patch_agent(_wta(fake), "ratatoskr:wizard")
|
|
assert fake.calls == []
|
|
|
|
async def test_non_tier3_id_asserts(self) -> None:
|
|
fake = _FakeAgents(result={})
|
|
with pytest.raises(AssertionError):
|
|
await patch_agent(_wta(fake), "mimir", system_prompt="x")
|
|
assert fake.calls == []
|
|
|
|
async def test_other_error_maps_to_default(self) -> None:
|
|
fake = _FakeAgents(error=ApiError("upstream", "boom", status=500))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x")
|
|
assert ei.value.status == 500
|
|
|
|
|
|
class TestDeleteAgent:
|
|
"""slice-4: delete_agent → SDK agents.delete(id); None on 204; 404 → not-found."""
|
|
|
|
async def test_happy_returns_none(self) -> None:
|
|
fake = _FakeAgents(result=None)
|
|
out = await delete_agent(_wta(fake), "ratatoskr:wizard")
|
|
assert out is None
|
|
assert fake.calls[-1] == ("delete", ("ratatoskr:wizard",), {})
|
|
|
|
async def test_404_maps_to_agent_not_found(self) -> None:
|
|
fake = _FakeAgents(error=ApiError("not_found", "no", status=404))
|
|
with pytest.raises(Tier3AgentNotFound) as ei:
|
|
await delete_agent(_wta(fake), "ratatoskr:ghost")
|
|
assert ei.value.agent_id == "ratatoskr:ghost"
|
|
|
|
async def test_non_tier3_id_asserts(self) -> None:
|
|
fake = _FakeAgents(result=None)
|
|
with pytest.raises(AssertionError):
|
|
await delete_agent(_wta(fake), "mimir")
|
|
assert fake.calls == []
|
|
|
|
async def test_other_error_maps_to_default(self) -> None:
|
|
fake = _FakeAgents(error=ApiError("upstream", "oops", status=500))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await delete_agent(_wta(fake), "ratatoskr:wizard")
|
|
assert ei.value.status == 500
|
|
|
|
|
|
# ── slice-5: characters + me/capabilities/models adapter routes ───────────────
|
|
# One canned result / error per fake (each slice-5 adapter fn touches exactly one
|
|
# sub-resource method), recorded by qualified name so the test can assert the route.
|
|
|
|
|
|
class _FakeMe:
|
|
def __init__(self, rec: _FakeMisc) -> None:
|
|
self._rec = rec
|
|
|
|
async def get(self, *a: Any, **k: Any) -> Any:
|
|
return await self._rec._dispatch("me.get", *a, **k)
|
|
|
|
|
|
class _FakeCapabilities:
|
|
def __init__(self, rec: _FakeMisc) -> None:
|
|
self._rec = rec
|
|
|
|
async def get(self, *a: Any, **k: Any) -> Any:
|
|
return await self._rec._dispatch("capabilities.get", *a, **k)
|
|
|
|
|
|
class _FakeModels:
|
|
def __init__(self, rec: _FakeMisc) -> None:
|
|
self._rec = rec
|
|
|
|
async def available_for_characters(self, *a: Any, **k: Any) -> Any:
|
|
return await self._rec._dispatch("models.available_for_characters", *a, **k)
|
|
|
|
|
|
class _FakeCharacters:
|
|
def __init__(self, rec: _FakeMisc) -> None:
|
|
self._rec = rec
|
|
|
|
async def create(self, *a: Any, **k: Any) -> Any:
|
|
return await self._rec._dispatch("characters.create", *a, **k)
|
|
|
|
async def state(self, *a: Any, **k: Any) -> Any:
|
|
return await self._rec._dispatch("characters.state", *a, **k)
|
|
|
|
async def delete(self, *a: Any, **k: Any) -> Any:
|
|
return await self._rec._dispatch("characters.delete", *a, **k)
|
|
|
|
|
|
class _FakeMisc:
|
|
"""Stand-in for the slice-5 client surface — exposes `.me` / `.capabilities` /
|
|
`.models` / `.characters`, recording each call under its qualified name and
|
|
returning a canned result or raising a canned error (same shape as `_FakeSessions`
|
|
/ `_FakeAgents`)."""
|
|
|
|
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]]] = []
|
|
self.me = _FakeMe(self)
|
|
self.capabilities = _FakeCapabilities(self)
|
|
self.models = _FakeModels(self)
|
|
self.characters = _FakeCharacters(self)
|
|
|
|
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
|
|
|
|
|
|
def _wtm(misc: _FakeMisc) -> WorldtreeClient:
|
|
"""Cast the slice-5 misc-surface fake (me/capabilities/models/characters) to the
|
|
nominal client type the route functions are typed against."""
|
|
return cast(WorldtreeClient, misc)
|
|
|
|
|
|
class TestGetMe:
|
|
"""slice-5: get_me → SDK me.get(); open-world dict verbatim."""
|
|
|
|
async def test_happy_returns_dict_verbatim(self) -> None:
|
|
me = {"user_id": "alice", "scopes": ["conversations.read"], "tier": "user"}
|
|
fake = _FakeMisc(result=me)
|
|
out = await get_me(_wtm(fake))
|
|
assert out is me
|
|
assert fake.calls[-1][0] == "me.get"
|
|
|
|
async def test_401_maps_to_session_api_failed(self) -> None:
|
|
fake = _FakeMisc(error=ApiError("auth_invalid", "no", status=401))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await get_me(_wtm(fake))
|
|
assert ei.value.status == 401
|
|
|
|
|
|
class TestGetCapabilities:
|
|
"""slice-5: get_capabilities → SDK capabilities.get(); open-world verbatim."""
|
|
|
|
async def test_happy_returns_dict_verbatim(self) -> None:
|
|
caps = {"ephemeral_templates": {"echo": {"default_role": "echo"}}}
|
|
fake = _FakeMisc(result=caps)
|
|
out = await get_capabilities(_wtm(fake))
|
|
assert out is caps
|
|
assert fake.calls[-1][0] == "capabilities.get"
|
|
|
|
async def test_error_maps_to_session_api_failed(self) -> None:
|
|
fake = _FakeMisc(error=ApiError("upstream", "boom", status=500))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await get_capabilities(_wtm(fake))
|
|
assert ei.value.status == 500
|
|
|
|
|
|
class TestListCharacterModels:
|
|
"""slice-5: list_character_models → SDK models.available_for_characters()."""
|
|
|
|
async def test_happy_returns_dict_verbatim(self) -> None:
|
|
models = {"items": [{"name": "fast", "thinking": False}]}
|
|
fake = _FakeMisc(result=models)
|
|
out = await list_character_models(_wtm(fake))
|
|
assert out is models
|
|
assert fake.calls[-1][0] == "models.available_for_characters"
|
|
|
|
async def test_error_maps_to_session_api_failed(self) -> None:
|
|
fake = _FakeMisc(error=ApiError("auth_scope_denied", "no", status=403))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await list_character_models(_wtm(fake))
|
|
assert ei.value.status == 403
|
|
|
|
|
|
class TestCreateCharacter:
|
|
"""slice-5: create_character → SDK characters.create(body); body-building + parity."""
|
|
|
|
async def test_happy_omits_state_when_none(self) -> None:
|
|
# SDK-idiomatic body: {character} only — no redundant explicit state:null.
|
|
created = {"character_id": "char_x", "ttl_expires_at": "t"}
|
|
fake = _FakeMisc(result=created)
|
|
out = await create_character(_wtm(fake), {"schema_version": "1", "name": "H"})
|
|
assert out is created
|
|
name, args, _ = fake.calls[-1]
|
|
assert name == "characters.create"
|
|
assert args[0] == {"character": {"schema_version": "1", "name": "H"}}
|
|
|
|
async def test_includes_state_when_supplied(self) -> None:
|
|
fake = _FakeMisc(result={"character_id": "c1"})
|
|
await create_character(
|
|
_wtm(fake), {"name": "H"}, state={"mood": "calm"}
|
|
)
|
|
assert fake.calls[-1][1][0] == {
|
|
"character": {"name": "H"},
|
|
"state": {"mood": "calm"},
|
|
}
|
|
|
|
async def test_empty_character_asserts_no_call(self) -> None:
|
|
fake = _FakeMisc(result={})
|
|
with pytest.raises(AssertionError):
|
|
await create_character(_wtm(fake), {})
|
|
assert fake.calls == []
|
|
|
|
async def test_403_maps_to_session_api_failed(self) -> None:
|
|
fake = _FakeMisc(error=ApiError("auth_scope_denied", "no", status=403))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await create_character(_wtm(fake), {"name": "H"})
|
|
assert ei.value.status == 403
|
|
|
|
|
|
class TestGetCharacterState:
|
|
"""slice-5: get_character_state → SDK characters.state(id); open-world verbatim."""
|
|
|
|
async def test_happy_returns_dict_verbatim(self) -> None:
|
|
state = {"schema_version": "1", "pad": [0.4, 0.1, -0.2]}
|
|
fake = _FakeMisc(result=state)
|
|
out = await get_character_state(_wtm(fake), "char_x")
|
|
assert out is state
|
|
assert fake.calls[-1] == ("characters.state", ("char_x",), {})
|
|
|
|
async def test_empty_id_asserts_no_call(self) -> None:
|
|
fake = _FakeMisc(result={})
|
|
with pytest.raises(AssertionError):
|
|
await get_character_state(_wtm(fake), "")
|
|
assert fake.calls == []
|
|
|
|
async def test_error_maps_to_session_api_failed(self) -> None:
|
|
fake = _FakeMisc(error=ApiError("not_found", "no", status=404))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await get_character_state(_wtm(fake), "char_x")
|
|
assert ei.value.status == 404
|
|
|
|
|
|
class TestDeleteCharacter:
|
|
"""slice-5: delete_character → SDK characters.delete(id); open ack verbatim."""
|
|
|
|
async def test_returns_ack_verbatim(self) -> None:
|
|
# Worldtree returns an open ack body here (not 204) — passed through, NOT
|
|
# normalized to None (parity posture).
|
|
ack = {"deleted": True, "character_id": "char_x"}
|
|
fake = _FakeMisc(result=ack)
|
|
out = await delete_character(_wtm(fake), "char_x")
|
|
assert out is ack
|
|
assert fake.calls[-1] == ("characters.delete", ("char_x",), {})
|
|
|
|
async def test_none_on_204(self) -> None:
|
|
# A 204 no-content yields None from the SDK — passed through unchanged.
|
|
fake = _FakeMisc(result=None)
|
|
assert await delete_character(_wtm(fake), "char_x") is None
|
|
|
|
async def test_empty_id_asserts_no_call(self) -> None:
|
|
fake = _FakeMisc(result=None)
|
|
with pytest.raises(AssertionError):
|
|
await delete_character(_wtm(fake), "")
|
|
assert fake.calls == []
|
|
|
|
async def test_error_maps_to_session_api_failed(self) -> None:
|
|
fake = _FakeMisc(error=ApiError("upstream", "oops", status=500))
|
|
with pytest.raises(SessionApiFailed) as ei:
|
|
await delete_character(_wtm(fake), "char_x")
|
|
assert ei.value.status == 500
|