From deab7627eb1051f80211818058c323afbdc51562 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sun, 19 Jul 2026 11:00:41 -0700 Subject: [PATCH] feat(#20): characters + me/capabilities/models onto the wt adapter (slice-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../worldtree_sdk_cutover.contract.md | 31 +++ docs/coverage-map.md | 12 +- pyproject.toml | 2 +- src/ratatoskr/cli.py | 84 ++++--- src/ratatoskr/sessions.py | 90 -------- src/ratatoskr/wt.py | 108 +++++++++ tests/test_cli.py | 21 ++ tests/test_sessions.py | 155 ------------- tests/test_wt.py | 217 ++++++++++++++++++ uv.lock | 2 +- 10 files changed, 436 insertions(+), 286 deletions(-) diff --git a/docs/contracts/worldtree_sdk_cutover.contract.md b/docs/contracts/worldtree_sdk_cutover.contract.md index 7731c97..315f66a 100644 --- a/docs/contracts/worldtree_sdk_cutover.contract.md +++ b/docs/contracts/worldtree_sdk_cutover.contract.md @@ -247,6 +247,37 @@ re-anchor its coverage-map rows. per the heid-code-review slice-4 precision flag (the § Error map 404 rows assume a well-formed Tier-3 id reaches the route). +### Slice-5 notes (Characters + me/capabilities/models, decided at TDD) + +- **No new § Error map rows.** All six routes (`me.get`, `capabilities.get`, + `models.available_for_characters`, `characters.create` / `.state` / `.delete`) are + open-world reads/acks (B-OPEN-2) whose SDK ops carry NO discriminated error (no + `map_error`), so every `ApiError` maps to the default `SessionApiFailed` — exact + parity with the retiring hand-rolled path, which likewise raised only its generic + `SessionApiFailed` on any non-2xx (never discriminating a status/code on these + routes). The route-map table above already lists all six. +- **`create_character` body — omit `state` when None.** The adapter sends + `{"character": …}` plus `"state"` only when the caller supplies a non-None state + (the SDK forwards the body dict as-is via httpx `json=`). This drops the hand-rolled + path's redundant explicit `"state": null` — server-equivalent (Worldtree's + `CreateCharacterRequest.state` defaults None whether omitted or explicit-null), + SDK-idiomatic (matches the SDK's `CreateCharacterInput` `NotRequired` shape), and + invisible at the sole call-site (`--characters` never passes a state). Adopt- + canonical over byte-for-byte wire parity. +- **`delete_character` returns the SDK's open ack verbatim (`-> Mapping | None`).** + The SDK route returns an open-world ack body (not 204 — `CharacterDeleteResult`), so + the adapter passes it through rather than normalizing to the hand-rolled `None` + (parity posture: no None-normalization of an open-world read). On a 204 no-content + the SDK yields `None`, so the return type is `Mapping | None`; the sole call-site + (`--characters`) ignores the value, so the change is unobservable. +- **Open-world presenter degrade-not-crash (cumulative foot-gun).** `_format_whoami` + is already hardened (slice-4 heid bug-hunt). The rewired `_characters_probe` extracts + the created id defensively (`created.get("character_id")` + type-guard → clean abort, + never a hard-index KeyError) since the create ACK is now an open-world SDK read. +- **CLI-only rewire.** `me` / `capabilities` / `characters` / `models` have NO + web-server caller — only the `--whoami` and `--characters` CLI one-shot probes. The + web surface is untouched this slice. + ## Out of scope - Bifrost PROVIDER planes (memory/affect) — hand-rolled, ADR-0009, untouched. diff --git a/docs/coverage-map.md b/docs/coverage-map.md index 09441b7..f329f93 100644 --- a/docs/coverage-map.md +++ b/docs/coverage-map.md @@ -94,15 +94,15 @@ sub-gap). | `POST /agents/define` | ✅ | `wt.py` `define_agent` (SDK `agents.define`) → `tier3.py` `_run_define` | **wt-adapter re-anchored (slice-4, #20)** — sends AgentDefineInput `{agent_name,role,system_prompt}`, returns open-world `DefinedAgent` (echoes `role`, b128); slug pre-validated; 429→Tier3QuotaExceeded(retry_after=0, header-less floor), 403→Tier3UserIdUnsupported, 422 layer_deferred→Tier3LayerDeferred. **LIVE-SMOKE 2026-07-19**: `define --role thoughtful-character` → `defined ratatoskr:slice4-smoke (thoughtful-character)` | | `PATCH /agents/{id}` | ✅ | `wt.py` `patch_agent` (SDK `agents.patch`) → `tier3.py` `_run_patch` | **wt-adapter re-anchored (slice-4, #20)** — Tier-3 mutate (system_prompt/**role**, model→role folded in); 404→Tier3AgentNotFound, 422 field_not_mutable→Tier3FieldNotMutable. **LIVE-SMOKE 2026-07-19**: `patched ratatoskr:slice4-smoke`; a non-existent id via `python -m` → `[agent_not_found]` (exit 20, class-identity fix proven) | | `DELETE /agents/{id}` | ✅ | `wt.py` `delete_agent` (SDK `agents.delete`) → `tier3.py` `_run_delete` | **wt-adapter re-anchored (slice-4, #20)** — 204→None; 404→Tier3AgentNotFound (route-discriminated, NOT hide-existence). **LIVE-SMOKE 2026-07-19**: `deleted ratatoskr:slice4-smoke` + local index → `[]` | -| `GET /me` | ✅ | `sessions.py:411` `get_me` → `cli.py` `--whoami` | identity/whoami probe; 401→SessionApiFailed | -| `GET /capabilities` | ✅ | `sessions.py` `get_capabilities` → `cli.py` `--whoami` | Echo ephemeral-template discovery. **v0.21.2: `--whoami` renderer reads `allowed_roles`/`default_role`** (was the dead `allowed_models`/`default_model`) + tolerates malformed caps; matches conversation-api-spec **v1.1** (`b4a278c`) | +| `GET /me` | ✅ | `wt.py` `get_me` (SDK `me.get`) → `cli.py` `--whoami` | **wt-adapter re-anchored (slice-5, #20)** — open-world identity dict verbatim; any error→SessionApiFailed default (401 on a bad/absent key), transport→ConnectFailed→exit 21. **LIVE-SMOKE 2026-07-19** on personal :8081 (b128): identity rendered (user_id ratatoskr, tier user, scopes incl. `character.*`, key_id c990f0be) | +| `GET /capabilities` | ✅ | `wt.py` `get_capabilities` (SDK `capabilities.get`) → `cli.py` `--whoami` | **wt-adapter re-anchored (slice-5, #20)** — open-world advertisement verbatim; `_format_whoami` reads `allowed_roles`/`default_role` and degrades on a null/non-mapping template (slice-4 hardening); matches conversation-api-spec **v1.1** (`b4a278c`). **LIVE-SMOKE 2026-07-19**: `ephemeral_template echo: default=echo max_bytes=32768 roles=[echo]` | | `GET /sessions/{id}/tools` | ✅ | `sessions.py:411` `get_session_tools` → `tui.py` `_hydrate_session_tools` | owner-scoped tool inventory in the TUI Tools pane (#183) | | `GET /admin/sessions/{id}/bifrost` | ✅ | `sessions.py:428` `get_session_bifrost` → `tui.py` `_hydrate_bifrost_state` | admin-scoped BifrostState pane (#176); admin key (`RATATOSKR_ADMIN_API_KEY`); live-auth-proven | | `GET /admin/events` (SSE) | ✅ | `sse_client.py` `stream_admin_events` → `tui.py` `_stream_admin_events` | admin lifecycle SSE stream (#11), session-filtered AdminEvents pane; admin key; live-auth-proven | -| `GET /models/available-for-characters` | ✅ | `sessions.py` `list_character_models` → `cli.py` `--characters` | character-capable model profiles (#161) | -| `POST /characters` | ✅ | `sessions.py` `create_character` → `cli.py` `--characters` | create transient character (#161) | -| `GET /characters/{id}/state` | ✅ | `sessions.py` `get_character_state` → `cli.py` `--characters` | live character PAD/emotions (#161) | -| `DELETE /characters/{id}` | ✅ | `sessions.py` `delete_character` → `cli.py` `--characters` | remove transient character (#161) | +| `GET /models/available-for-characters` | ✅ | `wt.py` `list_character_models` (SDK `models.available_for_characters`) → `cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — open-world catalog verbatim; the probe reads `items` null-safe (`or []`); any error→SessionApiFailed default. **LIVE-SMOKE 2026-07-19**: `character models: char-rp` | +| `POST /characters` | ✅ | `wt.py` `create_character` (SDK `characters.create`) → `cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — body `{character}` (+`state` only when set — SDK-idiomatic, drops the redundant explicit null); open-world create ACK verbatim; the probe degrades on a missing `character_id` (no hard-index). **LIVE-SMOKE 2026-07-19**: `created char_8c00006e…` | +| `GET /characters/{id}/state` | ✅ | `wt.py` `get_character_state` (SDK `characters.state`) → `cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — open-world live PAD/emotions verbatim; TTL-refreshing read. **LIVE-SMOKE 2026-07-19**: `state pad=[0.234, -0.136, 0.065]` read back | +| `DELETE /characters/{id}` | ✅ | `wt.py` `delete_character` (SDK `characters.delete`) → `cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — returns the SDK's open ACK verbatim (`-> Mapping|None`, NOT normalized to None; 204→None); any error→SessionApiFailed default. **LIVE-SMOKE 2026-07-19**: `deleted char_8c00006e…` | | `POST /sessions/{id}/persona_state` | ✅ | `wt.py` `set_persona_state` (SDK `sessions.set_persona_state`, `PadState`) → `cli.py` `--set-persona-pad` | **wt-adapter re-anchored (slice-3, #20)** — SDK owns the canonical `{"pad": {...}}` wire (#317); CLI passes the 3 PAD axes (finiteness pre-validated); 204→None, else SessionApiFailed default. **LIVE-SMOKE 2026-07-19** on personal :8081: `--set-persona-pad 0.4,0.1,-0.2` → **204** | **Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method diff --git a/pyproject.toml b/pyproject.toml index 422aed4..d80a885 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.21.15" +version = "0.21.16" description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/cli.py b/src/ratatoskr/cli.py index 9469325..1f5e35a 100644 --- a/src/ratatoskr/cli.py +++ b/src/ratatoskr/cli.py @@ -43,21 +43,14 @@ from ratatoskr.sessions import ( BifrostBinding, BifrostConsumerKeyMissing, BifrostHandshakeFailed, - SessionApiFailed, - create_character, - delete_character, endpoint_for_plane, - get_capabilities, - get_character_state, - get_me, - list_character_models, ) -# The turn path (create / stream / cancel) is served by the worldtree-sdk adapter -# (`wt.*`); these caller-semantic exceptions are what the adapter raises, so the -# presenter keeps catching ratatoskr's own types (DEC-2). The hand-rolled probes -# (--whoami / --characters / --set-persona / --seed-first-message) stay on the -# `sessions` wrappers until their own slices. +# The turn path (create / stream / cancel) and all consumer reads are served by the +# worldtree-sdk adapter (`wt.*`); these caller-semantic exceptions are what the adapter +# raises, so the presenter keeps catching ratatoskr's own types (DEC-2). Only the +# Bifrost-binding inputs + `endpoint_for_plane` remain hand-rolled here (the provider +# planes are consumer-orthogonal); `get_session_bifrost`'s admin surface lands in slice-6. from ratatoskr.sse_client import ( MalformedSseData, MalformedSseId, @@ -752,7 +745,7 @@ async def _amain(args: ParsedArgs) -> int: loop.remove_signal_handler(signal.SIGINT) -def _format_whoami(me: dict[str, Any], caps: dict[str, Any]) -> str: +def _format_whoami(me: Mapping[str, Any], caps: Mapping[str, Any]) -> str: """Render the --whoami report: identity (GET /me) + server capabilities.""" lines = ["identity:"] lines.append(f" user_id: {me.get('user_id', '?')}") @@ -794,18 +787,23 @@ async def _whoami(args: ParsedArgs) -> int: vocab + exit codes as the other modes. """ assert isinstance(args, ParsedArgs) - async with httpx.AsyncClient( - base_url=args.server_url, - headers={"Authorization": f"Bearer {args.api_key}", "User-Agent": USER_AGENT}, - timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=10.0), - ) as client: + async with _probe_client(args) as transport: + client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport) try: - me = await get_me(client) - caps = await get_capabilities(client) - except SessionApiFailed as exc: - sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n") + me = await wt.get_me(client) + caps = await wt.get_capabilities(client) + except wt.SessionApiFailed as exc: + sys.stderr.write( + f"[session_api_failed] status={exc.status} " + f"error_code={exc.error_code!r} body={exc.body!r}\n" + ) return 20 - except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: + except ( + httpx.ConnectError, + httpx.ReadTimeout, + httpx.TransportError, + ConnectFailed, # SDK normalizes a pre-response transport failure here + ) as exc: sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") return 21 sys.stdout.write(_format_whoami(me, caps)) @@ -826,12 +824,16 @@ async def _characters_probe(args: ParsedArgs) -> int: (models → create → get-state → delete), print a report, exit. A reference- consumer smoke of the #161 character surface (needs character.read/write).""" assert isinstance(args, ParsedArgs) - async with _probe_client(args) as client: + async with _probe_client(args) as transport: + client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport) try: - models = await list_character_models(client) - names = ", ".join(m.get("name", "?") for m in models.get("items", [])) + models = await wt.list_character_models(client) + # Open-world read: `items` may be absent OR explicit-null — `or []` degrades + # both to empty (mirrors the slice-4 `_format_whoami` hardening), never a + # `join(None)` crash. + names = ", ".join(m.get("name", "?") for m in (models.get("items") or [])) sys.stdout.write(f"character models: {names or '(none)'}\n") - created = await create_character( + created = await wt.create_character( client, { "schema_version": "1", @@ -845,16 +847,32 @@ async def _characters_probe(args: ParsedArgs) -> int: "voice_profile_block": "plain", }, ) - cid = created["character_id"] + # Open-world create ACK: degrade, don't hard-index (cumulative cutover + # foot-gun). A malformed/absent character_id aborts the probe cleanly rather + # than raising a KeyError — the lifecycle needs the id for state + delete. + cid = created.get("character_id") + if not (isinstance(cid, str) and cid): + sys.stderr.write( + f"[session_api_failed] create returned no character_id: {created!r}\n" + ) + return 20 sys.stdout.write(f"created: {cid} (ttl {created.get('ttl_expires_at')})\n") - state = await get_character_state(client, cid) + state = await wt.get_character_state(client, cid) sys.stdout.write(f"state: pad={state.get('pad')}\n") - await delete_character(client, cid) + await wt.delete_character(client, cid) sys.stdout.write(f"deleted: {cid}\n") - except SessionApiFailed as exc: - sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n") + except wt.SessionApiFailed as exc: + sys.stderr.write( + f"[session_api_failed] status={exc.status} " + f"error_code={exc.error_code!r} body={exc.body!r}\n" + ) return 20 - except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: + except ( + httpx.ConnectError, + httpx.ReadTimeout, + httpx.TransportError, + ConnectFailed, # SDK normalizes a pre-response transport failure here + ) as exc: sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") return 21 return 0 diff --git a/src/ratatoskr/sessions.py b/src/ratatoskr/sessions.py index 85b75a5..1136be1 100644 --- a/src/ratatoskr/sessions.py +++ b/src/ratatoskr/sessions.py @@ -230,81 +230,6 @@ def endpoint_for_plane(plane: str, base_host: str) -> str: return f"http://{base_host}:{ports[plane]}" -async def get_me(client: httpx.AsyncClient) -> dict[str, Any]: - """GET /me — the authenticated principal's identity + key metadata (spec §GET /me). - - Boot-time whoami: verify the key without agent-config side effects. Returns - the parsed dict verbatim (freeform per the frozen OpenAPI; the spec documents - `{user_id, scopes, tier, display_name?, key_id?, key_label?, ...}`, optional - fields omitted-not-null). 401 (bad/absent key when auth is enabled) — like - every other non-200 — surfaces as SessionApiFailed (get_persona_state - precedent). Read-only, rate-exempt, no audit emission. - """ - assert client is not None - resp = await client.get("/me") - if resp.status_code == 200: - return resp.json() - raise SessionApiFailed(status=resp.status_code, body=resp.content) - - -async def list_character_models(client: httpx.AsyncClient) -> dict[str, Any]: - """GET /models/available-for-characters — character-capable model profiles (#161). - - Requires `character.read`. Returns `{items: [{name, description, thinking}]}`. - Parsed dict verbatim; any non-200 → SessionApiFailed. - """ - assert client is not None - resp = await client.get("/models/available-for-characters") - if resp.status_code == 200: - return resp.json() - raise SessionApiFailed(status=resp.status_code, body=resp.content) - - -async def create_character( - client: httpx.AsyncClient, character: dict[str, Any], *, state: dict[str, Any] | None = None -) -> dict[str, Any]: - """POST /characters — create a transient character (#161). Requires `character.write`. - - Body is `{character, state}` (state optional — a CharacterStateSchema for - mid-conversation rehydration). Returns 201 `{character_id, ttl_expires_at}`; - any non-201 → SessionApiFailed. - """ - assert client is not None - assert isinstance(character, dict) and character - resp = await client.post("/characters", json={"character": character, "state": state}) - if resp.status_code == 201: - return resp.json() - raise SessionApiFailed(status=resp.status_code, body=resp.content) - - -async def get_character_state(client: httpx.AsyncClient, character_id: str) -> dict[str, Any]: - """GET /characters/{character_id}/state — live runtime state (#161). Requires `character.read`. - - Returns `{schema_version, pad, emotions_active, mood_drift, goal_signal_history}`; - refreshes the character's TTL. Any non-200 → SessionApiFailed. - """ - assert client is not None - assert character_id and isinstance(character_id, str) - resp = await client.get(f"/characters/{character_id}/state") - if resp.status_code == 200: - return resp.json() - raise SessionApiFailed(status=resp.status_code, body=resp.content) - - -async def delete_character(client: httpx.AsyncClient, character_id: str) -> None: - """DELETE /characters/{character_id} — remove a transient character (#161). - - Requires `character.write`. Bound sessions detach (next turn → 410 - character_not_found). 200/204 → None; any other status → SessionApiFailed. - """ - assert client is not None - assert character_id and isinstance(character_id, str) - resp = await client.delete(f"/characters/{character_id}") - if resp.status_code in (200, 204): - return None - raise SessionApiFailed(status=resp.status_code, body=resp.content) - - async def get_session_bifrost( client: httpx.AsyncClient, session_id: str, *, admin_key: str ) -> dict[str, Any]: @@ -328,18 +253,3 @@ async def get_session_bifrost( if resp.status_code == 200: return resp.json() raise SessionApiFailed(status=resp.status_code, body=resp.content) - - -async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]: - """GET /capabilities — server capability discovery (spec §Ephemeral Templates). - - Returns `{ephemeral_templates: {echo: {allowed_models, default_model, - system_prompt_max_bytes}}}` — what the server offers before a client decides - to instantiate. Any authenticated caller may read it (no scope). Parsed dict - verbatim; any non-200 → SessionApiFailed. - """ - assert client is not None - resp = await client.get("/capabilities") - if resp.status_code == 200: - return resp.json() - raise SessionApiFailed(status=resp.status_code, body=resp.content) diff --git a/src/ratatoskr/wt.py b/src/ratatoskr/wt.py index 6057c10..df9c417 100644 --- a/src/ratatoskr/wt.py +++ b/src/ratatoskr/wt.py @@ -576,3 +576,111 @@ async def delete_agent(client: WorldtreeClient, agent_id: str) -> None: if exc.status == 404: raise Tier3AgentNotFound(agent_id=agent_id) from exc raise translate_error(exc) from exc + + +# ── slice-5: characters + me/capabilities/models adapter routes ─────────────── +# The remaining consumer READS + transient-character CRUD over `client.me` / +# `client.capabilities` / `client.models` / `client.characters.*`. All six are +# open-world reads/acks (B-OPEN-2) returned verbatim; NONE carries a discriminated +# error on the SDK floor (no `map_error`), so each maps any `ApiError` → the +# `SessionApiFailed` default (INV-CUT-2) — exact parity with the retiring hand-rolled +# path, which never discriminated a status/code on these routes. No new § Error map +# rows. CLI-only: `--whoami` (me + capabilities) and `--characters` (models + CRUD); +# no web-server caller this slice. + + +async def get_me(client: WorldtreeClient) -> Mapping[str, Any]: + """The caller's identity + key metadata (GET /me), open-world dict verbatim. + + The boot whoami — verifies the key with no agent-config side effects. Any error → + the `SessionApiFailed` default (notably 401 on a bad/absent key when auth is on). + """ + try: + return await client.me.get() + except ApiError as exc: + raise translate_error(exc) from exc + + +async def get_capabilities(client: WorldtreeClient) -> Mapping[str, Any]: + """The server capability advertisement (GET /capabilities), open-world verbatim. + + Any authenticated caller may read it (no scope). Any error → the `SessionApiFailed` + default. The `--whoami` renderer degrades on a malformed advertisement rather than + crashing (`_format_whoami`, already hardened). + """ + try: + return await client.capabilities.get() + except ApiError as exc: + raise translate_error(exc) from exc + + +async def list_character_models(client: WorldtreeClient) -> Mapping[str, Any]: + """The character-capable model catalog (GET /models/available-for-characters, #161), + open-world dict verbatim. Requires `character.read`. Any error → the + `SessionApiFailed` default.""" + try: + return await client.models.available_for_characters() + except ApiError as exc: + raise translate_error(exc) from exc + + +async def create_character( + client: WorldtreeClient, + character: Mapping[str, Any], + *, + state: Mapping[str, Any] | None = None, +) -> Mapping[str, Any]: + """Create a transient character (POST /characters, #161). Requires `character.write`. + + The body is `{character}` plus `state` ONLY when supplied — the SDK forwards the + dict as-is, so ratatoskr omits the hand-rolled path's redundant explicit + `state: null` (server-equivalent — Worldtree's `CreateCharacterRequest.state` + defaults None whether omitted or explicit-null; SDK-idiomatic). Returns the + open-world create ACK verbatim (`{character_id, ttl_expires_at, ...}`). Any error → + the `SessionApiFailed` default (notably 403 when the key lacks `character.write`). + """ + assert isinstance(character, Mapping) and character + try: + # Inline literals (per branch) so each type-checks structurally against the + # SDK's `CreateCharacterInput` TypedDict (`character` required, `state` + # NotRequired) without importing the SDK's private `_types` — same posture as + # `define_agent`. `state` is present ONLY when supplied (no redundant null). + if state is not None: + return await client.characters.create( + {"character": dict(character), "state": dict(state)} + ) + return await client.characters.create({"character": dict(character)}) + except ApiError as exc: + raise translate_error(exc) from exc + + +async def get_character_state( + client: WorldtreeClient, character_id: str +) -> Mapping[str, Any]: + """The character's live runtime state (GET /characters/{id}/state, #161), open-world + dict verbatim; the read refreshes the character's TTL. Requires `character.read`. + Any error → the `SessionApiFailed` default.""" + assert character_id and isinstance(character_id, str) + try: + return await client.characters.state(character_id) + except ApiError as exc: + raise translate_error(exc) from exc + + +async def delete_character( + client: WorldtreeClient, character_id: str +) -> Mapping[str, Any] | None: + """Delete a transient character (DELETE /characters/{id}, #161). Requires + `character.write`; bound sessions detach (next turn → 410 `character_not_found`). + + Returns the SDK's open-world delete ACK verbatim (`CharacterDeleteResult` — + Worldtree returns a body here, NOT 204) rather than normalizing to the hand-rolled + `None` (parity: no None-normalization of an open-world read). A 204 no-content + yields `None`, hence the `Mapping | None` return; the sole call-site ignores it. + Any error → the `SessionApiFailed` default. + """ + assert character_id and isinstance(character_id, str) + try: + return await client.characters.delete(character_id) + except ApiError as exc: + raise translate_error(exc) from exc diff --git a/tests/test_cli.py b/tests/test_cli.py index 13d4c11..8c4668d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1923,6 +1923,27 @@ class TestTier2Probes: assert "deleted: char_z" in out assert del_route.call_count == 1 # lifecycle cleaned up + @respx.mock + def test_characters_probe_create_missing_id_aborts( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """create ACK without character_id → clean abort (exit 20), never a hard-index + KeyError (open-world degrade-not-crash; slice-5 cutover foot-gun).""" + respx.get("https://w.example/models/available-for-characters").mock( + return_value=httpx.Response(200, json={"items": []}) + ) + # 201 but the open-world ACK omits character_id — the probe must degrade. + respx.post("https://w.example/characters").mock( + return_value=httpx.Response(201, json={"ttl_expires_at": "t"}) + ) + del_route = respx.delete(url__regex=r"https://w\.example/characters/.+").mock( + return_value=httpx.Response(204) + ) + rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"]) + assert rc == 20 + assert "no character_id" in capsys.readouterr().err + assert del_route.call_count == 0 # aborted before state/delete — nothing to clean + @respx.mock def test_set_persona_probe(self, capsys: pytest.CaptureFixture[str]) -> None: """set_persona_probe [happy,tracer]: POST pad to /sessions/{id}/persona_state; 204.""" diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 70a7e68..0213cc2 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -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 diff --git a/tests/test_wt.py b/tests/test_wt.py index 55cf6a1..2415f91 100644 --- a/tests/test_wt.py +++ b/tests/test_wt.py @@ -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 diff --git a/uv.lock b/uv.lock index 732eb47..3ae38c6 100644 --- a/uv.lock +++ b/uv.lock @@ -472,7 +472,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.21.15" +version = "0.21.16" source = { editable = "." } dependencies = [ { name = "httpx" },