Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d86d6df147 | |||
| deab7627eb | |||
| 4f74645a00 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
`[2026-07-19]` **worldtree-sdk cutover SLICE-4 COMPLETE + committed (`c62b4ee`+`aed9429`+`477d98f`, v0.21.13–.15, 475 green).**
|
||||
|
||||
Agents/Tier-3 family onto the `ratatoskr.wt` adapter + the `model`→`role` fold (scope B),
|
||||
full House Code Discipline end-to-end (contract error-map → TDD → rewire → delete → live
|
||||
smoke → coverage re-anchor → both heid gates → fixups).
|
||||
|
||||
**What moved onto the SDK** (`client.agents.*`, open-world dicts, errors mapped by
|
||||
route+(status,error_code) per INV-CUT-2): `list_agents` (`agents.list`),
|
||||
`get_persona_state` (`agents.persona_state`; 404 persona_not_configured /
|
||||
404 agent_not_available / 403 auth_scope_denied dual-key), `define_agent`
|
||||
(`agents.define`; 429→Tier3QuotaExceeded(retry_after=0) status-only, 403
|
||||
tier3_user_id_unsupported, 422 layer_deferred), `patch_agent` (`agents.patch`; bare-404→
|
||||
Tier3AgentNotFound, 422 field_not_mutable), `delete_agent` (`agents.delete`; bare-404).
|
||||
Rewired the `python -m ratatoskr.tier3` CLI + the web `_agents_endpoint` /
|
||||
`_persona_state_endpoint`. DELETED the hand-rolled `sessions.list_agents` /
|
||||
`get_persona_state` / `AgentInfo` and `tier3.define/patch/delete_agent` /
|
||||
`Tier3AgentInfo` / parse+extract helpers.
|
||||
|
||||
**model→role fold:** define/patch response echoes `role` (spec 1.2 / b128), read off the
|
||||
open-world dict; `LocalAgentEntry.model`→`.role`, local-index `_SCHEMA_VERSION` 1→2 (old
|
||||
index discarded, no-compat). `project_tier3_agents_model_to_role_pending` is RESOLVED by
|
||||
this — the deferred scope-B work landed here (doing it standalone would have been throwaway).
|
||||
|
||||
**KEY SLICE-4 FOOT-GUNS (for slices 5-7 + any tier3 work):**
|
||||
- **`python -m` double-module exception identity.** Running tier3 as `__main__` while
|
||||
`wt` imports `ratatoskr.tier3` bound TWO copies of each `Tier3*` class → a raised
|
||||
`Tier3AgentNotFound` escaped the CLI's `except` as an uncaught traceback (exit 1, not
|
||||
the mapped exit 20). Unit tests call `main()` in-process (module is `ratatoskr.tier3`,
|
||||
not `__main__`) so they NEVER hit the split — the LIVE SMOKE caught it. Fix: the `Tier3*`
|
||||
exceptions live in `sessions.py` (never run as `__main__`) → single class identity, and
|
||||
it removed the `wt→tier3` import edge. Rule: caller-semantic exceptions the adapter
|
||||
raises + a `-m` CLI catches must NOT live in the `-m` module.
|
||||
- **Open-world presenters must degrade, never crash.** The heid-bug-hunt panel (5/5/5, two
|
||||
3/3 crash sites) caught the CLI (`_run_define`/`_run_patch`) and web (`_agents_endpoint`)
|
||||
HARD-INDEXING the open-world dicts (`info["agent_id"]`, `{a["agent_id"] for a in
|
||||
upstream}`) → KeyError/TypeError on a partial/drifted 2xx response (incl. `system_prompt:
|
||||
null` → `None.splitlines()` AttributeError). The wt tests + live smoke used FULL server
|
||||
dicts, so it never surfaced. Fix: `_str_field` (absent/null/non-str → default) in the
|
||||
CLI; well-formed-mapping filter in the web endpoint; a no-agent_id 2xx → controlled exit
|
||||
20. The invariant "open-world reads degrade, never crash the presenter" must hold at
|
||||
EVERY presenter, not just the adapter.
|
||||
|
||||
**Both heid gates cleared:**
|
||||
- **code-review (panel, zero adapter/error-map/model→role drift):** 3 fixups (v0.21.14,
|
||||
`aed9429`) — persona endpoint now catches `wt.SessionApiFailed` (parity with 3 sibling
|
||||
endpoints; a latent PRE-cutover gap, NOT a slice-4 regression); dual-key NEGATIVE tests
|
||||
for define/patch + flat-`field` test; contract documents the `":" in agent_id` PRE.
|
||||
- **bug-hunt (panel 5/5/5, no false positives):** 3 fixups (v0.21.15, `477d98f`) — the two
|
||||
open-world presenter crash sites above + `_error_field_from_body` type-checks `field` is
|
||||
str. HELD (contract-intended, Heid-confirmed): the 429→quota / bare-404→not-found
|
||||
status-only maps (the arms flagged them spec-free; the § Error map specifies them; the
|
||||
SDK's ApiError floor drops Retry-After so retry_after=0 is canonical). Dual-keying
|
||||
define's 429 for full row consistency is an available tightening (contract amendment),
|
||||
surfaced not applied.
|
||||
|
||||
**LIVE-SMOKE on personal :8081 (b128):** valid agent roles are `thoughtful-character` /
|
||||
`character` / `assistant` (NOT the `/models/available-for-characters` `char-rp` — that's a
|
||||
character-model, a different vocab; define 422s on it). define→patch→list(6 agents:
|
||||
forseti/lofn/mask/mimir/vili/…)→persona_state(→PersonaNotConfigured mapped)→delete→index
|
||||
empty; non-existent-id patch via `-m` → `[agent_not_found]` exit 20 (double-module fix
|
||||
proven). Throwaway `ratatoskr:slice4-smoke` deleted, server left clean.
|
||||
|
||||
**NEXT = slice-5** (characters + me/capabilities/models — the remaining consumer reads);
|
||||
then slice-6 (admin: `stream_admin_events` + `get_session_bifrost`, admin_auth), slice-7
|
||||
(teardown: delete residual hand-rolled, drop `httpx-sse`, retire contracts #2/#15, MINOR
|
||||
bump per DEC-6 w/ operator approval). Consumer layer ONLY; Bifrost provider planes untouched.
|
||||
@@ -0,0 +1,31 @@
|
||||
`[2026-07-19]` **wyrd-dev #368 silo-enforcement consult DELIVERED (althing thread `01KXXNDH3JE4`) — ratatoskr's store-side memory silo is CONVENTIONAL, wyrd going STRUCTURAL off my framing.**
|
||||
|
||||
wyrd-dev is standing up their `bifrost-memory-store-server` (unit-4) and asked for
|
||||
ratatoskr's hands-on read of the #368 `(end_user, agent)` silo mechanics before writing
|
||||
their contract. I answered artifact-only from `src/ratatoskr/provider/memory_store.py`
|
||||
(reference-impl posture: verified against code, not memory).
|
||||
|
||||
**The load-bearing finding:** ratatoskr's silo is **conventional (query-time scope
|
||||
filter), NOT structural.** Scope axes live inside `record_json` (+ a mirror `scope_json`
|
||||
column), and every read path (`search`, `scan`, `list_chunks`) calls `_matches_scope` in
|
||||
Python — "an `if` a caller can forget." Inherited byte-faithfully from bifrost's reference
|
||||
`InMemoryMemoryStore`. Sharpest leak surfaces: `get`/`get_many` do ZERO scope check
|
||||
(trust caller-knows-entitled-ids); the vec-search must over-fetch-ALL-then-Python-filter
|
||||
(a naive `LIMIT top_k` in SQL silently under-recalls or leaks). ratatoskr has NO
|
||||
`clear_partition` verb — forget is by-id `delete_many` (two-table chunk+vec atomicity in
|
||||
one txn); the idempotency table is a hidden replay channel a total-forget must purge.
|
||||
|
||||
**Outcome — wyrd committed to STRUCTURAL** (msg 3, `01KXXNQYGX0K`): one-DB-file-per-campaign
|
||||
(campaign_id silo structurally unrepresentable across files) + `(scope_end_user,
|
||||
scope_agent_self)` first-class NOT NULL composite-indexed columns, every query incl. the
|
||||
vec JOIN carrying the partition in SQL `WHERE`. Dissolves my two sharpest leak surfaces
|
||||
(by-id reads enforce the predicate; the vec JOIN filters+ranks+caps in ONE statement).
|
||||
Folding my foot-guns as contract STEPS/invariants (two-table+producer-candidate delete
|
||||
atomicity; forget purges idempotency + reaches superseded; `scope_any` = list of
|
||||
whole-element-conjunctive dicts never flatten axes #297; `check_same_thread=False`;
|
||||
`SortableChunkField` needs name+type or handshake dies; advertise⟹implement;
|
||||
revision+idempotency-check before writes in one txn). worldtree-dev already pushed wyrd
|
||||
structural in the record-shape re-read, so aligned; wyrd will flag the
|
||||
reference-`InMemoryMemoryStore`-divergence to bifrost-dev (internal enforcement, wire
|
||||
unchanged). **OPEN LOOP:** I offered to eyeball wyrd's data model once the unit-4 contract
|
||||
is cut — they took it; they'll ping. No action pending on ratatoskr now.
|
||||
+33
-29
@@ -46,34 +46,34 @@ upstream API key stays server-side (INV-003).
|
||||
|
||||
_As of 2026-07-19:_
|
||||
|
||||
**🔨 ACTIVE MIGRATION — worldtree-sdk cutover (issue #20): SLICE-1 + SLICE-2 + SLICE-3 COMPLETE, slice-4 next.**
|
||||
**🔨 ACTIVE MIGRATION — worldtree-sdk cutover (issue #20): SLICE-1–4 COMPLETE, slice-5 next.**
|
||||
Operator ruled ADOPT (2026-07-18): ratatoskr cuts its CONSUMER client layer over to **worldtree-sdk (Python)
|
||||
1.0.0**, retiring the hand-rolled httpx wrappers behind a thin `ratatoskr.wt` adapter. Design locked (6 DECs,
|
||||
vor-cross'd, heid-panel-reviewed); contract `docs/contracts/worldtree_sdk_cutover.contract.md`. **SLICE-1+2
|
||||
(foundation + sessions/turn) ✅ PUSHED** origin `aba1730` (arc `b1fbadd`→`aba1730`, tags v0.21.3–.10). **SLICE-3
|
||||
(persona + authored-history + first-message) ✅ DONE** — `ca9a339` (feat) + `fc256bb` (heid-bug-hunt fixups),
|
||||
tags v0.21.11–.12; full House Code Discipline (TDD → heid-code-review CLEAN/zero-drift → heid-bug-hunt).
|
||||
Suite **469 green**. Slice-3 migrated `set_persona_state` (SDK `PadState`), `write_authored_history`
|
||||
(`write_history`, 404→AuthoredHistoryUnavailable hide-existence), `get_session_messages`, + routed
|
||||
`first_message` seed through the adapter; DELETED the last hand-rolled `sessions.py` paths (`create_session`+
|
||||
`SessionInfo`, `set_persona_state`, `write_authored_history`, `get_session_messages`, `_bifrost_error_from`).
|
||||
LIVE-SMOKE on :8081 (b128): seed→201(seq0)→read-back; persona→204; create-path preset seed. **KEY ADAPTER FACTS
|
||||
(foot-guns for slices 4-7):** SDK returns **open-world dicts** for reads → `info["session_id"]`; `TurnEvent`
|
||||
carries `sse_id` + a **`turn_id` ABSENT on text/thinking frames** → parse cancel-target from `sse_id`; the SDK
|
||||
**normalizes ANY transport failure to `ConnectFailed(status=0)`** (`request.py:196`, NOT raw httpx) — every
|
||||
caller through the adapter must `except ConnectFailed` (the slice-3 bug-hunt caught both probes missing it);
|
||||
`consumer_key` is BOUND-create-only; the SDK's envelope parser **prefers nested `detail`** (why bound-502 isn't
|
||||
error_code-gated). **NEXT = slice-4** (agents/tier3 — list/get/define/patch/delete/persona_state, FOLDS the
|
||||
`model`→`role` cutover); then slice-5 (characters/me/caps), slice-6 (admin — `stream_admin_events` +
|
||||
`get_session_bifrost` still hand-rolled), slice-7 (teardown: retire contracts #2/#15, drop `httpx-sse`, minor
|
||||
bump per DEC-6 w/ operator approval). Scope: consumer layer ONLY; Bifrost provider planes untouched. Full
|
||||
design → auto-memory `project_worldtree_sdk_cutover`.
|
||||
(foundation + sessions/turn) ✅ PUSHED** origin `aba1730`. **SLICE-3 (persona + authored-history + first-message)
|
||||
✅ DONE** `ca9a339`+`fc256bb`. **SLICE-4 (agents/Tier-3 + `model`→`role` fold) ✅ DONE** — `c62b4ee` (feat) +
|
||||
`aed9429` (heid-code-review fixups) + `477d98f` (heid-bug-hunt fixups), tags v0.21.13–.15; full House Code
|
||||
Discipline, both heid panels cleared. Suite **475 green**. Slice-4 migrated
|
||||
list_agents/get_persona_state/define/patch/delete onto `client.agents.*` (open-world dicts, errors mapped by
|
||||
route+(status,error_code)), folded `model`→`role` (`LocalAgentEntry.role`, index schema v2), and DELETED the
|
||||
hand-rolled `sessions.list_agents`/`get_persona_state`/`AgentInfo` + `tier3.define/patch/delete_agent`. Full arc
|
||||
+ the two slice-4 foot-guns → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-4-complete.md`.
|
||||
**KEY ADAPTER FACTS (foot-guns, cumulative for slices 5-7):** SDK reads = **open-world dicts** —
|
||||
presenters MUST degrade not crash (slice-4 bug-hunt: hard-indexing `info["x"]` crashes on partial wire; use
|
||||
`.get`/type-guards; `system_prompt: null` → `None.splitlines()`); the SDK **normalizes ANY transport failure to
|
||||
`ConnectFailed(status=0)`** (NOT raw httpx) — every adapter caller must `except ConnectFailed`;
|
||||
**caller-semantic exceptions the adapter raises + a `-m` CLI catches must NOT live in the `-m` module** (the
|
||||
`python -m ratatoskr.tier3` double-module split gave two `Tier3*` class identities → uncaught traceback; fixed
|
||||
by homing them in `sessions.py`; unit tests can't catch this, the live smoke did); `TurnEvent` `turn_id` ABSENT
|
||||
on text/thinking frames; `consumer_key` is BOUND-create-only; envelope parser prefers nested `detail`.
|
||||
**NEXT = slice-5** (characters + me/capabilities/models — remaining consumer reads); then slice-6 (admin:
|
||||
`stream_admin_events` + `get_session_bifrost`, admin_auth), slice-7 (teardown: retire contracts #2/#15, drop
|
||||
`httpx-sse`, MINOR bump per DEC-6 w/ operator approval). Scope: consumer layer ONLY; Bifrost provider untouched.
|
||||
Full design → auto-memory `project_worldtree_sdk_cutover`.
|
||||
|
||||
**⏸️ DEFERRED — tier3 agents `model`→`role` (scope B), folds into cutover slice-4.** WT renamed the
|
||||
agents-RESPONSE selector `model`→`role` (spec 1.2). **DEPLOY NOW LIVE** on :8080/:8081 (v1.0.0b128,
|
||||
worldtree-dev confirmed 2026-07-19 — was the deploy-flag gate; acked). Operator chose scope B (full tier3
|
||||
`model`→`role` incl. contract #15 + CLI `--model`→`--role`); lands in cutover slice-4 where tier3.py routes
|
||||
through the SDK (doing it standalone now = throwaway). Auto-memory `project_tier3_agents_model_to_role_pending`.
|
||||
**✅ RESOLVED — tier3 agents `model`→`role` (scope B) folded into cutover slice-4** (`c62b4ee`, v0.21.13). The
|
||||
deferred deploy-gated scope-B work (response `model`→`role` per spec 1.2 / b128, `LocalAgentEntry`, index schema
|
||||
v2) landed with the agents-family SDK cutover — no longer pending. See the slice-4 detail file + Recent decisions.
|
||||
|
||||
**✅ RESOLVED — the "app product" workstreams leave Rata entirely (operator 2026-07-18).**
|
||||
**No arbo fork, no SillyTavern-on-Rata** — a NEW repo (template-dev standing up) takes over BOTH
|
||||
@@ -117,13 +117,13 @@ Full record → `persistent-memory.d/2026-07-18-368-silo-test-passed.md`. Siblin
|
||||
(2) R39 Phase-2 **matched-quartets rebuild** (confirmatory, "whenever"); (3) bifrost **snapshot-cursor
|
||||
adoption** (ruled normative, not blocking → `persistent-memory.d/2026-07-16-bifrost-cursor-conformance.md`).
|
||||
|
||||
**Substrate / environment:** branch `main` at **v0.21.12**, **PUSHED to origin** (slice-3 arc `ca9a339`+
|
||||
`fc256bb` + this snapshot, tags v0.21.11–.12, pushed 2026-07-19; slice-1+2 arc `b1fbadd`→`aba1730` +
|
||||
v0.21.3–.10 earlier). origin `git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep:
|
||||
**Substrate / environment:** branch `main` at **v0.21.15** — slice-4 arc `c62b4ee`→`477d98f` + this snapshot
|
||||
**COMMITTED, not-yet-pushed** (tags v0.21.13–.15; push is the operator's call); slice-1–3 (v0.21.3–.12,
|
||||
`b1fbadd`→`4f92a21`) PUSHED to origin earlier. origin `git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep:
|
||||
`worldtree-sdk==1.0.0`** (gitea PyPI, `[tool.uv.sources]`; `httpx-sse` retires at slice-7). bifrost
|
||||
**`==1.1.4`** / wire v0.7; WT openapi vendored 2.3.0, **conversation-api-spec re-synced to v1.1** (`b4a278c`);
|
||||
**suite 469 green** (was 497 post-slice-2; net delta = slice-3 adapter/probe tests added, ~50 deleted
|
||||
hand-rolled sessions tests). Personal WT on **b128**
|
||||
**suite 475 green** (slice-4 added the agents-family adapter tests + heid-gate fixup tests, ~offset by the
|
||||
deleted hand-rolled agent/persona tests). Personal WT on **b128**
|
||||
(`http://10.250.50.152:8081`; #368 silo + #364 promotion-hygiene live both instances). The combined
|
||||
**:8392** provider (memory+affect) + **:8765** web are THE surfaces, dev-box BACKGROUND SHELLS —
|
||||
restart via `scratchpad/relaunch_by_pid.py <pid>` (pid via `ss -ltnp | grep <port>`). `env.sh` sets
|
||||
@@ -274,6 +274,10 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
- `[2026-07-19]` **worldtree-sdk cutover SLICE-1 + SLICE-2 COMPLETE + PUSHED (origin `aba1730`, v0.21.10, 497 green).** The biggest, riskiest cutover slice done end-to-end through the full House Code Discipline (adapter→cli→web→delete→live-smoke→both heid gates); the bug-hunt caught 4 real confirmed bugs the conformance lens couldn't, and a convergent code-review finding was correctly REJECTED as category-5 (SDK envelope-parser behavior). Slice-3 next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-1-2-complete.md`
|
||||
- `[2026-07-19]` **worldtree-sdk cutover SLICE-3 COMPLETE + pushed (`ca9a339`+`fc256bb`, v0.21.11–.12, 469 green).** Persona/authored-history/first-message onto the wt adapter; last hand-rolled `sessions.py` paths deleted; both heid gates cleared — code-review ZERO drift, bug-hunt caught + fixed a real regression I shipped (ConnectFailed escaping both rewired probes → uncaught crash; the SDK normalizes ALL transport failures to `ConnectFailed`, not raw httpx) plus a finite-PAD chokepoint gap. Slice-4 (agents/tier3 + model→role) next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-3-complete.md`
|
||||
|
||||
- `[2026-07-19]` **worldtree-sdk cutover SLICE-4 COMPLETE (agents/Tier-3 + `model`→`role` fold, `c62b4ee`+`aed9429`+`477d98f`, v0.21.13–.15, 475 green).** Full House Code Discipline; the LIVE SMOKE caught a `python -m` double-module exception-class-identity bug unit tests structurally can't; both heid panels cleared (code-review zero-drift + 3 fixups; bug-hunt 5/5/5 → open-world-presenter degrade-not-crash fixes). Resolves the deferred scope-B model→role. Slice-5 next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-4-complete.md`
|
||||
|
||||
- `[2026-07-19]` **wyrd-dev #368 silo-enforcement consult delivered — ratatoskr's store-side silo is CONVENTIONAL (query-time filter); wyrd going STRUCTURAL off the framing.** Answered artifact-only from the provider memory-store code; wyrd folded my foot-guns into their unit-4 contract + committed to one-DB-file-per-campaign + first-class partition columns. OPEN LOOP: I'll eyeball their data model once the contract's cut (they'll ping). → `persistent-memory.d/2026-07-19-wyrd-368-silo-consult-delivered.md`
|
||||
|
||||
_67 older entries (2026-05-* debug-TUI/web era + the 2026-06-14 → 06-18 Bifrost-provider build / #17+#18 / #295-296 era) archived to archival-memory.md._
|
||||
|
||||
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.21.15"
|
||||
version = "0.21.17"
|
||||
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+62
-34
@@ -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,12 +745,16 @@ 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', '?')}")
|
||||
lines.append(f" tier: {me.get('tier', '?')}")
|
||||
lines.append(f" scopes: {', '.join(me.get('scopes', [])) or '(none)'}")
|
||||
# Open-world read: `scopes` may be absent, present-null, or carry non-strings —
|
||||
# `or []` + str() degrades all three (a present-null `.get('scopes', [])` returns
|
||||
# None, not the default), matching the `allowed_roles` hardening below (heid-code-
|
||||
# review slice-5: the contract names this function the degrade-not-crash exemplar).
|
||||
lines.append(f" scopes: {', '.join(str(s) for s in (me.get('scopes') or [])) or '(none)'}")
|
||||
for k in ("display_name", "key_id", "key_label"):
|
||||
if k in me:
|
||||
lines.append(f" {k}: {me[k]}")
|
||||
@@ -794,18 +791,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 +828,22 @@ 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/null (`or []`) AND each entry may be
|
||||
# a non-mapping (`[None]` / `["x"]`) or carry a non-string `name` — guard the
|
||||
# item is a dict and coerce `name` to str so a malformed catalog degrades
|
||||
# rather than crashing (heid-code-review slice-5; element-level completion of
|
||||
# the list-level `or []` guard).
|
||||
names = ", ".join(
|
||||
str(m.get("name", "?"))
|
||||
for m in (models.get("items") or [])
|
||||
if isinstance(m, dict)
|
||||
)
|
||||
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 +857,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1879,6 +1879,25 @@ class TestWhoami:
|
||||
assert rc == 20
|
||||
assert "[session_api_failed]" in capsys.readouterr().err
|
||||
|
||||
@respx.mock
|
||||
def test_whoami_tolerates_null_and_nonstring_scopes(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""scopes present-null / non-string → renders '(none)' or str-coerced, never a
|
||||
`join(None)` TypeError (heid-code-review slice-5: `_format_whoami` is the
|
||||
contract's degrade-not-crash exemplar; `allowed_roles` was hardened, `scopes`
|
||||
was not)."""
|
||||
# scopes: null (present, not absent) → `.get('scopes', [])` would return None.
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(200, json={"user_id": "u", "scopes": None, "tier": "user"})
|
||||
)
|
||||
respx.get("https://w.example/capabilities").mock(
|
||||
return_value=httpx.Response(200, json={"ephemeral_templates": {}})
|
||||
)
|
||||
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
|
||||
assert rc == 0
|
||||
assert "scopes: (none)" in capsys.readouterr().out
|
||||
|
||||
|
||||
class TestTier2Probes:
|
||||
"""--characters + --set-persona-pad one-shot probes (Tier-2: #161 + persona_state-write)."""
|
||||
@@ -1923,6 +1942,53 @@ class TestTier2Probes:
|
||||
assert "deleted: char_z" in out
|
||||
assert del_route.call_count == 1 # lifecycle cleaned up
|
||||
|
||||
@respx.mock
|
||||
def test_characters_probe_tolerates_malformed_models(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""models catalog with non-mapping / non-string-name items → degrades (no
|
||||
AttributeError/TypeError), lifecycle still proceeds (heid-code-review slice-5:
|
||||
element-level completion of the list-level `or []` guard)."""
|
||||
respx.get("https://w.example/models/available-for-characters").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"items": [None, "x", {"name": 123}, {"name": "ok"}]}
|
||||
)
|
||||
)
|
||||
respx.post("https://w.example/characters").mock(
|
||||
return_value=httpx.Response(201, json={"character_id": "c1", "ttl_expires_at": "t"})
|
||||
)
|
||||
respx.get("https://w.example/characters/c1/state").mock(
|
||||
return_value=httpx.Response(200, json={"pad": [0.0, 0.0, 0.0]})
|
||||
)
|
||||
respx.delete("https://w.example/characters/c1").mock(return_value=httpx.Response(204))
|
||||
rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"])
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
# non-mappings dropped; {"name":123}→"123", {"name":"ok"}→"ok" — no crash.
|
||||
assert "character models: 123, ok" in out
|
||||
assert "created: c1" in out
|
||||
|
||||
@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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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