Compare commits

..

6 Commits

Author SHA1 Message Date
vh de9a5baf45 feat(#20): admin (bifrost inspection + admin-events stream) onto the wt adapter (slice-6)
Slice-6 of the worldtree-sdk cutover: migrate the two admin routes off the
hand-rolled paths onto the `ratatoskr.wt` adapter over `client.admin.*`, and delete
the retired code. Both are web-only (the coverage-map's `tui.py` rows were stale —
corrected to `web/server.py`).

Adapter (`wt.py`): `get_session_bifrost` → `client.admin.sessions.bifrost` (open-world
dict verbatim, any error → SessionApiFailed default); `stream_admin_events` →
`client.admin.stream_events`, re-wrapping the SDK's `AdminEvent` → ratatoskr's at the
boundary.

Decisions (contract § slice-6 notes):
- Admin auth moves from a per-call `Authorization` header override to the client's
  `admin_auth` (`_wt_client(admin_key=…)`, extended this slice) — the SDK's admin.*
  routes use the provider, not a header.
- `AdminEvent` re-wrap (chosen over yield-through): the SDK's `admin_id`(nan)/None-able
  `type`/`data` diverge from ratatoskr's `id`/`type`/`data` that the web filter reads;
  re-wrapping (nan→0, None→""/{}) degrades the open-world None/nan ONCE at the adapter
  and keeps the web endpoint + `_admin_event_matches_web` + the `AdminEvent` domain type
  unchanged (preserves the web surface). Rejected: yield SDK events + rewire the web
  filter (heavier churn, scattered hardening).
- Admin-stream error map: a NON-200 open raises `ApiError("admin_stream_failed")`
  (NOT `ConnectFailed`) → SseConnectFailed; `ConnectionDropped` (connect-time OR
  mid-stream/resumable-EOF) → SseConnectionDropped. The web integration test caught the
  ApiError-not-ConnectFailed gotcha the unit fake couldn't.

Web (`web/server.py`): both admin endpoints build the wt client with admin_key and call
`wt.*`; the bifrost endpoint gains ConnectFailed→502 handling (cutover foot-gun); the
admin-events endpoint closes the injected transport (INV-CUT-1), never the wt client.

Deleted the hand-rolled `sessions.get_session_bifrost` + `sse_client.stream_admin_events`
(+ orphaned httpx/httpx_sse/json/AsyncIterator imports); the ratatoskr `AdminEvent`
dataclass stays in `sse_client.py` (re-wrap target, imported by wt + web) until slice-7.
Retired `test_sse_client.py` entirely (its last test was the admin stream) and the
`test_sessions.py` `TestGetSessionBifrost`; added the slice-6 adapter tests.

LIVE SMOKE (:8081, readonly-admin key) — INV-CUT-5 / DEC-4 cleared: the web bifrost
endpoint returned an admin-authed clean 404 envelope (auth + route + mapping proven);
a real `session.created` admin event (id=32) re-wrapped cleanly on live wire (driven by
a session-create, throwaway session cleaned up).

Suite 490 green; ruff clean; mypy net-improved on web/server.py (16→12 pre-existing, no
new). Patch bump 0.21.18 → 0.21.19 (the cutover MINOR is deferred to slice-7, DEC-6).
2026-07-19 12:48:04 -07:00
vh 5bc39a092e memory: /snapshot — worldtree-sdk cutover slice-5 complete (deab762→4e20030)
Slice-5 (characters + me/capabilities/models) done through the full House Code
Discipline, tags v0.21.16–.18, suite 488 green, live-smoke-proven on :8081/b128,
both heid gates cleared. Current state / in-flight advanced to slice-6 (admin) next;
Recent-decisions index entry + detail file added; substrate at v0.21.18.

persistent-memory.md stays ~333 lines (over the ~300 soft cap): the length is
dominated by the non-archivable Current state / in-flight block plus <30-day July
entries (guarded), so archival can't reach the 250 target — left as-is per the
stop-where-the-guards-stop rule.
2026-07-19 11:34:10 -07:00
vh 4e20030229 fix(#20): heid-bug-hunt fixups — CLI open-world container-type hardening (slice-5)
Panel (Gróa + Hulda + Regin, source-verified by Heid): adapter/route-map/
ConnectFailed-at-call-sites sound against the declared invariants; 4 real
robustness findings, all in the CLI open-world presenter/probe paths — the
container-type layer BELOW the null/element holes the code-review already fixed.

Fixed (findings 1-3):
- `_format_whoami` (`cli.py`): a non-iterable `scopes`/`allowed_roles` scalar
  (`{"scopes": 123}`) made `x or []` yield `123` → `for s in 123` TypeError. New
  `_display_seq` helper degrades any non-list (scalar / bare string / null / absent)
  to empty; applied to both `scopes` and `allowed_roles`.
- `_characters_probe` (`cli.py`): same class on the model catalog `items` (`{"items":
  123}`) — now guards `models` is a Mapping and `items` is a list before iterating.
- `_characters_probe`: the top-level open-world reads `created` / `state` are now
  `isinstance(_, Mapping)`-guarded before any `.get` — a non-mapping SDK passthrough
  (`created=[...]`) aborts cleanly (exit 20) / renders `pad=None` instead of an
  AttributeError.

Accepted (finding 4, documented in contract § slice-5 notes): the `--characters`
probe leaks its transient character on a mid-lifecycle failure. PRE-EXISTING (the
retired probe had the identical linear no-`finally` structure — cutover did not
worsen it), TTL-bounded, one-shot diagnostic; a `try/finally` would swallow a
happy-path delete-failure (delete is both teardown and a tested step). Gróa + Heid
concur accept is defensible.

Dismissed (finding 5): Hulda flagged `sessions.py` dropping `get_me`/etc. as a
caller-contract break — it is the intended DEC-3 no-backwards-compat migration (all
in-repo callers rewired same-diff); Heid labels it intended-surface-change.

Added CLI tests for the three hardened paths (scalar scopes/roles; scalar items +
non-mapping state; non-mapping create abort). Suite 488 green; ruff clean; live
smoke re-run clean (identical happy-path output). Patch bump 0.21.17 → 0.21.18.
2026-07-19 11:29:10 -07:00
vh d86d6df147 fix(#20): heid-code-review fixups — CLI presenter degrade-not-crash (slice-5)
Panel: Gróa + Regin returned zero (adapter/route-map/error-map faithful);
Hulda flagged two source-confirmed open-world-presenter crash holes — the same
class the slice-4 bug-hunt found in the agents presenters. Both fixed:

- `_format_whoami` scopes (`cli.py`): `', '.join(me.get('scopes', []))` crashes on
  a present-null `scopes` (`.get(k, [])` returns None, not the default) or a
  non-string element. Now `', '.join(str(s) for s in (me.get('scopes') or []))` —
  matching the `allowed_roles` hardening on the same function. The contract names
  `_format_whoami` as the degrade-not-crash exemplar (contract:144-146); the cited
  exemplar had an un-hardened line.
- `_characters_probe` model items (`cli.py`): the slice-5 `or []` guarded the
  list-level null but not each entry — `[None]` / `["x"]` / `[{"name":123}]` would
  raise. Now guards each item is a dict and str-coerces `name` (element-level
  completion of the list-level guard).

Hulda #3 (live-smoke not in the reviewed file set) → accept: the smoke WAS run and
is recorded in deab762 + coverage-map (artifact-only review couldn't see it).

Added CLI tests for both hardened paths (present-null/non-string scopes; malformed
model items). Suite 485 green; ruff clean; live smoke re-run clean (identical
happy-path output). Patch bump 0.21.16 → 0.21.17.
2026-07-19 11:11:12 -07:00
vh deab7627eb feat(#20): characters + me/capabilities/models onto the wt adapter (slice-5)
Slice-5 of the worldtree-sdk cutover: migrate the remaining consumer READS +
transient-character CRUD off the hand-rolled httpx wrappers onto the
`ratatoskr.wt` adapter over the SDK, and delete the retired path.

Adapter (`wt.py`): add `get_me` / `get_capabilities` / `list_character_models`
/ `create_character` / `get_character_state` / `delete_character` over
`client.me` / `client.capabilities` / `client.models` / `client.characters.*`.
All six are open-world reads/acks returned verbatim; none carries a
discriminated SDK error, so each maps any `ApiError` → the `SessionApiFailed`
default (INV-CUT-2) — exact parity with the retired path. No new Error-map rows.

Decisions (contract § slice-5 notes): `create_character` omits `state` when None
(SDK-idiomatic inline literal, server-equivalent to the retired explicit null);
`delete_character` returns the SDK's open ACK verbatim (`-> Mapping|None`, not
normalized to None).

CLI rewire (`cli.py`): `--whoami` (me + capabilities) and `--characters`
(models → create → state → delete) build a `wt.build_client` over the injected
probe transport and catch `wt.SessionApiFailed` + `ConnectFailed`. Open-world
degrade-not-crash carried (cumulative cutover foot-gun): `_characters_probe`
reads `items` null-safe and extracts `character_id` defensively (clean abort,
no hard-index KeyError); `_format_whoami` widened to `Mapping`.

Deleted the six hand-rolled `sessions.py` wrappers (net -5 mypy no-any-return);
`endpoint_for_plane` + `get_session_bifrost` (slice-6) + the exception classes
stay. Retired the corresponding `test_sessions.py` classes; added the slice-5
adapter tests + a CLI malformed-create-abort test.

LIVE SMOKE (:8081, b128) — INV-CUT-5 / DEC-4 cleared: `--whoami` rendered real
identity + capabilities; `--characters` drove the full lifecycle end-to-end
(char-rp catalog → created char_8c00006e… → PAD read-back → deleted).

Suite 483 green; ruff clean; mypy at the 2 pre-existing baseline errors.
Patch bump 0.21.15 → 0.21.16 (the cutover MINOR is deferred to slice-7, DEC-6).
2026-07-19 11:00:41 -07:00
vh 4f74645a00 memory: /snapshot — worldtree-sdk cutover slice-4 complete (c62b4ee→477d98f) 2026-07-19 10:24:35 -07:00
17 changed files with 1163 additions and 585 deletions
@@ -247,6 +247,94 @@ 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.
- **Container-type hardening (heid code-review + bug-hunt slice-5).** The degrade-not-
crash floor is guarded at THREE levels for the CLI presenters, not just one: (a) the
list-typed fields `scopes` / `allowed_roles` / model `items` degrade a non-list scalar
(`123`) or a bare string to empty via `_display_seq` / an `isinstance(_, list)` guard —
the older `or []` idiom only caught null/absent and would `for x in 123` `TypeError`;
(b) each element is type-guarded (`isinstance(m, dict)`); (c) the top-level open-world
reads `created` / `models` / `state` are `isinstance(_, Mapping)`-guarded before any
`.get` (a non-mapping passthrough would otherwise `AttributeError`). All three feed
`--whoami` / `--characters` only.
- **Accepted (not fixed): the `--characters` probe leaks its transient character on a
mid-lifecycle failure.** create → get-state → delete runs linearly with no `finally`,
so a state/delete failure after a successful create orphans the probe character until
its TTL. This is PRE-EXISTING (the retired hand-rolled probe had the identical
structure — the cutover did not worsen it), TTL-bounded, and `--characters` is a
one-shot diagnostic smoke; a `try/finally` cleanup would also swallow a happy-path
delete-failure (delete is both the teardown AND a tested lifecycle step). Accepted as
known-risk per the heid bug-hunt (Gróa + Heid concur accept is defensible).
- **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.
### Slice-6 notes (Admin: bifrost inspection + admin-events stream, decided at TDD)
- **Admin auth moves from a per-call header override to the client's `admin_auth`.**
The SDK's `admin.*` methods authenticate with the client's `admin_auth` provider
(set via `build_client(admin_key=...)`), NOT a per-request `Authorization` header. So
the two web admin endpoints build their wt client WITH `admin_key` (`_wt_client(client,
admin_key=...)`, extended this slice); the hand-rolled per-call `admin_key=` +
header-override is retired. The web already guards `if not admin_key: 400` before the
call, so the SDK's pre-HTTP `ConfigurationError` (missing admin_auth, W-5) is
unreachable from the web surface. **CLI has no admin caller** — both routes are
web-only (the coverage-map's `tui.py` rows were stale; corrected to `web/server.py`).
- **`get_session_bifrost` — no new § Error map row.** `client.admin.sessions.bifrost`
returns the open-world `BifrostInspection` dict verbatim; any `ApiError` (notably 403
`auth_scope_denied`, 404 `session_not_bifrost_bound`) → the `SessionApiFailed` default
— exact parity with the retired path (which mapped every non-200 → `SessionApiFailed`).
- **`stream_admin_events` re-wraps the SDK's `AdminEvent` → ratatoskr's `AdminEvent`
(chosen over yield-through).** The SDK's `AdminEvent` diverges from ratatoskr's:
`admin_id: int|float` (`nan` for an id-less envelope) vs ratatoskr's `id: int` (0
default), and the SDK's `type`/`data` are None-able where ratatoskr's are a dotted-str
/ a `{}`-default dict. The web filter + SSE formatter read `ev.id`/`ev.type`/`ev.data`.
The adapter re-wraps at the boundary — `id = admin_id if int else 0` (nan→0),
`type = type or ""` (None→"" so `.startswith` never crashes), `data = data or {}`
degrading the SDK's open-world None/nan ONCE at the adapter, keeping the web endpoint +
`_admin_event_matches_web` + the ratatoskr `AdminEvent` domain type UNCHANGED (preserves
the web surface per § Out of scope). **Rejected alternative:** yield SDK `AdminEvent`s
through and rewire the web filter for `admin_id`/None/nan (the slice-2 turn-stream
precedent) — heavier web churn + scatters the None/nan hardening through the filter;
re-wrap localizes it. The ratatoskr `AdminEvent` dataclass stays in `sse_client.py` this
slice (imported by `wt` + the web); its home moves in slice-7 teardown if `sse_client.py`
is retired.
- **Admin-stream error mapping (reuses the § Error map stream rows).** The SDK admin
stream raises `ApiError("admin_stream_failed", status=…)` on a NON-200 open (NOT
`ConnectFailed` — a gotcha the web integration test caught that the unit fake could not)
`SseConnectFailed`; and `ConnectionDropped` on a connect-time transport failure
(cursor None) OR a mid-stream drop / the long-lived stream's resumable EOF (cursor set)
`SseConnectionDropped`. The SDK admin stream is best-effort (skips malformed frames —
no `Malformed*`), as was the retired hand-rolled path; the web endpoint's existing
`except (…, MalformedSseId, MalformedSseData)` stays a harmless defensive superset
(pre-existing, not introduced here).
## Out of scope
- Bifrost PROVIDER planes (memory/affect) — hand-rolled, ADR-0009, untouched.
+8 -8
View File
@@ -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 /admin/sessions/{id}/bifrost` | ✅ | `wt.py` `get_session_bifrost` (SDK `admin.sessions.bifrost`)`web/server.py` `_session_bifrost_endpoint` | **wt-adapter re-anchored (slice-6, #20)** admin-scoped BifrostState (#176); admin_auth rides on the wt client (`_wt_client(admin_key=…)`), NOT a per-call header; open-world dict verbatim, any error→SessionApiFailed default. **LIVE-SMOKE 2026-07-19** on :8081 (readonly-admin key): admin-authed end-to-end (404 `session_not_bifrost_bound` clean envelope — auth + route + mapping proven). (Consumer is `web/server.py`, not `tui.py` — the old row was stale.) |
| `GET /admin/events` (SSE) | ✅ | `wt.py` `stream_admin_events` (SDK `admin.stream_events`) → `web/server.py` `_admin_events_endpoint` | **wt-adapter re-anchored (slice-6, #20)** admin lifecycle SSE (#11), session-filtered; admin_auth on the wt client; the adapter re-wraps the SDK's `AdminEvent`→ratatoskr's (nan `admin_id`→id 0, None type/data→`""`/`{}`), non-200 open `ApiError`→SseConnectFailed, `ConnectionDropped`→SseConnectionDropped. **LIVE-SMOKE 2026-07-19**: a real `session.created` event (id=32) re-wrapped cleanly on live wire. (Consumer is `web/server.py`, not `tui.py` — stale row corrected.) |
| `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,115 @@
# worldtree-sdk cutover — SLICE-5 COMPLETE (characters + me/capabilities/models)
`[2026-07-19]` Slice 5 of 7 of the worldtree-sdk consumer cutover (issue #20;
contract `docs/contracts/worldtree_sdk_cutover.contract.md`). Full House Code
Discipline end-to-end: contract slice-notes → TDD → LIVE smoke → heid-code-review →
fixup → heid-bug-hunt → fixup. Both heid panels cleared. Suite **488 green**.
## Commits (tags v0.21.16.18, on `main`, not-yet-pushed)
- **`deab762`** feat — the six routes onto `ratatoskr.wt`, hand-rolled deleted.
- **`d86d6df`** fix — heid-code-review fixups (CLI presenter degrade-not-crash).
- **`4e20030`** fix — heid-bug-hunt fixups (CLI open-world container-type hardening).
## What migrated
`get_me` / `get_capabilities` / `list_character_models` / `create_character` /
`get_character_state` / `delete_character` moved off the hand-rolled httpx wrappers
onto `client.me.get()` / `client.capabilities.get()` /
`client.models.available_for_characters()` / `client.characters.create|state|delete`.
All six are **open-world reads/acks returned verbatim**; none carries a discriminated
SDK error, so each maps any `ApiError` → the `SessionApiFailed` default —
**NO new § Error map rows** (exact parity with the retired path, which never
discriminated a status/code on these routes).
**CLI-only rewire**`--whoami` (me + capabilities) and `--characters`
(models → create → state → delete) build a `wt.build_client` over the injected
`_probe_client` transport and catch `wt.SessionApiFailed` + `ConnectFailed`. **No
web-server caller** for any of these six routes.
Deleted the six hand-rolled `sessions.py` wrappers (net **5 mypy `no-any-return`**
errors); `endpoint_for_plane` + `get_session_bifrost` (slice-6) + the exception
classes stay. Retired the matching `test_sessions.py` classes (`TestGetMe`,
`TestGetCapabilities`, `TestTransientCharacters`); kept `TestEndpointForPlane` +
`TestGetSessionBifrost`.
## Decisions made at TDD (contract § slice-5 notes)
- **`create_character` omits `state` when None** — SDK-idiomatic inline literal
(per branch, to type-check against the SDK's `CreateCharacterInput` TypedDict
without importing its private `_types`); server-equivalent to the retired explicit
`state: null` (Worldtree's field defaults None either way). The only wire-shape
change; the sole call-site never sets state.
- **`delete_character` returns the SDK's open ACK verbatim** (`-> Mapping | None`,
not normalized to the hand-rolled `None`; 204 → None). The CLI ignores it.
## LIVE SMOKE (:8081/b128, `WORLDTREE_API_KEY`, INV-CUT-5 / DEC-4 cleared)
Drove both probes end-to-end through the CLI (`python -c "from ratatoskr.cli import
main; main([...])"` — the `ratatoskr` console script isn't on PATH here; `python -m
ratatoskr.cli` imports without calling `main`, no `__main__` guard). `--whoami`
rendered real identity (user_id ratatoskr, tier user, scopes incl. `character.*`,
key_id c990f0be) + `ephemeral_template echo`. `--characters` drove the full
lifecycle: `char-rp` catalog → `created char_…``state pad=[0.234,0.136,0.065]`
read-back → `deleted`. Observed real success, not merely non-crash.
## heid-code-review (thread 01KXXRN50K…) — 2 fixups
Panel: **Gróa + Regin zero** (adapter/route-map/error-map faithful); **Hulda** flagged
2 source-confirmed CLI open-world-presenter crash holes + a live-smoke test-gap. Both
holes fixed (the null/element layer):
- `_format_whoami` `scopes`: `', '.join(me.get('scopes', []))` crashes on a
present-null `scopes` (`.get(k, [])` returns None, not the default) or a non-string
element. The contract names `_format_whoami` the degrade-not-crash exemplar — the
cited exemplar had an un-hardened line (`allowed_roles` was hardened in slice-4,
`scopes` was not).
- `_characters_probe` model `items`: the slice-5 `or []` guarded the list-level null
but not each entry (`[None]`/`["x"]`/`[{"name":123}]`).
Test-gap (live-smoke not in the file set) → accept (it WAS run + recorded).
## heid-bug-hunt (thread 01KXXS9S45…) — 3 fixups + 1 accept + 1 dismiss
Cold spec-free diff-scoped panel over the post-code-review-fixup diff. Adapter +
route-map + `ConnectFailed`-at-call-sites **sound against the declared invariants
(all arms agree)**. 4 real findings, all CLI open-world paths — the **container-type
layer BELOW** the null/element holes the code-review had just fixed (the code
comments cite the CR; the two consults were firewalled from each other and converged
independently):
- **[bug, fixed] non-iterable `scopes`/`allowed_roles`** — `{"scopes": 123}`
`123 or [] == 123``for s in 123` TypeError. New `_display_seq(value)` helper
degrades any non-list (scalar / bare string / null / absent) to empty; applied to
both.
- **[bug, fixed] non-iterable `items`** — `{"items": 123}`, same class. Guard `models`
is a Mapping AND `items` is a list before iterating.
- **[robustness, fixed] non-mapping top-level `created`/`state`** — a non-mapping SDK
passthrough (`created=[...]`) → `.get` AttributeError. `isinstance(_, Mapping)`
guard → clean exit-20 abort / `pad=None`.
- **[robustness, ACCEPTED] the probe leaks its transient character on a mid-lifecycle
failure** — create → state → delete linear, no `finally`. PRE-EXISTING (retired
probe had the identical structure — "cutover did not worsen it"), TTL-bounded,
one-shot diagnostic; a `try/finally` would swallow a happy-path delete-failure
(delete is both teardown and a tested step). Gróa + Heid concur accept is
defensible. Documented in contract § slice-5 notes.
- **[DISMISSED] `sessions.py` dropped `get_me`/etc.** (Hulda, caller-contract) — the
intended DEC-3 no-backwards-compat migration (all in-repo callers rewired
same-diff); Heid labels it intended-surface-change, not a defect.
**Regin BH calibration note:** Regin (glm-5.2 non-reasoning) found only the leak,
missed the 3 crash paths, and self-graded "0 confirmed" on a control-flow-guaranteed
finding — consistent with the crystallized **Regin-unreliable-on-bug-hunts** pattern
(its code-review work this session was reliable). Gróa was the BH standout.
## The cumulative lesson (foot-gun for slices 6-7)
Open-world SDK reads need degrade-not-crash guarding at **THREE levels**, and the two
heid lenses caught different ones: the CODE-REVIEW (conformance) caught the
null/element layer; the cold BUG-HUNT (robustness) caught the container-type layer
below it. Run BOTH — they are complementary, not redundant. The three levels:
1. **container-type** — the field value may be a truthy non-iterable scalar (`123`) or
a bare string; `or []` only catches null/absent. Guard `isinstance(_, (list, tuple))`.
2. **element-type** — each entry may be a non-mapping; guard `isinstance(m, dict)`.
3. **top-level-mapping** — the whole read may be a non-mapping passthrough; guard
`isinstance(_, Mapping)` before any `.get`.
See also [[2026-07-19-worldtree-sdk-cutover-slice-4-complete]] (the slice-4 arc + the
`-m` double-module class-identity foot-gun).
@@ -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.
+41 -29
View File
@@ -46,34 +46,39 @@ 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-15 COMPLETE, slice-6 (admin) 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``477d98f`
(v0.21.13.15). **SLICE-5 (characters + me/capabilities/models) ✅ DONE**`deab762` (feat) + `d86d6df`
(heid-code-review fixups) + `4e20030` (heid-bug-hunt fixups), tags v0.21.16.18; full House Code Discipline,
both heid panels cleared. Suite **488 green**; **LIVE SMOKE on :8081/b128** drove `--whoami` (identity+caps) +
`--characters` (models→create→PAD read-back→delete) end-to-end. Slice-5 migrated
`get_me`/`get_capabilities`/`list_character_models`/`create_character`/`get_character_state`/`delete_character`
onto `client.me`/`.capabilities`/`.models`/`.characters.*` (all open-world reads → `SessionApiFailed` default,
**NO new Error-map rows**), rewired `--whoami`/`--characters` (**CLI-only; no web caller**), and DELETED the 6
hand-rolled `sessions.py` wrappers (`endpoint_for_plane`+`get_session_bifrost` [slice-6]+exceptions stay). Full
arc → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-5-complete.md`.
**KEY ADAPTER FACTS (foot-guns, cumulative for slices 6-7):** SDK reads = **open-world dicts** — presenters
MUST degrade not crash, guarded at THREE levels (slice-5 needed all three): **container-type** (a scalar `123`
is non-iterable → `for x in 123` TypeError; the `or []` idiom catches null/absent but NOT a truthy non-iterable
— the heid CODE-REVIEW caught null/element, the cold BUG-HUNT caught the container layer below it, run BOTH),
**element-type** (`isinstance(m, dict)`), **top-level-mapping** (`isinstance(_, Mapping)` before any `.get`; a
non-mapping passthrough → AttributeError); never hard-index `info["x"]`. The SDK **normalizes ANY transport
failure to `ConnectFailed(status=0)`** (NOT raw httpx) — every adapter caller `except ConnectFailed`.
**caller-semantic exceptions the adapter raises + a `-m` CLI catches must NOT live in the `-m` module** (double-
module class-identity split → uncaught traceback; live smoke catches it, unit tests can't); `TurnEvent` `turn_id`
ABSENT on text/thinking frames; `consumer_key` is BOUND-create-only; envelope parser prefers nested `detail`.
**NEXT = slice-6** (admin: `admin.sessions.bifrost` + `admin.stream_events`, admin_auth — `get_session_bifrost`
in `sessions.py` + `stream_admin_events` in `sse_client.py`); then 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 +122,14 @@ 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.18**slice-4 arc `c62b4ee``477d98f` + slice-5 arc
`deab762``4e20030` + this snapshot **COMMITTED, not-yet-pushed** (tags v0.21.13.18; push is the operator's
call); slice-13 (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 488 green** (slice-5 added the characters/me/caps adapter tests + heid code-review/bug-hunt fixup
tests, ~offset by the deleted hand-rolled character/me/caps 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 +280,12 @@ 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]` **worldtree-sdk cutover SLICE-5 COMPLETE (characters + me/capabilities/models, `deab762`+`d86d6df`+`4e20030`, v0.21.16.18, 488 green).** Last consumer reads + transient-character CRUD onto the wt adapter (CLI-only rewire, NO new Error-map rows — all six routes → SessionApiFailed default); live-smoke-proven on :8081/b128; both heid gates cleared — code-review caught the null/element open-world-presenter degrade holes, the cold bug-hunt caught the **container-type layer below** them (guard container-type + element-type + top-level-mapping). Slice-6 (admin) next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-5-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
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.21.15"
version = "0.21.19"
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
readme = "README.md"
requires-python = ">=3.12"
+83 -39
View File
@@ -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,29 @@ 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 _display_seq(value: Any) -> list[str]:
"""Coerce an open-world wire value to a list of display strings — the degrade-not-
crash floor for a list-typed field (`scopes`, `allowed_roles`, model `items`, ...).
A non-list scalar (absent, null, `123`, or a bare string) → empty rather than a
crash: the older `or []` idiom handles absent/null but NOT a truthy non-iterable
(`123 or [] == 123` → `for x in 123` `TypeError`) and would char-iterate a bare
string. Only a genuine list/tuple is str-mapped (heid bug-hunt slice-5, findings 1-2).
"""
if not isinstance(value, (list, tuple)):
return []
return [str(x) for x in value]
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, null, a scalar, or carry non-strings —
# `_display_seq` degrades every non-list to empty (the contract names this function
# the degrade-not-crash exemplar; heid code-review + bug-hunt slice-5).
lines.append(f" scopes: {', '.join(_display_seq(me.get('scopes'))) or '(none)'}")
for k in ("display_name", "key_id", "key_label"):
if k in me:
lines.append(f" {k}: {me[k]}")
@@ -766,16 +776,17 @@ def _format_whoami(me: dict[str, Any], caps: dict[str, Any]) -> str:
if isinstance(templates, dict) and templates:
for name, spec in templates.items():
# A diagnostic renderer must tolerate a malformed / partially-cutover
# server (heid bug-hunt Gróa#1/#2): a non-mapping template value, or an
# explicit-null `allowed_roles` (`.get(k, [])` returns None on null, not
# the default), must degrade — not abort the whole --whoami report.
# server: a non-mapping template value, or an `allowed_roles` that is null
# / a scalar / carries non-strings, must degrade — not abort the whole
# --whoami report (heid bug-hunt slice-5: `_display_seq` guards the
# container type, not just null/element as the prior `or []` did).
if not isinstance(spec, dict):
lines.append(f" ephemeral_template {name}: (malformed)")
continue
# Canonical post-cutover shape (worldtree-dev althing 2026-07-18,
# ADR-0012): roles, not models. `config.role` selects; `config.model`
# is now rejected server-side.
roles = ", ".join(str(r) for r in (spec.get("allowed_roles") or []))
roles = ", ".join(_display_seq(spec.get("allowed_roles")))
lines.append(
f" ephemeral_template {name}: default={spec.get('default_role', '?')} "
f"max_bytes={spec.get('system_prompt_max_bytes', '?')} roles=[{roles}]"
@@ -794,18 +805,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 +842,21 @@ 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 reads degrade, never crash (heid code-review + bug-hunt slice-5):
# guard the top-level `models` is a mapping AND `items` is a list before
# iterating (a scalar `items: 123` makes `... or []` yield `123` → `for m in
# 123` TypeError), then guard each entry is a dict with a str-coerced `name`.
raw_items = models.get("items") if isinstance(models, Mapping) else None
items = raw_items if isinstance(raw_items, (list, tuple)) else []
names = ", ".join(
str(m.get("name", "?")) for m in items 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 +870,35 @@ 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 non-mapping ACK or an absent/blank character_id aborts the
# probe cleanly (exit 20) rather than raising AttributeError/KeyError — the
# lifecycle needs the id for state + delete (heid bug-hunt slice-5). Past the
# guard, `created`/`state` are known mappings.
cid = created.get("character_id") if isinstance(created, Mapping) else None
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)
sys.stdout.write(f"state: pad={state.get('pad')}\n")
await delete_character(client, cid)
state = await wt.get_character_state(client, cid)
pad = state.get("pad") if isinstance(state, Mapping) else None
sys.stdout.write(f"state: pad={pad}\n")
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
-118
View File
@@ -6,9 +6,6 @@ Implements docs/contracts/issues/2.contract.md.
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
@dataclass(frozen=True)
@@ -228,118 +225,3 @@ def endpoint_for_plane(plane: str, base_host: str) -> str:
if plane not in ports:
raise ValueError(f"unknown plane: {plane!r} (expected 'memory', 'affect', or 'combined')")
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]:
"""GET /admin/sessions/{session_id}/bifrost — admin-scoped Bifrost dispatch state (#176).
Returns the live Bifrost binding for a session: `{endpoint_url, consumer_id,
connected, capabilities_granted, tools: [{name, description}]}`. Requires the
`admin.sessions.read` scope (admin tier), so the request OVERRIDES the
Authorization header with `admin_key` (distinct from the client's default
consumer key). Read-only (audited server-side). Parsed dict verbatim; any
non-200 → SessionApiFailed — notably 403 `auth_scope_denied` (key lacks the
scope) and 404 `session_not_bifrost_bound` (session exists, no live client).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert admin_key and isinstance(admin_key, str)
resp = await client.get(
f"/admin/sessions/{session_id}/bifrost",
headers={"Authorization": f"Bearer {admin_key}"},
)
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)
-53
View File
@@ -5,14 +5,9 @@ Implements docs/contracts/issues/1.contract.md.
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any, NamedTuple
import httpx
import httpx_sse
class SseId(NamedTuple):
"""Parsed composite SSE wire `id:` per spec §SSE id format."""
@@ -173,51 +168,3 @@ class CancelFailed(Exception):
super().__init__(f"cancel failed: status={status}, body={body[:128]!r}")
self.status = status
self.body = body
async def stream_admin_events(
client: httpx.AsyncClient,
*,
admin_key: str,
last_event_id: int | None = None,
) -> AsyncIterator[AdminEvent]:
"""GET /admin/events SSE — the admin-tier lifecycle broadcast stream (INV-046).
Yields `AdminEvent` envelopes as they arrive. Admin-scoped (admin.events.read):
the request OVERRIDES Authorization with `admin_key` (distinct from the
client's default consumer bearer). `last_event_id` sets the `Last-Event-ID`
header for resume (plain decimal int). Long-lived — iterate until the caller
stops or the connection ends. Non-200 → SseConnectFailed; a mid-stream drop
→ SseConnectionDropped (caller may reconnect from the last-seen `AdminEvent.id`).
Malformed frames are skipped (best-effort stream).
"""
assert client is not None
assert admin_key and isinstance(admin_key, str)
headers = {"Authorization": f"Bearer {admin_key}"}
if last_event_id is not None:
headers["Last-Event-ID"] = str(last_event_id)
async with httpx_sse.aconnect_sse(
client, "GET", "/admin/events", headers=headers
) as event_source:
if event_source.response.status_code != 200:
body = await event_source.response.aread()
raise SseConnectFailed(status=event_source.response.status_code, body=body)
try:
async for sse in event_source.aiter_sse():
if sse.data == "":
continue
try:
env = json.loads(sse.data)
except json.JSONDecodeError:
continue # skip a malformed admin frame (best-effort)
yield AdminEvent(
id=env.get("id", 0),
type=env["type"],
timestamp=env.get("timestamp"),
data=env.get("data", {}),
)
except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ReadTimeout) as exc:
raise SseConnectionDropped(last_seen_sse_id=None) from exc
+41 -17
View File
@@ -45,15 +45,14 @@ from ratatoskr.sessions import (
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
PersonaNotConfigured,
SessionApiFailed,
endpoint_for_plane,
get_session_bifrost,
)
# The turn path (create / stream / cancel / tools / messages) AND the agents /
# persona-state reads are served by the worldtree-sdk adapter (`wt.*`), which raises
# ratatoskr's caller-semantic exceptions (DEC-2). The remaining hand-rolled endpoint
# (admin bifrost) stays on the `sessions` / `sse_client` wrappers until slice 6.
# The turn path (create / stream / cancel / tools / messages), the agents /
# persona-state reads, AND the admin surface (bifrost inspection + admin-events stream)
# are all served by the worldtree-sdk adapter (`wt.*`), which raises ratatoskr's
# caller-semantic exceptions (DEC-2). `AdminEvent` is still ratatoskr's domain event
# type the adapter re-wraps into (imported from `sse_client` until slice-7 teardown).
from ratatoskr.sse_client import (
AdminEvent,
CancelAlreadyCompleted,
@@ -64,16 +63,21 @@ from ratatoskr.sse_client import (
SseConnectFailed,
SseConnectionDropped,
TurnIdFlip,
stream_admin_events,
)
def _wt_client(client: httpx.AsyncClient, *, max_reconnects: int = 5) -> WorldtreeClient:
def _wt_client(
client: httpx.AsyncClient, *, admin_key: str | None = None, max_reconnects: int = 5
) -> WorldtreeClient:
"""Wrap a client_factory transport as the adapter's WorldtreeClient (INV-CUT-1:
the SDK never closes it). base_url + bearer are read off the transport (the
factory bakes them in); the SDK re-applies auth per request, so the extracted
key just mirrors the transport's default. A no-auth test transport falls back to
a placeholder key (respx ignores auth)."""
a placeholder key (respx ignores auth).
`admin_key` is the SERVER-HELD admin credential (slice-6): the SDK's `admin.*`
routes authenticate with the client's `admin_auth`, NOT a per-call header, so an
admin endpoint passes it here. Omitted for the default-tier reads."""
base_url = str(client.base_url) or "http://localhost"
header = client.headers.get("Authorization", "")
# Case-insensitive scheme + tolerant of extra whitespace, so a valid bearer is
@@ -81,7 +85,11 @@ def _wt_client(client: httpx.AsyncClient, *, max_reconnects: int = 5) -> Worldtr
parts = header.split(None, 1)
api_key = parts[1].strip() if len(parts) == 2 and parts[0].lower() == "bearer" else ""
return wt.build_client(
base_url, api_key=api_key or "ratatoskr", transport=client, max_reconnects=max_reconnects
base_url,
api_key=api_key or "ratatoskr",
admin_key=admin_key,
transport=client,
max_reconnects=max_reconnects,
)
@@ -573,14 +581,24 @@ async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
bstate = await get_session_bifrost(client, session_id, admin_key=admin_key)
except SessionApiFailed as exc:
async with client_factory() as transport:
# slice-6: the SDK's admin.* routes use the client's admin_auth (built with
# admin_key), not a per-call header — so it rides on the wt client here.
client = _wt_client(transport, admin_key=admin_key)
bstate = await wt.get_session_bifrost(client, session_id)
except wt.SessionApiFailed as exc:
return JSONResponse(
{"error_code": "bifrost_state_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(bstate, status_code=200)
except (httpx.RequestError, ConnectFailed) as exc:
# SDK normalizes a transport failure to ConnectFailed(status=0), not a raw
# httpx error; both surface the same network envelope (cutover foot-gun).
return JSONResponse(
{"error_code": "network_error", "message": str(exc)},
status_code=502,
)
return JSONResponse(dict(bstate), status_code=200)
def _admin_event_matches_web(ev: AdminEvent, session_id: str | None) -> bool:
@@ -608,9 +626,13 @@ async def _admin_events_endpoint(request: Request) -> Response:
client_factory = request.app.state.client_factory
async def gen() -> AsyncIterator[bytes]:
client = client_factory()
transport = client_factory()
# slice-6: admin_auth rides on the wt client (built with admin_key); the adapter
# re-wraps the SDK's AdminEvent → ratatoskr's (id/type/data degraded) and the
# stream's terminal SDK errors (incl. ConnectFailed) → the Sse* types below.
client = _wt_client(transport, admin_key=admin_key)
try:
async for ev in stream_admin_events(client, admin_key=admin_key):
async for ev in wt.stream_admin_events(client):
if not _admin_event_matches_web(ev, session_id):
continue
# Fixed SSE event name so the browser renders EVERY admin type
@@ -630,7 +652,9 @@ async def _admin_events_endpoint(request: Request) -> Response:
except asyncio.CancelledError:
raise # browser disconnect — let the generator unwind
finally:
await client.aclose()
# ratatoskr owns the transport lifecycle (INV-CUT-1); close the injected
# httpx client, never the wt client (which would no-op the transport anyway).
await transport.aclose()
return StreamingResponse(gen(), media_type="text/event-stream")
+172
View File
@@ -60,6 +60,7 @@ from .sessions import (
Tier3UserIdUnsupported,
)
from .sse_client import (
AdminEvent,
AgentNotAvailable,
CancelAlreadyCompleted,
CancelFailed,
@@ -576,3 +577,174 @@ 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
# ── slice-6: admin (bifrost inspection + admin-events stream) adapter routes ──
# The admin surface over `client.admin.*` — admin_auth-scoped (set via
# `build_client(admin_key=...)`, NOT a per-call `Authorization` header). Both are
# web-only. `get_session_bifrost` reads the open-world `BifrostInspection` verbatim
# (any error → the `SessionApiFailed` default); `stream_admin_events` drives the
# long-lived D2 admin-events SSE, re-wrapping the SDK's `AdminEvent` → ratatoskr's
# (degrading the SDK's `admin_id`-nan / None `type`/`data` at the boundary so the web
# filter never crashes) and re-wrapping the stream's terminal errors → ratatoskr's
# `Sse*` types (INV-CUT-2 stream rows).
async def get_session_bifrost(
client: WorldtreeClient, session_id: str
) -> Mapping[str, Any]:
"""The admin-scoped Bifrost dispatch state for a session (GET
/admin/sessions/{id}/bifrost, #176), open-world dict verbatim.
Admin-tier — the client MUST carry `admin_auth` (built with `admin_key`); the SDK
uses that provider, not a per-call header. Any error → the `SessionApiFailed`
default (notably 403 `auth_scope_denied`, 404 `session_not_bifrost_bound`) — the
retired hand-rolled path likewise mapped every non-200 generically.
"""
assert session_id and isinstance(session_id, str)
try:
return await client.admin.sessions.bifrost(session_id)
except ApiError as exc:
raise translate_error(exc) from exc
async def stream_admin_events(
client: WorldtreeClient, *, last_event_id: int | None = None
) -> AsyncGenerator[AdminEvent, None]:
"""Drive the long-lived admin-events SSE (GET /admin/events, #11 / INV-046) and yield
ratatoskr `AdminEvent`s, re-wrapping the SDK's typed `AdminEvent` at the boundary.
Admin-tier (the client MUST carry `admin_auth`). The SDK's `AdminEvent` is open-world
where ratatoskr's is stable: `admin_id` is `nan` for an id-less envelope (→ `id=0`),
and `type`/`data` may be None (→ `""` / `{}`) — normalized HERE so the web filter +
formatter (`ev.id` / `ev.type` / `ev.data`) never crash on a partial wire (chosen over
yielding SDK events through + rewiring the web filter). Error map (INV-CUT-2, stream
rows): SDK `ApiError` (a non-200 open — the admin stream raises `admin_stream_failed`,
NOT `ConnectFailed`) → `SseConnectFailed`; SDK `ConnectionDropped` (a connect-time
transport failure → cursor None, OR a mid-stream drop / the long-lived stream's
resumable EOF → cursor) → `SseConnectionDropped`. The SDK stream is best-effort (skips
malformed frames — no `Malformed*`).
"""
try:
async for ev in client.admin.stream_events(last_event_id=last_event_id):
yield AdminEvent(
id=ev.admin_id if isinstance(ev.admin_id, int) else 0,
type=ev.type or "",
timestamp=ev.timestamp,
data=dict(ev.data) if isinstance(ev.data, Mapping) else {},
)
except wtsdk.ConnectionDropped as exc:
raise SseConnectionDropped(last_seen_sse_id=exc.last_seen_sse_id) from exc
except ApiError as exc:
# The admin stream raises ApiError("admin_stream_failed", status=…) on a non-200
# open (a connect-time transport failure instead surfaces as ConnectionDropped);
# map the non-200 → SseConnectFailed so the web's stream-error handler catches it.
raise SseConnectFailed(status=exc.status, body=(exc.body or "").encode()) from exc
+139
View File
@@ -1879,6 +1879,49 @@ 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
@respx.mock
def test_whoami_tolerates_scalar_scopes_and_roles(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""Non-iterable (scalar) `scopes` / `allowed_roles` → degrade to empty, never a
`for x in 123` TypeError (heid bug-hunt slice-5: `_display_seq` guards the
container TYPE, the next layer past the code-review null/element fix)."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(200, json={"user_id": "u", "scopes": 123, "tier": "user"})
)
respx.get("https://w.example/capabilities").mock(
return_value=httpx.Response(
200,
json={
"ephemeral_templates": {"echo": {"allowed_roles": 7, "default_role": "echo"}}
},
)
)
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
assert rc == 0
out = capsys.readouterr().out
assert "scopes: (none)" in out
assert "roles=[]" in out
class TestTier2Probes:
"""--characters + --set-persona-pad one-shot probes (Tier-2: #161 + persona_state-write)."""
@@ -1923,6 +1966,102 @@ 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_tolerates_scalar_items_and_nonmapping_state(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""Scalar `items` (`123`) → '(none)' not a `for m in 123` TypeError; a non-mapping
`state` → 'pad=None' not an AttributeError. Lifecycle still completes (heid
bug-hunt slice-5: container-type + top-level-mapping guards)."""
respx.get("https://w.example/models/available-for-characters").mock(
return_value=httpx.Response(200, json={"items": 123})
)
respx.post("https://w.example/characters").mock(
return_value=httpx.Response(201, json={"character_id": "c1", "ttl_expires_at": "t"})
)
# non-mapping state body (open-world passthrough of a JSON array).
respx.get("https://w.example/characters/c1/state").mock(
return_value=httpx.Response(200, json=["not", "a", "mapping"])
)
del_route = 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
assert "character models: (none)" in out
assert "state: pad=None" in out
assert "deleted: c1" in out
assert del_route.call_count == 1
@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_characters_probe_non_mapping_create_aborts(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""A non-mapping create ACK (open-world passthrough of a JSON array/scalar) →
clean exit-20 abort, never an AttributeError on `created.get(...)` (heid
bug-hunt slice-5, finding #3)."""
respx.get("https://w.example/models/available-for-characters").mock(
return_value=httpx.Response(200, json={"items": []})
)
respx.post("https://w.example/characters").mock(
return_value=httpx.Response(201, json=["not", "a", "mapping"])
)
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
@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."""
+7 -225
View File
@@ -1,20 +1,13 @@
"""Tests for ratatoskr.sessions per docs/contracts/issues/2.contract.md."""
"""Tests for ratatoskr.sessions per docs/contracts/issues/2.contract.md.
Post worldtree-sdk cutover the `sessions` module is down to `endpoint_for_plane`
(the Bifrost provider-plane helper) + the caller-semantic exception classes the
`ratatoskr.wt` adapter raises; every wire wrapper has retired onto the SDK adapter
(the wrappers' tests live in `test_wt.py`)."""
import httpx
import pytest
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,
)
from ratatoskr.sessions import endpoint_for_plane
class TestEndpointForPlane:
@@ -36,214 +29,3 @@ class TestEndpointForPlane:
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
with pytest.raises(ValueError):
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)."""
@respx.mock
async def test_happy_uses_admin_bearer(self) -> None:
"""happy [happy,tracer]: 200 → binding dict; request carries the ADMIN bearer (override)."""
route = respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
return_value=httpx.Response(
200,
json={
"endpoint_url": "https://bifrost.example/mcp",
"consumer_id": "alice",
"connected": True,
"capabilities_granted": ["tools:call", "tools:read"],
"tools": [{"name": "bifrost.alice.echo", "description": "echo"}],
},
)
)
async with httpx.AsyncClient(
base_url="https://w.example",
headers={"Authorization": "Bearer consumer-key"},
) as client:
state = await get_session_bifrost(client, "s1", admin_key="admin-xyz")
assert state["connected"] is True
assert state["tools"][0]["name"] == "bifrost.alice.echo"
# the request overrode the client's default consumer bearer with the admin key
assert route.calls[0].request.headers["Authorization"] == "Bearer admin-xyz"
@respx.mock
async def test_403_scope_denied(self) -> None:
"""403 [error]: admin key lacks admin.sessions.read → SessionApiFailed(403)."""
respx.get("https://w.example/admin/sessions/s1/bifrost").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 get_session_bifrost(client, "s1", admin_key="k")
assert exc.value.status == 403
@respx.mock
async def test_404_not_bound(self) -> None:
"""404 [error]: session_not_bifrost_bound → SessionApiFailed(404)."""
respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_bifrost_bound"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_session_bifrost(client, "s1", admin_key="k")
assert exc.value.status == 404
@respx.mock
async def test_empty_admin_key_asserts(self) -> None:
"""empty_admin_key [adversarial]: '' → AssertionError; no HTTP issued."""
route = respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
return_value=httpx.Response(200, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
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
-94
View File
@@ -1,94 +0,0 @@
"""Tests for ratatoskr.sse_client — the admin-events stream (#11).
The turn-stream + Event-model tests retired with the worldtree-sdk cutover (#20);
the turn path is now covered by tests/test_wt.py + the CLI/web integration tests.
This module keeps the still-hand-rolled admin-events surface (slice-6)."""
import httpx
import pytest
import respx
from ratatoskr.sse_client import (
AdminEvent,
SseConnectFailed,
stream_admin_events,
)
def _sse_chunk(sse_id: str, body: dict[str, object]) -> bytes:
"""Compose one SSE event in wire format. Trailing blank line per spec."""
import json
return f"id: {sse_id}\ndata: {json.dumps(body)}\n\n".encode()
class TestStreamAdminEvents:
"""docs/conversation-api-spec.md § Admin Event Stream — stream_admin_events (#11)."""
@respx.mock
async def test_happy_multi_event_admin_bearer(self) -> None:
"""happy [happy,tracer]: yields AdminEvent envelopes; request uses the ADMIN bearer."""
env1 = {
"id": 41, "type": "session.created", "timestamp": "2026-05-06T10:00:00.000Z",
"data": {"session_id": "s1", "agent_id": "mimir", "user_id": None},
}
env2 = {
"id": 42, "type": "turn.started", "timestamp": "2026-05-06T10:00:01.000Z",
"data": {"session_id": "s1", "turn_id": 7, "agent_id": "mimir", "user_id": None},
}
stream = _sse_chunk("41", env1) + _sse_chunk("42", env2)
route = respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=stream
)
)
async with httpx.AsyncClient(
base_url="https://w.example", headers={"Authorization": "Bearer consumer"}
) as client:
events = [e async for e in stream_admin_events(client, admin_key="admin-xyz")]
assert [e.type for e in events] == ["session.created", "turn.started"]
assert isinstance(events[0], AdminEvent)
assert events[0].id == 41
assert events[1].data["turn_id"] == 7
assert route.calls[0].request.headers["Authorization"] == "Bearer admin-xyz"
@respx.mock
async def test_last_event_id_header(self) -> None:
"""last_event_id_header [trace]: empty stream → []; Last-Event-ID header sent."""
route = respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=b""
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_admin_events(client, admin_key="k", last_event_id=99)]
assert events == []
assert route.calls[0].request.headers["Last-Event-ID"] == "99"
@respx.mock
async def test_403_scope_denied(self) -> None:
"""403 [error]: key lacks admin.events.read → SseConnectFailed(403)."""
respx.get("https://w.example/admin/events").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(SseConnectFailed) as exc:
_ = [e async for e in stream_admin_events(client, admin_key="k")]
assert exc.value.status == 403
@respx.mock
async def test_skips_malformed_frame(self) -> None:
"""skips_malformed [adversarial]: a bad-JSON frame is skipped, not fatal."""
good = _sse_chunk("41", {"id": 41, "type": "session.created", "data": {"session_id": "s1"}})
bad = b"id: 42\ndata: not-json\n\n"
good2 = _sse_chunk(
"43", {"id": 43, "type": "session.deleted", "data": {"session_id": "s1"}}
)
respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=good + bad + good2
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_admin_events(client, admin_key="k")]
assert [e.type for e in events] == ["session.created", "session.deleted"]
+370
View File
@@ -38,6 +38,7 @@ from ratatoskr.sessions import (
Tier3UserIdUnsupported,
)
from ratatoskr.sse_client import (
AdminEvent,
AgentNotAvailable,
CancelAlreadyCompleted,
CancelFailed,
@@ -53,16 +54,24 @@ 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_bifrost,
get_session_messages,
get_session_tools,
list_agents,
list_character_models,
list_sessions,
patch_agent,
set_persona_state,
stream_admin_events,
stream_turn,
translate_error,
write_authored_history,
@@ -849,3 +858,364 @@ 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
# ── slice-6: admin (bifrost inspection + admin-events stream) adapter routes ──
class _SdkAdminEvent:
"""Minimal stand-in for the SDK's `AdminEvent` — the adapter reads
`admin_id`/`type`/`timestamp`/`data`. `admin_id` may be `nan` (id-less);
`type`/`data` may be None (open-world)."""
def __init__(self, admin_id: Any, type: Any, timestamp: Any = None, data: Any = None) -> None:
self.admin_id = admin_id
self.type = type
self.timestamp = timestamp
self.data = data
class _FakeAdminSessions:
def __init__(self, admin: _FakeAdmin) -> None:
self._admin = admin
async def bifrost(self, *a: Any, **k: Any) -> Any:
return await self._admin._bifrost(*a, **k)
class _FakeAdmin:
"""Stand-in for `client.admin` — `.sessions.bifrost(id)` (canned result/error) +
`.stream_events(...)` (canned events / terminal error). Same shape as `_FakeSessions`."""
def __init__(
self,
*,
result: Any = None,
error: BaseException | None = None,
events: list[Any] | None = None,
stream_error: BaseException | None = None,
) -> None:
self._result = result
self._error = error
self._events = events or []
self._stream_error = stream_error
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
self.sessions = _FakeAdminSessions(self)
async def _bifrost(self, *a: Any, **k: Any) -> Any:
self.calls.append(("bifrost", a, k))
if self._error is not None:
raise self._error
return self._result
def stream_events(self, *a: Any, **k: Any) -> Any:
self.calls.append(("stream_events", a, k))
return self._astream()
async def _astream(self) -> Any:
for ev in self._events:
yield ev
if self._stream_error is not None:
raise self._stream_error
class _FakeAdminClient:
def __init__(self, admin: _FakeAdmin) -> None:
self.admin = admin
def _wtad(admin: _FakeAdmin) -> WorldtreeClient:
"""Cast the admin-surface fake (`.admin.sessions.bifrost` + `.admin.stream_events`)
to the nominal client type the slice-6 route functions are typed against."""
return cast(WorldtreeClient, _FakeAdminClient(admin))
class TestGetSessionBifrostWt:
"""slice-6: get_session_bifrost → SDK admin.sessions.bifrost(id); open-world verbatim."""
async def test_happy_returns_dict_verbatim(self) -> None:
binding = {"endpoint_url": "https://b/mcp", "connected": True, "tools": []}
fake = _FakeAdmin(result=binding)
out = await get_session_bifrost(_wtad(fake), "s1")
assert out is binding
assert fake.calls[-1] == ("bifrost", ("s1",), {})
async def test_empty_id_asserts_no_call(self) -> None:
fake = _FakeAdmin(result={})
with pytest.raises(AssertionError):
await get_session_bifrost(_wtad(fake), "")
assert fake.calls == []
async def test_403_maps_to_session_api_failed(self) -> None:
fake = _FakeAdmin(error=ApiError("auth_scope_denied", "no", status=403))
with pytest.raises(SessionApiFailed) as ei:
await get_session_bifrost(_wtad(fake), "s1")
assert ei.value.status == 403
async def test_404_not_bound_maps_to_session_api_failed(self) -> None:
fake = _FakeAdmin(error=ApiError("session_not_bifrost_bound", "no", status=404))
with pytest.raises(SessionApiFailed) as ei:
await get_session_bifrost(_wtad(fake), "s1")
assert ei.value.status == 404
class TestStreamAdminEventsWt:
"""slice-6: stream_admin_events → SDK admin.stream_events; re-wrap SDK AdminEvent →
ratatoskr AdminEvent (nan/None degraded), terminal errors → Sse* types."""
async def test_rewraps_events_to_ratatoskr_shape(self) -> None:
sdk_evs = [
_SdkAdminEvent(5, "session.created", "t0", {"session_id": "s1"}),
_SdkAdminEvent(6, "turn.completed", "t1", {"session_id": "s1", "turn_id": 2}),
]
fake = _FakeAdmin(events=sdk_evs)
out = await _drain(stream_admin_events(_wtad(fake)))
assert all(isinstance(e, AdminEvent) for e in out)
assert (out[0].id, out[0].type, out[0].timestamp) == (5, "session.created", "t0")
assert out[0].data == {"session_id": "s1"}
assert out[1].id == 6
async def test_nan_admin_id_degrades_to_zero(self) -> None:
fake = _FakeAdmin(events=[_SdkAdminEvent(float("nan"), "system.heartbeat", None, None)])
out = await _drain(stream_admin_events(_wtad(fake)))
assert out[0].id == 0 # id-less envelope → 0, not nan
async def test_none_type_and_data_degrade(self) -> None:
# A partial wire: type=None (would crash `.startswith` in the web filter) and
# data=None (would crash `.get`) → "" and {} at the adapter boundary.
fake = _FakeAdmin(events=[_SdkAdminEvent(1, None, None, None)])
out = await _drain(stream_admin_events(_wtad(fake)))
assert out[0].type == ""
assert out[0].data == {}
async def test_passes_last_event_id(self) -> None:
fake = _FakeAdmin(events=[])
await _drain(stream_admin_events(_wtad(fake), last_event_id=42))
assert fake.calls[-1] == ("stream_events", (), {"last_event_id": 42})
async def test_non_200_apierror_maps_to_sse_connect_failed(self) -> None:
# The SDK admin stream raises ApiError("admin_stream_failed", status=…) on a
# non-200 open (NOT ConnectFailed) — mapped → SseConnectFailed for the web.
fake = _FakeAdmin(stream_error=ApiError("admin_stream_failed", "no", status=502))
with pytest.raises(SseConnectFailed) as ei:
await _drain(stream_admin_events(_wtad(fake)))
assert ei.value.status == 502
async def test_connection_dropped_maps_and_carries_cursor(self) -> None:
# Both a connect-time failure (cursor None) and a mid-stream drop / resumable
# EOF (cursor set) surface as ConnectionDropped → SseConnectionDropped.
fake = _FakeAdmin(stream_error=wtsdk.ConnectionDropped("42"))
with pytest.raises(SseConnectionDropped) as ei:
await _drain(stream_admin_events(_wtad(fake)))
assert ei.value.last_seen_sse_id == "42"
Generated
+1 -1
View File
@@ -472,7 +472,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.21.15"
version = "0.21.19"
source = { editable = "." }
dependencies = [
{ name = "httpx" },