feat(#20): characters + me/capabilities/models onto the wt adapter (slice-5)

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).
This commit is contained in:
vh
2026-07-19 11:00:41 -07:00
parent 4f74645a00
commit deab7627eb
10 changed files with 436 additions and 286 deletions
+217
View File
@@ -53,13 +53,19 @@ 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,
@@ -849,3 +855,214 @@ class TestDeleteAgent:
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