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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user