Compare commits

..

3 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
15 changed files with 584 additions and 312 deletions
@@ -274,10 +274,67 @@ re-anchor its coverage-map rows.
is already hardened (slice-4 heid bug-hunt). The rewired `_characters_probe` extracts 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, 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. 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 - **CLI-only rewire.** `me` / `capabilities` / `characters` / `models` have NO
web-server caller — only the `--whoami` and `--characters` CLI one-shot probes. The web-server caller — only the `--whoami` and `--characters` CLI one-shot probes. The
web surface is untouched this slice. 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 ## Out of scope
- Bifrost PROVIDER planes (memory/affect) — hand-rolled, ADR-0009, untouched. - Bifrost PROVIDER planes (memory/affect) — hand-rolled, ADR-0009, untouched.
+2 -2
View File
@@ -97,8 +97,8 @@ sub-gap).
| `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 /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 /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 /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/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) | ✅ | `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 /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` | | `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…` | | `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 | | `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 |
@@ -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).
+33 -25
View File
@@ -46,30 +46,35 @@ upstream API key stays server-side (INV-003).
_As of 2026-07-19:_ _As of 2026-07-19:_
**🔨 ACTIVE MIGRATION — worldtree-sdk cutover (issue #20): SLICE-14 COMPLETE, slice-5 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) 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, 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 vor-cross'd, heid-panel-reviewed); contract `docs/contracts/worldtree_sdk_cutover.contract.md`. **SLICE-1+2
(foundation + sessions/turn) ✅ PUSHED** origin `aba1730`. **SLICE-3 (persona + authored-history + first-message) (foundation + sessions/turn) ✅ PUSHED** origin `aba1730`. **SLICE-3 (persona + authored-history + first-message)
✅ DONE** `ca9a339`+`fc256bb`. **SLICE-4 (agents/Tier-3 + `model`→`role` fold) ✅ DONE** `c62b4ee` (feat) + ✅ DONE** `ca9a339`+`fc256bb`. **SLICE-4 (agents/Tier-3 + `model`→`role` fold) ✅ DONE** `c62b4ee``477d98f`
`aed9429` (heid-code-review fixups) + `477d98f` (heid-bug-hunt fixups), tags v0.21.13.15; full House Code (v0.21.13.15). **SLICE-5 (characters + me/capabilities/models) ✅ DONE**`deab762` (feat) + `d86d6df`
Discipline, both heid panels cleared. Suite **475 green**. Slice-4 migrated (heid-code-review fixups) + `4e20030` (heid-bug-hunt fixups), tags v0.21.16.18; full House Code Discipline,
list_agents/get_persona_state/define/patch/delete onto `client.agents.*` (open-world dicts, errors mapped by both heid panels cleared. Suite **488 green**; **LIVE SMOKE on :8081/b128** drove `--whoami` (identity+caps) +
route+(status,error_code)), folded `model``role` (`LocalAgentEntry.role`, index schema v2), and DELETED the `--characters` (models→create→PAD read-back→delete) end-to-end. Slice-5 migrated
hand-rolled `sessions.list_agents`/`get_persona_state`/`AgentInfo` + `tier3.define/patch/delete_agent`. Full arc `get_me`/`get_capabilities`/`list_character_models`/`create_character`/`get_character_state`/`delete_character`
+ the two slice-4 foot-guns → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-4-complete.md`. onto `client.me`/`.capabilities`/`.models`/`.characters.*` (all open-world reads → `SessionApiFailed` default,
**KEY ADAPTER FACTS (foot-guns, cumulative for slices 5-7):** SDK reads = **open-world dicts** **NO new Error-map rows**), rewired `--whoami`/`--characters` (**CLI-only; no web caller**), and DELETED the 6
presenters MUST degrade not crash (slice-4 bug-hunt: hard-indexing `info["x"]` crashes on partial wire; use hand-rolled `sessions.py` wrappers (`endpoint_for_plane`+`get_session_bifrost` [slice-6]+exceptions stay). Full
`.get`/type-guards; `system_prompt: null``None.splitlines()`); the SDK **normalizes ANY transport failure to arc → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-5-complete.md`.
`ConnectFailed(status=0)`** (NOT raw httpx) — every adapter caller must `except ConnectFailed`; **KEY ADAPTER FACTS (foot-guns, cumulative for slices 6-7):** SDK reads = **open-world dicts** — presenters
**caller-semantic exceptions the adapter raises + a `-m` CLI catches must NOT live in the `-m` module** (the MUST degrade not crash, guarded at THREE levels (slice-5 needed all three): **container-type** (a scalar `123`
`python -m ratatoskr.tier3` double-module split gave two `Tier3*` class identities → uncaught traceback; fixed is non-iterable → `for x in 123` TypeError; the `or []` idiom catches null/absent but NOT a truthy non-iterable
by homing them in `sessions.py`; unit tests can't catch this, the live smoke did); `TurnEvent` `turn_id` ABSENT — the heid CODE-REVIEW caught null/element, the cold BUG-HUNT caught the container layer below it, run BOTH),
on text/thinking frames; `consumer_key` is BOUND-create-only; envelope parser prefers nested `detail`. **element-type** (`isinstance(m, dict)`), **top-level-mapping** (`isinstance(_, Mapping)` before any `.get`; a
**NEXT = slice-5** (characters + me/capabilities/models — remaining consumer reads); then slice-6 (admin: non-mapping passthrough → AttributeError); never hard-index `info["x"]`. The SDK **normalizes ANY transport
`stream_admin_events` + `get_session_bifrost`, admin_auth), slice-7 (teardown: retire contracts #2/#15, drop failure to `ConnectFailed(status=0)`** (NOT raw httpx) — every adapter caller `except ConnectFailed`.
`httpx-sse`, MINOR bump per DEC-6 w/ operator approval). Scope: consumer layer ONLY; Bifrost provider untouched. **caller-semantic exceptions the adapter raises + a `-m` CLI catches must NOT live in the `-m` module** (double-
Full design → auto-memory `project_worldtree_sdk_cutover`. 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`.
**✅ RESOLVED — tier3 agents `model``role` (scope B) folded into cutover slice-4** (`c62b4ee`, v0.21.13). The **✅ 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 deferred deploy-gated scope-B work (response `model``role` per spec 1.2 / b128, `LocalAgentEntry`, index schema
@@ -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 (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`). adoption** (ruled normative, not blocking → `persistent-memory.d/2026-07-16-bifrost-cursor-conformance.md`).
**Substrate / environment:** branch `main` at **v0.21.15** — slice-4 arc `c62b4ee``477d98f` + this snapshot **Substrate / environment:** branch `main` at **v0.21.18** — slice-4 arc `c62b4ee``477d98f` + slice-5 arc
**COMMITTED, not-yet-pushed** (tags v0.21.13.15; push is the operator's call); slice-13 (v0.21.3.12, `deab762``4e20030` + this snapshot **COMMITTED, not-yet-pushed** (tags v0.21.13.18; push is the operator's
`b1fbadd``4f92a21`) PUSHED to origin earlier. origin `git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep: 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 `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`); **`==1.1.4`** / wire v0.7; WT openapi vendored 2.3.0, **conversation-api-spec re-synced to v1.1** (`b4a278c`);
**suite 475 green** (slice-4 added the agents-family adapter tests + heid-gate fixup tests, ~offset by the **suite 488 green** (slice-5 added the characters/me/caps adapter tests + heid code-review/bug-hunt fixup
deleted hand-rolled agent/persona tests). Personal WT on **b128** 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 (`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 — **: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 restart via `scratchpad/relaunch_by_pid.py <pid>` (pid via `ss -ltnp | grep <port>`). `env.sh` sets
@@ -276,6 +282,8 @@ decision. Captures rationale that won't be obvious from code alone.
- `[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-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` - `[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._ _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._
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ratatoskr" name = "ratatoskr"
version = "0.21.17" version = "0.21.19"
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability" description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+37 -21
View File
@@ -745,16 +745,29 @@ async def _amain(args: ParsedArgs) -> int:
loop.remove_signal_handler(signal.SIGINT) loop.remove_signal_handler(signal.SIGINT)
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: def _format_whoami(me: Mapping[str, Any], caps: Mapping[str, Any]) -> str:
"""Render the --whoami report: identity (GET /me) + server capabilities.""" """Render the --whoami report: identity (GET /me) + server capabilities."""
lines = ["identity:"] lines = ["identity:"]
lines.append(f" user_id: {me.get('user_id', '?')}") lines.append(f" user_id: {me.get('user_id', '?')}")
lines.append(f" tier: {me.get('tier', '?')}") lines.append(f" tier: {me.get('tier', '?')}")
# Open-world read: `scopes` may be absent, present-null, or carry non-strings — # Open-world read: `scopes` may be absent, null, a scalar, or carry non-strings —
# `or []` + str() degrades all three (a present-null `.get('scopes', [])` returns # `_display_seq` degrades every non-list to empty (the contract names this function
# None, not the default), matching the `allowed_roles` hardening below (heid-code- # the degrade-not-crash exemplar; heid code-review + bug-hunt slice-5).
# review slice-5: the contract names this function the degrade-not-crash exemplar). lines.append(f" scopes: {', '.join(_display_seq(me.get('scopes'))) or '(none)'}")
lines.append(f" scopes: {', '.join(str(s) for s in (me.get('scopes') or [])) or '(none)'}")
for k in ("display_name", "key_id", "key_label"): for k in ("display_name", "key_id", "key_label"):
if k in me: if k in me:
lines.append(f" {k}: {me[k]}") lines.append(f" {k}: {me[k]}")
@@ -763,16 +776,17 @@ def _format_whoami(me: Mapping[str, Any], caps: Mapping[str, Any]) -> str:
if isinstance(templates, dict) and templates: if isinstance(templates, dict) and templates:
for name, spec in templates.items(): for name, spec in templates.items():
# A diagnostic renderer must tolerate a malformed / partially-cutover # A diagnostic renderer must tolerate a malformed / partially-cutover
# server (heid bug-hunt Gróa#1/#2): a non-mapping template value, or an # server: a non-mapping template value, or an `allowed_roles` that is null
# explicit-null `allowed_roles` (`.get(k, [])` returns None on null, not # / a scalar / carries non-strings, must degrade — not abort the whole
# the default), must degrade — not abort the whole --whoami report. # --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): if not isinstance(spec, dict):
lines.append(f" ephemeral_template {name}: (malformed)") lines.append(f" ephemeral_template {name}: (malformed)")
continue continue
# Canonical post-cutover shape (worldtree-dev althing 2026-07-18, # Canonical post-cutover shape (worldtree-dev althing 2026-07-18,
# ADR-0012): roles, not models. `config.role` selects; `config.model` # ADR-0012): roles, not models. `config.role` selects; `config.model`
# is now rejected server-side. # 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( lines.append(
f" ephemeral_template {name}: default={spec.get('default_role', '?')} " f" ephemeral_template {name}: default={spec.get('default_role', '?')} "
f"max_bytes={spec.get('system_prompt_max_bytes', '?')} roles=[{roles}]" f"max_bytes={spec.get('system_prompt_max_bytes', '?')} roles=[{roles}]"
@@ -832,15 +846,14 @@ async def _characters_probe(args: ParsedArgs) -> int:
client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport) client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport)
try: try:
models = await wt.list_character_models(client) models = await wt.list_character_models(client)
# Open-world read: `items` may be absent/null (`or []`) AND each entry may be # Open-world reads degrade, never crash (heid code-review + bug-hunt slice-5):
# a non-mapping (`[None]` / `["x"]`) or carry a non-string `name` — guard the # guard the top-level `models` is a mapping AND `items` is a list before
# item is a dict and coerce `name` to str so a malformed catalog degrades # iterating (a scalar `items: 123` makes `... or []` yield `123` → `for m in
# rather than crashing (heid-code-review slice-5; element-level completion of # 123` TypeError), then guard each entry is a dict with a str-coerced `name`.
# the list-level `or []` guard). raw_items = models.get("items") if isinstance(models, Mapping) else None
items = raw_items if isinstance(raw_items, (list, tuple)) else []
names = ", ".join( names = ", ".join(
str(m.get("name", "?")) str(m.get("name", "?")) for m in items if isinstance(m, dict)
for m in (models.get("items") or [])
if isinstance(m, dict)
) )
sys.stdout.write(f"character models: {names or '(none)'}\n") sys.stdout.write(f"character models: {names or '(none)'}\n")
created = await wt.create_character( created = await wt.create_character(
@@ -858,9 +871,11 @@ async def _characters_probe(args: ParsedArgs) -> int:
}, },
) )
# Open-world create ACK: degrade, don't hard-index (cumulative cutover # Open-world create ACK: degrade, don't hard-index (cumulative cutover
# foot-gun). A malformed/absent character_id aborts the probe cleanly rather # foot-gun). A non-mapping ACK or an absent/blank character_id aborts the
# than raising a KeyError — the lifecycle needs the id for state + delete. # probe cleanly (exit 20) rather than raising AttributeError/KeyError — the
cid = created.get("character_id") # 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): if not (isinstance(cid, str) and cid):
sys.stderr.write( sys.stderr.write(
f"[session_api_failed] create returned no character_id: {created!r}\n" f"[session_api_failed] create returned no character_id: {created!r}\n"
@@ -868,7 +883,8 @@ async def _characters_probe(args: ParsedArgs) -> int:
return 20 return 20
sys.stdout.write(f"created: {cid} (ttl {created.get('ttl_expires_at')})\n") sys.stdout.write(f"created: {cid} (ttl {created.get('ttl_expires_at')})\n")
state = await wt.get_character_state(client, cid) state = await wt.get_character_state(client, cid)
sys.stdout.write(f"state: pad={state.get('pad')}\n") pad = state.get("pad") if isinstance(state, Mapping) else None
sys.stdout.write(f"state: pad={pad}\n")
await wt.delete_character(client, cid) await wt.delete_character(client, cid)
sys.stdout.write(f"deleted: {cid}\n") sys.stdout.write(f"deleted: {cid}\n")
except wt.SessionApiFailed as exc: except wt.SessionApiFailed as exc:
-28
View File
@@ -6,9 +6,6 @@ Implements docs/contracts/issues/2.contract.md.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
import httpx
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -228,28 +225,3 @@ def endpoint_for_plane(plane: str, base_host: str) -> str:
if plane not in ports: if plane not in ports:
raise ValueError(f"unknown plane: {plane!r} (expected 'memory', 'affect', or 'combined')") raise ValueError(f"unknown plane: {plane!r} (expected 'memory', 'affect', or 'combined')")
return f"http://{base_host}:{ports[plane]}" return f"http://{base_host}:{ports[plane]}"
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)
-53
View File
@@ -5,14 +5,9 @@ Implements docs/contracts/issues/1.contract.md.
from __future__ import annotations from __future__ import annotations
import json
from collections.abc import AsyncIterator
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, NamedTuple from typing import Any, NamedTuple
import httpx
import httpx_sse
class SseId(NamedTuple): class SseId(NamedTuple):
"""Parsed composite SSE wire `id:` per spec §SSE id format.""" """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}") super().__init__(f"cancel failed: status={status}, body={body[:128]!r}")
self.status = status self.status = status
self.body = body 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, BifrostConsumerKeyMissing,
BifrostHandshakeFailed, BifrostHandshakeFailed,
PersonaNotConfigured, PersonaNotConfigured,
SessionApiFailed,
endpoint_for_plane, endpoint_for_plane,
get_session_bifrost,
) )
# The turn path (create / stream / cancel / tools / messages) AND the agents / # The turn path (create / stream / cancel / tools / messages), the agents /
# persona-state reads are served by the worldtree-sdk adapter (`wt.*`), which raises # persona-state reads, AND the admin surface (bifrost inspection + admin-events stream)
# ratatoskr's caller-semantic exceptions (DEC-2). The remaining hand-rolled endpoint # are all served by the worldtree-sdk adapter (`wt.*`), which raises ratatoskr's
# (admin bifrost) stays on the `sessions` / `sse_client` wrappers until slice 6. # 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 ( from ratatoskr.sse_client import (
AdminEvent, AdminEvent,
CancelAlreadyCompleted, CancelAlreadyCompleted,
@@ -64,16 +63,21 @@ from ratatoskr.sse_client import (
SseConnectFailed, SseConnectFailed,
SseConnectionDropped, SseConnectionDropped,
TurnIdFlip, 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: """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 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 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 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" base_url = str(client.base_url) or "http://localhost"
header = client.headers.get("Authorization", "") header = client.headers.get("Authorization", "")
# Case-insensitive scheme + tolerant of extra whitespace, so a valid bearer is # 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) parts = header.split(None, 1)
api_key = parts[1].strip() if len(parts) == 2 and parts[0].lower() == "bearer" else "" api_key = parts[1].strip() if len(parts) == 2 and parts[0].lower() == "bearer" else ""
return wt.build_client( 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) return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
client_factory = request.app.state.client_factory client_factory = request.app.state.client_factory
try: try:
async with client_factory() as client: async with client_factory() as transport:
bstate = await get_session_bifrost(client, session_id, admin_key=admin_key) # slice-6: the SDK's admin.* routes use the client's admin_auth (built with
except SessionApiFailed as exc: # 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( return JSONResponse(
{"error_code": "bifrost_state_unavailable", "status": exc.status}, {"error_code": "bifrost_state_unavailable", "status": exc.status},
status_code=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: 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 client_factory = request.app.state.client_factory
async def gen() -> AsyncIterator[bytes]: 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: 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): if not _admin_event_matches_web(ev, session_id):
continue continue
# Fixed SSE event name so the browser renders EVERY admin type # 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: except asyncio.CancelledError:
raise # browser disconnect — let the generator unwind raise # browser disconnect — let the generator unwind
finally: 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") return StreamingResponse(gen(), media_type="text/event-stream")
+64
View File
@@ -60,6 +60,7 @@ from .sessions import (
Tier3UserIdUnsupported, Tier3UserIdUnsupported,
) )
from .sse_client import ( from .sse_client import (
AdminEvent,
AgentNotAvailable, AgentNotAvailable,
CancelAlreadyCompleted, CancelAlreadyCompleted,
CancelFailed, CancelFailed,
@@ -684,3 +685,66 @@ async def delete_character(
return await client.characters.delete(character_id) return await client.characters.delete(character_id)
except ApiError as exc: except ApiError as exc:
raise translate_error(exc) from 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
+73
View File
@@ -1898,6 +1898,30 @@ class TestWhoami:
assert rc == 0 assert rc == 0
assert "scopes: (none)" in capsys.readouterr().out 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: class TestTier2Probes:
"""--characters + --set-persona-pad one-shot probes (Tier-2: #161 + persona_state-write).""" """--characters + --set-persona-pad one-shot probes (Tier-2: #161 + persona_state-write)."""
@@ -1968,6 +1992,34 @@ class TestTier2Probes:
assert "character models: 123, ok" in out assert "character models: 123, ok" in out
assert "created: c1" 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 @respx.mock
def test_characters_probe_create_missing_id_aborts( def test_characters_probe_create_missing_id_aborts(
self, capsys: pytest.CaptureFixture[str] self, capsys: pytest.CaptureFixture[str]
@@ -1989,6 +2041,27 @@ class TestTier2Probes:
assert "no character_id" in capsys.readouterr().err assert "no character_id" in capsys.readouterr().err
assert del_route.call_count == 0 # aborted before state/delete — nothing to clean 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 @respx.mock
def test_set_persona_probe(self, capsys: pytest.CaptureFixture[str]) -> None: def test_set_persona_probe(self, capsys: pytest.CaptureFixture[str]) -> None:
"""set_persona_probe [happy,tracer]: POST pad to /sessions/{id}/persona_state; 204.""" """set_persona_probe [happy,tracer]: POST pad to /sessions/{id}/persona_state; 204."""
+7 -70
View File
@@ -1,14 +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 pytest
import respx
from ratatoskr.sessions import ( from ratatoskr.sessions import endpoint_for_plane
SessionApiFailed,
endpoint_for_plane,
get_session_bifrost,
)
class TestEndpointForPlane: class TestEndpointForPlane:
@@ -30,65 +29,3 @@ class TestEndpointForPlane:
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001).""" """unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
with pytest.raises(ValueError): with pytest.raises(ValueError):
endpoint_for_plane("persona", "10.100.10.50") endpoint_for_plane("persona", "10.100.10.50")
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
-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"]
+153
View File
@@ -38,6 +38,7 @@ from ratatoskr.sessions import (
Tier3UserIdUnsupported, Tier3UserIdUnsupported,
) )
from ratatoskr.sse_client import ( from ratatoskr.sse_client import (
AdminEvent,
AgentNotAvailable, AgentNotAvailable,
CancelAlreadyCompleted, CancelAlreadyCompleted,
CancelFailed, CancelFailed,
@@ -62,6 +63,7 @@ from ratatoskr.wt import (
get_character_state, get_character_state,
get_me, get_me,
get_persona_state, get_persona_state,
get_session_bifrost,
get_session_messages, get_session_messages,
get_session_tools, get_session_tools,
list_agents, list_agents,
@@ -69,6 +71,7 @@ from ratatoskr.wt import (
list_sessions, list_sessions,
patch_agent, patch_agent,
set_persona_state, set_persona_state,
stream_admin_events,
stream_turn, stream_turn,
translate_error, translate_error,
write_authored_history, write_authored_history,
@@ -1066,3 +1069,153 @@ class TestDeleteCharacter:
with pytest.raises(SessionApiFailed) as ei: with pytest.raises(SessionApiFailed) as ei:
await delete_character(_wtm(fake), "char_x") await delete_character(_wtm(fake), "char_x")
assert ei.value.status == 500 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]] [[package]]
name = "ratatoskr" name = "ratatoskr"
version = "0.21.17" version = "0.21.19"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },