Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de9a5baf45 | |||
| 5bc39a092e | |||
| 4e20030229 |
@@ -274,10 +274,67 @@ re-anchor its coverage-map rows.
|
||||
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.
|
||||
|
||||
@@ -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 /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 /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 |
|
||||
|
||||
@@ -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
@@ -46,30 +46,35 @@ upstream API key stays server-side (INV-003).
|
||||
|
||||
_As of 2026-07-19:_
|
||||
|
||||
**🔨 ACTIVE MIGRATION — worldtree-sdk cutover (issue #20): SLICE-1–4 COMPLETE, slice-5 next.**
|
||||
**🔨 ACTIVE MIGRATION — worldtree-sdk cutover (issue #20): SLICE-1–5 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`. **SLICE-3 (persona + authored-history + first-message)
|
||||
✅ DONE** `ca9a339`+`fc256bb`. **SLICE-4 (agents/Tier-3 + `model`→`role` fold) ✅ DONE** — `c62b4ee` (feat) +
|
||||
`aed9429` (heid-code-review fixups) + `477d98f` (heid-bug-hunt fixups), tags v0.21.13–.15; full House Code
|
||||
Discipline, both heid panels cleared. Suite **475 green**. Slice-4 migrated
|
||||
list_agents/get_persona_state/define/patch/delete onto `client.agents.*` (open-world dicts, errors mapped by
|
||||
route+(status,error_code)), folded `model`→`role` (`LocalAgentEntry.role`, index schema v2), and DELETED the
|
||||
hand-rolled `sessions.list_agents`/`get_persona_state`/`AgentInfo` + `tier3.define/patch/delete_agent`. Full arc
|
||||
+ the two slice-4 foot-guns → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-4-complete.md`.
|
||||
**KEY ADAPTER FACTS (foot-guns, cumulative for slices 5-7):** SDK reads = **open-world dicts** —
|
||||
presenters MUST degrade not crash (slice-4 bug-hunt: hard-indexing `info["x"]` crashes on partial wire; use
|
||||
`.get`/type-guards; `system_prompt: null` → `None.splitlines()`); the SDK **normalizes ANY transport failure to
|
||||
`ConnectFailed(status=0)`** (NOT raw httpx) — every adapter caller must `except ConnectFailed`;
|
||||
**caller-semantic exceptions the adapter raises + a `-m` CLI catches must NOT live in the `-m` module** (the
|
||||
`python -m ratatoskr.tier3` double-module split gave two `Tier3*` class identities → uncaught traceback; fixed
|
||||
by homing them in `sessions.py`; unit tests can't catch this, the live smoke did); `TurnEvent` `turn_id` ABSENT
|
||||
on text/thinking frames; `consumer_key` is BOUND-create-only; envelope parser prefers nested `detail`.
|
||||
**NEXT = slice-5** (characters + me/capabilities/models — remaining consumer reads); then slice-6 (admin:
|
||||
`stream_admin_events` + `get_session_bifrost`, admin_auth), slice-7 (teardown: retire contracts #2/#15, drop
|
||||
`httpx-sse`, MINOR bump per DEC-6 w/ operator approval). Scope: consumer layer ONLY; Bifrost provider untouched.
|
||||
Full design → auto-memory `project_worldtree_sdk_cutover`.
|
||||
✅ 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`.
|
||||
|
||||
**✅ 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
|
||||
@@ -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.15** — slice-4 arc `c62b4ee`→`477d98f` + this snapshot
|
||||
**COMMITTED, not-yet-pushed** (tags v0.21.13–.15; push is the operator's call); slice-1–3 (v0.21.3–.12,
|
||||
`b1fbadd`→`4f92a21`) PUSHED to origin earlier. origin `git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep:
|
||||
**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-1–3 (v0.21.3–.12, `b1fbadd`→`4f92a21`) PUSHED to origin earlier. origin
|
||||
`git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep:
|
||||
`worldtree-sdk==1.0.0`** (gitea PyPI, `[tool.uv.sources]`; `httpx-sse` retires at slice-7). bifrost
|
||||
**`==1.1.4`** / wire v0.7; WT openapi vendored 2.3.0, **conversation-api-spec re-synced to v1.1** (`b4a278c`);
|
||||
**suite 475 green** (slice-4 added the agents-family adapter tests + heid-gate fixup tests, ~offset by the
|
||||
deleted hand-rolled agent/persona tests). Personal WT on **b128**
|
||||
**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
|
||||
@@ -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-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._
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.21.17"
|
||||
version = "0.21.19"
|
||||
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+37
-21
@@ -745,16 +745,29 @@ async def _amain(args: ParsedArgs) -> int:
|
||||
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:
|
||||
"""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', '?')}")
|
||||
# Open-world read: `scopes` may be absent, present-null, or carry non-strings —
|
||||
# `or []` + str() degrades all three (a present-null `.get('scopes', [])` returns
|
||||
# None, not the default), matching the `allowed_roles` hardening below (heid-code-
|
||||
# review slice-5: the contract names this function the degrade-not-crash exemplar).
|
||||
lines.append(f" scopes: {', '.join(str(s) for s in (me.get('scopes') or [])) or '(none)'}")
|
||||
# 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]}")
|
||||
@@ -763,16 +776,17 @@ def _format_whoami(me: Mapping[str, Any], caps: Mapping[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}]"
|
||||
@@ -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)
|
||||
try:
|
||||
models = await wt.list_character_models(client)
|
||||
# Open-world read: `items` may be absent/null (`or []`) AND each entry may be
|
||||
# a non-mapping (`[None]` / `["x"]`) or carry a non-string `name` — guard the
|
||||
# item is a dict and coerce `name` to str so a malformed catalog degrades
|
||||
# rather than crashing (heid-code-review slice-5; element-level completion of
|
||||
# the list-level `or []` guard).
|
||||
# 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 (models.get("items") or [])
|
||||
if isinstance(m, dict)
|
||||
str(m.get("name", "?")) for m in items if isinstance(m, dict)
|
||||
)
|
||||
sys.stdout.write(f"character models: {names or '(none)'}\n")
|
||||
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
|
||||
# foot-gun). A malformed/absent character_id aborts the probe cleanly rather
|
||||
# than raising a KeyError — the lifecycle needs the id for state + delete.
|
||||
cid = created.get("character_id")
|
||||
# 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"
|
||||
@@ -868,7 +883,8 @@ async def _characters_probe(args: ParsedArgs) -> int:
|
||||
return 20
|
||||
sys.stdout.write(f"created: {cid} (ttl {created.get('ttl_expires_at')})\n")
|
||||
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)
|
||||
sys.stdout.write(f"deleted: {cid}\n")
|
||||
except wt.SessionApiFailed as exc:
|
||||
|
||||
@@ -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,28 +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_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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ from .sessions import (
|
||||
Tier3UserIdUnsupported,
|
||||
)
|
||||
from .sse_client import (
|
||||
AdminEvent,
|
||||
AgentNotAvailable,
|
||||
CancelAlreadyCompleted,
|
||||
CancelFailed,
|
||||
@@ -684,3 +685,66 @@ async def delete_character(
|
||||
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
|
||||
|
||||
@@ -1898,6 +1898,30 @@ class TestWhoami:
|
||||
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)."""
|
||||
@@ -1968,6 +1992,34 @@ class TestTier2Probes:
|
||||
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]
|
||||
@@ -1989,6 +2041,27 @@ class TestTier2Probes:
|
||||
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
-70
@@ -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 respx
|
||||
|
||||
from ratatoskr.sessions import (
|
||||
SessionApiFailed,
|
||||
endpoint_for_plane,
|
||||
get_session_bifrost,
|
||||
)
|
||||
from ratatoskr.sessions import endpoint_for_plane
|
||||
|
||||
|
||||
class TestEndpointForPlane:
|
||||
@@ -30,65 +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 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
|
||||
|
||||
@@ -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"]
|
||||
@@ -38,6 +38,7 @@ from ratatoskr.sessions import (
|
||||
Tier3UserIdUnsupported,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
AdminEvent,
|
||||
AgentNotAvailable,
|
||||
CancelAlreadyCompleted,
|
||||
CancelFailed,
|
||||
@@ -62,6 +63,7 @@ from ratatoskr.wt import (
|
||||
get_character_state,
|
||||
get_me,
|
||||
get_persona_state,
|
||||
get_session_bifrost,
|
||||
get_session_messages,
|
||||
get_session_tools,
|
||||
list_agents,
|
||||
@@ -69,6 +71,7 @@ from ratatoskr.wt import (
|
||||
list_sessions,
|
||||
patch_agent,
|
||||
set_persona_state,
|
||||
stream_admin_events,
|
||||
stream_turn,
|
||||
translate_error,
|
||||
write_authored_history,
|
||||
@@ -1066,3 +1069,153 @@ class TestDeleteCharacter:
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user