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:
@@ -6,14 +6,8 @@ import respx
|
||||
|
||||
from ratatoskr.sessions import (
|
||||
SessionApiFailed,
|
||||
create_character,
|
||||
delete_character,
|
||||
endpoint_for_plane,
|
||||
get_capabilities,
|
||||
get_character_state,
|
||||
get_me,
|
||||
get_session_bifrost,
|
||||
list_character_models,
|
||||
)
|
||||
|
||||
|
||||
@@ -38,99 +32,6 @@ class TestEndpointForPlane:
|
||||
endpoint_for_plane("persona", "10.100.10.50")
|
||||
|
||||
|
||||
class TestGetMe:
|
||||
"""docs/contracts/issues/2.contract.md FN get_me (slice: capabilities+me)."""
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_authenticated(self) -> None:
|
||||
"""happy_authenticated [happy,tracer]: 200 → parsed identity dict verbatim."""
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"user_id": "alice",
|
||||
"scopes": ["conversations.read", "conversations.write"],
|
||||
"tier": "user",
|
||||
"key_id": "a1b2c3d4",
|
||||
"key_label": "alice phone",
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
me = await get_me(client)
|
||||
assert me["user_id"] == "alice"
|
||||
assert me["tier"] == "user"
|
||||
assert me["key_id"] == "a1b2c3d4"
|
||||
assert me["scopes"] == ["conversations.read", "conversations.write"]
|
||||
|
||||
@respx.mock
|
||||
async def test_anonymous_dev_mode(self) -> None:
|
||||
"""anonymous_dev_mode: 200 anonymous shape → dict with tier=anonymous."""
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"user_id": "anonymous",
|
||||
"scopes": ["conversations.read"],
|
||||
"tier": "anonymous",
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
me = await get_me(client)
|
||||
assert me["tier"] == "anonymous"
|
||||
assert "key_id" not in me # optional fields omitted, not null
|
||||
|
||||
@respx.mock
|
||||
async def test_401_raises_session_api_failed(self) -> None:
|
||||
"""401_raises [error]: bad/absent key → SessionApiFailed(status=401)."""
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(401, json={"detail": "auth_invalid"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await get_me(client)
|
||||
assert exc.value.status == 401
|
||||
|
||||
|
||||
class TestGetCapabilities:
|
||||
"""docs/contracts/issues/2.contract.md FN get_capabilities (slice: capabilities+me)."""
|
||||
|
||||
@respx.mock
|
||||
async def test_happy(self) -> None:
|
||||
"""happy [happy]: 200 → ephemeral_templates dict verbatim."""
|
||||
respx.get("https://w.example/capabilities").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ephemeral_templates": {
|
||||
"echo": {
|
||||
"allowed_models": ["glm5-turbo", "glm4.7"],
|
||||
"default_model": "glm5-turbo",
|
||||
"system_prompt_max_bytes": 32768,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
caps = await get_capabilities(client)
|
||||
echo = caps["ephemeral_templates"]["echo"]
|
||||
assert echo["default_model"] == "glm5-turbo"
|
||||
assert echo["system_prompt_max_bytes"] == 32768
|
||||
|
||||
@respx.mock
|
||||
async def test_non_200_raises(self) -> None:
|
||||
"""non_200_raises [error]: 500 → SessionApiFailed(status=500)."""
|
||||
respx.get("https://w.example/capabilities").mock(
|
||||
return_value=httpx.Response(500, content=b"boom")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await get_capabilities(client)
|
||||
assert exc.value.status == 500
|
||||
|
||||
|
||||
class TestGetSessionBifrost:
|
||||
"""#2 contract — get_session_bifrost (GET /admin/sessions/{id}/bifrost, #176)."""
|
||||
|
||||
@@ -191,59 +92,3 @@ class TestGetSessionBifrost:
|
||||
with pytest.raises(AssertionError):
|
||||
await get_session_bifrost(client, "s1", admin_key="")
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
class TestTransientCharacters:
|
||||
"""docs/contracts/issues/2.contract.md — transient-character wrappers (#161)."""
|
||||
|
||||
@respx.mock
|
||||
async def test_list_models(self) -> None:
|
||||
"""list_models [happy,tracer]: 200 → {items:[...]} verbatim."""
|
||||
respx.get("https://w.example/models/available-for-characters").mock(
|
||||
return_value=httpx.Response(200, json={"items": [{"name": "fast", "thinking": False}]})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
models = await list_character_models(client)
|
||||
assert models["items"][0]["name"] == "fast"
|
||||
|
||||
@respx.mock
|
||||
async def test_create_body_and_response(self) -> None:
|
||||
"""create [happy]: body is {character, state}; 201 → {character_id, ttl_expires_at}."""
|
||||
import json as _json
|
||||
|
||||
route = respx.post("https://w.example/characters").mock(
|
||||
return_value=httpx.Response(201, json={"character_id": "char_x", "ttl_expires_at": "t"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
out = await create_character(client, {"schema_version": "1", "name": "H"})
|
||||
assert out["character_id"] == "char_x"
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
assert body == {"character": {"schema_version": "1", "name": "H"}, "state": None}
|
||||
|
||||
@respx.mock
|
||||
async def test_get_state(self) -> None:
|
||||
"""get_state [happy]: 200 → live PAD/emotions snapshot."""
|
||||
respx.get("https://w.example/characters/char_x/state").mock(
|
||||
return_value=httpx.Response(200, json={"schema_version": "1", "pad": [0.4, 0.1, -0.2]})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
state = await get_character_state(client, "char_x")
|
||||
assert state["pad"] == [0.4, 0.1, -0.2]
|
||||
|
||||
@respx.mock
|
||||
async def test_delete_204(self) -> None:
|
||||
"""delete [happy]: 204 → None."""
|
||||
respx.delete("https://w.example/characters/char_x").mock(return_value=httpx.Response(204))
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
assert await delete_character(client, "char_x") is None
|
||||
|
||||
@respx.mock
|
||||
async def test_create_403_scope(self) -> None:
|
||||
"""create_403 [error]: key lacks character.write → SessionApiFailed(403)."""
|
||||
respx.post("https://w.example/characters").mock(
|
||||
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await create_character(client, {"name": "H"})
|
||||
assert exc.value.status == 403
|
||||
|
||||
Reference in New Issue
Block a user