Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0a9d5f5e4 | |||
| fc1e1487c7 | |||
| af07a2329a |
@@ -313,3 +313,62 @@ TESTS:
|
||||
not_bound_404 [error]: 404 session_not_bifrost_bound → SessionApiFailed(status=404)
|
||||
empty_admin_key [adversarial]: admin_key="" → AssertionError; no HTTP issued
|
||||
```
|
||||
|
||||
## Amendment 2026-07-01 — Tier-2: transient characters + persona-state write (v1 coverage-audit)
|
||||
|
||||
The last in-scope client I/O points. Transient-character CRUD (#161) surfaced
|
||||
via a `--characters` one-shot lifecycle probe; persona-state write surfaced via
|
||||
`--set-persona-pad "p,a,d"` (requires `--session`). All mirror the existing
|
||||
wrappers: parsed dict verbatim (or None on 204), any off-status → SessionApiFailed.
|
||||
**Note:** `set_persona_state`'s request body is FREEFORM — the frozen OpenAPI 2.2.0
|
||||
declares no request schema and the prose spec documents only the GET counterpart,
|
||||
so the caller supplies the snapshot shape (`--set-persona-pad` sends `{pad:[…]}`).
|
||||
|
||||
```contract
|
||||
FN list_character_models(client) -> dict[str, Any]
|
||||
BRIEF: GET /models/available-for-characters (character.read). Returns {items:[{name, description, thinking}]}. Non-200 → SessionApiFailed.
|
||||
PRE: [PRE-001 hard] client is not None
|
||||
POST: [POST-001 return_value] on 200 returns resp.json() unmodified
|
||||
STEPS:
|
||||
1. [sequential, prescriptive] resp = await client.get("/models/available-for-characters"); IF 200 RETURN resp.json(); ELSE RAISE SessionApiFailed
|
||||
TESTS:
|
||||
list_models [happy,tracer]: 200 {items:[{name:"fast"}]} → dict verbatim
|
||||
|
||||
FN create_character(client, character: dict, *, state: dict | None = None) -> dict[str, Any]
|
||||
BRIEF: POST /characters (character.write). Body {character, state}. Returns 201 {character_id, ttl_expires_at}; non-201 → SessionApiFailed.
|
||||
PRE: [PRE-001 hard] client is not None; [PRE-002 hard] character is a non-empty dict
|
||||
POST: [POST-001 return_value] on 201 returns resp.json(); [POST-002 side_effect] outbound body == {"character": <arg>, "state": <state|null>}
|
||||
STEPS:
|
||||
1. [sequential, prescriptive] resp = await client.post("/characters", json={"character": character, "state": state}); IF 201 RETURN resp.json(); ELSE RAISE SessionApiFailed
|
||||
TESTS:
|
||||
create [happy]: 201 → {character_id}; body is {character, state:null}
|
||||
create_403 [error]: 403 auth_scope_denied → SessionApiFailed(403)
|
||||
|
||||
FN get_character_state(client, character_id: str) -> dict[str, Any]
|
||||
BRIEF: GET /characters/{id}/state (character.read). Live PAD/emotions snapshot; refreshes TTL. Non-200 → SessionApiFailed.
|
||||
PRE: [PRE-001 hard] client not None; [PRE-002 hard] character_id non-empty str
|
||||
POST: [POST-001 return_value] on 200 returns resp.json()
|
||||
STEPS:
|
||||
1. [sequential, prescriptive] resp = await client.get(f"/characters/{character_id}/state"); IF 200 RETURN resp.json(); ELSE RAISE SessionApiFailed
|
||||
TESTS:
|
||||
get_state [happy]: 200 {pad:[...]} → dict verbatim
|
||||
|
||||
FN delete_character(client, character_id: str) -> None
|
||||
BRIEF: DELETE /characters/{id} (character.write). 200/204 → None; other → SessionApiFailed.
|
||||
PRE: [PRE-001 hard] client not None; [PRE-002 hard] character_id non-empty str
|
||||
POST: [POST-001 return_value] on 200/204 returns None
|
||||
STEPS:
|
||||
1. [sequential, prescriptive] resp = await client.delete(f"/characters/{character_id}"); IF status in (200,204) RETURN None; ELSE RAISE SessionApiFailed
|
||||
TESTS:
|
||||
delete [happy]: 204 → None
|
||||
|
||||
FN set_persona_state(client, session_id: str, snapshot: dict) -> None
|
||||
BRIEF: POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection). Request body is the FREEFORM snapshot (caller-supplied; unpinned in the frozen surface). 204 → None; other → SessionApiFailed.
|
||||
PRE: [PRE-001 hard] client not None; [PRE-002 hard] session_id non-empty str; [PRE-003 hard] snapshot is a dict
|
||||
POST: [POST-001 return_value] on 204 returns None; [POST-002 side_effect] outbound body == snapshot verbatim
|
||||
STEPS:
|
||||
1. [sequential, prescriptive] resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot); IF 204 RETURN None; ELSE RAISE SessionApiFailed
|
||||
TESTS:
|
||||
happy [happy]: 204 → None; body == {"pad":[...]} verbatim
|
||||
non_204 [error]: 422 → SessionApiFailed(422)
|
||||
```
|
||||
|
||||
+28
-16
@@ -48,7 +48,7 @@ resolved (§ Surface 1, scope-resolution table).
|
||||
|
||||
| Surface | Points | ✅ covered-live | ⬜ gap (in-scope) | 🚫 excluded-by-design |
|
||||
|---|---|---|---|---|
|
||||
| REST (OpenAPI 2.2.0, path groups) | 40 | 12 | 6 | 22 |
|
||||
| REST (OpenAPI 2.2.0, path groups) | 40 | 17 | 0 | 23 |
|
||||
| SSE events | 11 | 11 | 0 | 0 |
|
||||
| Bifrost provider planes | 8 verbs | 8 | 0 | (10 gated verbs deferred) |
|
||||
|
||||
@@ -80,6 +80,11 @@ sub-gap).
|
||||
| `GET /sessions/{id}/tools` | ✅ | `sessions.py:411` `get_session_tools` → `tui.py` `_hydrate_session_tools` | owner-scoped tool inventory in the TUI Tools pane (#183) |
|
||||
| `GET /admin/sessions/{id}/bifrost` | ✅ | `sessions.py:428` `get_session_bifrost` → `tui.py` `_hydrate_bifrost_state` | admin-scoped BifrostState pane (#176); admin key (`RATATOSKR_ADMIN_API_KEY`); live-auth-proven |
|
||||
| `GET /admin/events` (SSE) | ✅ | `sse_client.py` `stream_admin_events` → `tui.py` `_stream_admin_events` | admin lifecycle SSE stream (#11), session-filtered AdminEvents pane; admin key; live-auth-proven |
|
||||
| `GET /models/available-for-characters` | ✅ | `sessions.py` `list_character_models` → `cli.py` `--characters` | character-capable model profiles (#161) |
|
||||
| `POST /characters` | ✅ | `sessions.py` `create_character` → `cli.py` `--characters` | create transient character (#161) |
|
||||
| `GET /characters/{id}/state` | ✅ | `sessions.py` `get_character_state` → `cli.py` `--characters` | live character PAD/emotions (#161) |
|
||||
| `DELETE /characters/{id}` | ✅ | `sessions.py` `delete_character` → `cli.py` `--characters` | remove transient character (#161) |
|
||||
| `POST /sessions/{id}/persona_state` | ✅ | `sessions.py` `set_persona_state` → `cli.py` `--set-persona-pad` | persona-state write / affect injection (freeform body — unpinned in the frozen surface) |
|
||||
|
||||
**Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method
|
||||
on the same path is an unwired frontier item — see frontier Tier 1):
|
||||
@@ -91,21 +96,20 @@ on the same path is an unwired frontier item — see frontier Tier 1):
|
||||
- `GET /agents/{id}` — consumer-agent lookup (`GET /agents/<owner>:<name>` with
|
||||
the owner key) is **manual-curl-only**, not in code.
|
||||
|
||||
### In-scope gaps — the convergence frontier (debug-observability path)
|
||||
### In-scope gaps — CONVERGED (zero remaining, 2026-07-01)
|
||||
|
||||
**Tier 1 — the debug-observability core (design-brief'd for v1, unbuilt):**
|
||||
**Every in-scope REST I/O point is now covered.** The frontier that opened this
|
||||
audit (the design-brief §5 observability panes + the presenter-wiring sub-gaps +
|
||||
the Tier-2 tail) is fully closed:
|
||||
|
||||
| Endpoint | Status | Why in-scope |
|
||||
|---|---|---|
|
||||
| `GET /admin/sessions/{id}/tools` | ⬜ | admin variant of the Tools inventory — **covered-by-alternative** via the owner-scoped `GET /sessions/{id}/tools` (✅); this admin variant remains a gap only for cross-user operator debug |
|
||||
| (`GET /sessions` picker · resume) | ⬜ | sub-gaps above — presenter-wiring only, wrappers exist |
|
||||
- Session picker + SSE-resume — wired (`v0.18.5`–`.7`).
|
||||
- Persona · Tools · BifrostState · AdminEvents panes — all built + live (`v0.18.x`–`v0.19.0`).
|
||||
- Transient-characters CRUD + persona-state write — consumed via `--characters` /
|
||||
`--set-persona-pad` (`v0.19.1`).
|
||||
|
||||
**Tier 2 — rounds out I/O coverage under A (postdates the design-brief):**
|
||||
|
||||
| Endpoint | Status | Why in-scope |
|
||||
|---|---|---|
|
||||
| `POST /sessions/{id}/persona_state` (write) | ⬜ | affect-injection is debug-relevant; pairs with our provider affect plane |
|
||||
| `POST /characters` · `DELETE /characters/{id}` · `GET /characters/{id}/state` · `GET /models/available-for-characters` | ⬜ | transient-characters (Echo) is a session-creation **routing path** a debug client should be able to drive a turn through |
|
||||
The only remaining not-consumed in-scope method is `GET /agents/{id}` (consumer-
|
||||
agent lookup, manual-curl-only) — a sub-method on an already-✅ path group, not a
|
||||
path-group gap. Everything else is covered or excluded-by-design below.
|
||||
|
||||
### Excluded by design — the design-brief negative clauses
|
||||
|
||||
@@ -115,6 +119,7 @@ on the same path is an unwired frontier item — see frontier Tier 1):
|
||||
| `GET /sessions/{id}/messages` (history) | 🚫 | §6: single-session live transcript, no history fetch |
|
||||
| `GET /sessions/{id}` | 🚫 | session detail — identity is footer-visible, no detail view |
|
||||
| `GET /sessions/{id}/tool-events` | 🚫 | §5: tool calls observed **inline from SSE** `tool_start`/`tool_result`; persisted-events endpoint is opt-in only |
|
||||
| `GET /admin/sessions/{id}/tools` | 🚫 | **covered-by-alternative** — the owner-scoped `GET /sessions/{id}/tools` (✅) serves the Tools inventory; this admin variant is only for cross-user operator debug, out of the single-session focus (§6) |
|
||||
| `GET/POST /admin/keys` · `DELETE/POST /admin/keys/{id}` · `POST /admin/keys/{id}/rotate` · `DELETE/POST /admin/keys/bulk` · `POST /admin/keys/bulk/rotate` | 🚫 | §6: **NOT a Worldtree-admin tool** (key mgmt) |
|
||||
| `POST /admin/sessions/{id}/retire` | 🚫 | admin session mutation |
|
||||
| `POST /admin/persona/{archive,erase}` | 🚫 | admin persona GDPR ops (new in b2) |
|
||||
@@ -199,7 +204,13 @@ starts exercising them.
|
||||
|
||||
---
|
||||
|
||||
## Convergence frontier (the v1 to-do)
|
||||
## Convergence frontier (the v1 to-do) — CLOSED 2026-07-01
|
||||
|
||||
**Every in-scope I/O point is covered.** The frontier is empty: REST 17/40 ✅
|
||||
with **zero in-scope gaps** (the other 23 REST path-groups are excluded-by-design),
|
||||
SSE 11/11, Bifrost provider planes 8/8. v1 convergence (per scope A: "every
|
||||
frozen I/O point classified, zero unaccounted") is **met** — ratatoskr cuts v1
|
||||
when Worldtree tags 1.0. The arc, for the record:
|
||||
|
||||
**Tier 1 — debug-observability core:**
|
||||
|
||||
@@ -218,8 +229,9 @@ starts exercising them.
|
||||
|
||||
5. ✅ **DONE** — `GET /sessions/{id}/tools` (`v0.18.9`, owner-scoped tool inventory
|
||||
in the TUI Tools pane).
|
||||
6. **Transient-characters routing** (4 endpoints) + **`POST /sessions/{id}/persona_state`**
|
||||
— the only remaining in-scope client I/O points.
|
||||
6. ✅ **DONE** — Transient-characters CRUD (4 endpoints) + `POST /sessions/{id}/persona_state`
|
||||
(`v0.19.1`, `--characters` + `--set-persona-pad` one-shot probes). The last
|
||||
in-scope client I/O points.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+44
-52
@@ -1,6 +1,6 @@
|
||||
# Persistent memory — ratatoskr
|
||||
|
||||
_Last updated: 2026-06-30_
|
||||
_Last updated: 2026-07-01_
|
||||
|
||||
This file captures durable intent and supporting evidence (goals, decisions,
|
||||
foot-gun warnings, in-flight state) across context resets. Read it at session
|
||||
@@ -39,64 +39,54 @@ upstream API key stays server-side (INV-003).
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-06-20:_
|
||||
_As of 2026-07-01:_
|
||||
|
||||
**#17 and #18 BOTH CLOSED — the composite both-plane binding is fully proven.** #18 shipped
|
||||
`v0.18.0` (`359dbb1`): D2 (PAD read-endpoint, `v0.17.14`) renders live PAD in the web pane from our
|
||||
`:8390` store; D1 (composite endpoint, `v0.17.16` `7f4ceaa`) — `build_combined_provider_app`
|
||||
(`provider/combined.py`) on `:8392` wraps bifrost's public `build_combined_app` over BOTH stores +
|
||||
the shared affect read route; one bound session drives memory.* AND affect.* through ONE endpoint,
|
||||
op-feed deriving plane per path. Suite **503 green**. **#17 closed in the tracker 2026-06-20**
|
||||
(shipped `v0.17.8`–`.13` + the `v0.17.17` op-feed field fix).
|
||||
**THE v1 COVERAGE-AUDIT HAS CONVERGED.** The audit that ran this session (2026-06-30 → 07-01)
|
||||
reached its scope-A done-definition: **every frozen Worldtree v1 I/O point is classified — covered
|
||||
or excluded-with-rationale, zero unaccounted.** Coverage: **REST 17/40 ✅ with ZERO in-scope gaps**
|
||||
(23 REST path-groups excluded-by-design), **SSE 11/11 ✅**, **Bifrost provider planes 8/8 ✅**
|
||||
(live-proven). The living ledger is `docs/coverage-map.md`. **v1 cuts when Worldtree tags 1.0**
|
||||
(ratatoskr v1 = full Worldtree I/O coverage; the target is the coverage map, not a feature list).
|
||||
Only not-consumed in-scope *method*: `GET /agents/{id}` (consumer-agent lookup, manual-curl-only,
|
||||
on an already-✅ path group).
|
||||
|
||||
**#18's final leg — the Worldtree-DRIVEN composite turn — RAN and is PROVEN end-to-end + persisted
|
||||
(2026-06-20).** infra-ops added `10.100.10.50:8392` to the personal WT's (`:8081`)
|
||||
`BIFROST_CLIENT_ALLOWED_HOSTS` (thread `01KVHWJGTT…`), unblocking the smoke. A real WT turn through
|
||||
`:8392` (session `b83a66b6`, agent `ratatoskr:sindra`, fresh end_user `resmoke-choco-1`) drove the
|
||||
FULL both-plane lifecycle on ONE endpoint, caps-routed by path: `handshake`
|
||||
(`caps_granted=[memory, affect]`) → `affect.fetch` + `memory.search` (reads) → `affect.emit`
|
||||
(`stored:true`, PAD row in `affect_snapshots`) → `memory.upsert_many` (`upserted:1`, chunk
|
||||
`2df1b79de761b948` in `memory_chunks`). Both writes verified directly in our SQLite. The
|
||||
model-backend outage that blocked the first attempt (both agents' models `model_unavailable`) was
|
||||
operator-fixed mid-session, then the resmoke completed clean. **No open legs remain on the composite.**
|
||||
**The debug-observability core is COMPLETE** (published as the `v0.19.0` milestone): all four
|
||||
observability panes built + live + consuming their real endpoints — **Persona** (`GET /agents/{id}/persona_state`),
|
||||
**Tools** (`GET /sessions/{id}/tools`), **BifrostState** (`GET /admin/sessions/{id}/bifrost`),
|
||||
**AdminEvents** (`GET /admin/events` SSE, session-filtered). **#11 is closed-by-build** (AdminEvents
|
||||
shipped `v0.18.11`; its long-standing "blocked on `admin.events.read`" status was STALE — the admin
|
||||
key already carries the scope). The whole slice arc: SSE-resume (`v0.18.5/.6`) → session-picker
|
||||
(`v0.18.7`) → `--whoami` me/capabilities (`v0.18.8`) → Tools (`v0.18.9`) → BifrostState (`v0.18.10`)
|
||||
→ AdminEvents (`v0.18.11`) → **v0.19.0 milestone** → Tier-2 characters+persona-write (`v0.19.1`).
|
||||
|
||||
**bifrost repinned 0.8.0 → 0.10.0** (floor, `provider` extra). 0.10.0 made `affect.fetch`
|
||||
MANDATORY (strong-or-absent: `_supports_affect_plane` requires `affect_supported`+`emit`+`fetch`,
|
||||
gating EVERY affect op incl. emit) — so the repin FORCED `affect.fetch` (`v0.17.15`, conformed
|
||||
to bifrost's reference `InMemoryAffectStore.fetch` → `{found, snapshot?}`) or our shipped affect
|
||||
plane would 400. The composite's affect cap depends on it.
|
||||
**Standing substrate pins:** Worldtree spec **v1.0.0b2** (`5810a26`) — ratatoskr now vendors the
|
||||
FROZEN machine-readable `conversation-api-openapi.json` (2.2.0) + `conversation-api-sse-events.schema.json`,
|
||||
pinned in `.corviduo-canonicals.toml` + drift-gated by `canonical_drift.py` (the prose markdown is a
|
||||
`tolerate_drift` reference). **bifrost `==1.0.0` / wire v0.6 STABLE/FROZEN.** Suite **573 green**.
|
||||
|
||||
**OPERATOR SESSION STATE — `:8390`/`:8391`/`:8765` shells are PRE-#18 code (foot-gun).** web `:8765`
|
||||
+ affect `:8390` + memory `:8391` are prior-session background shells on OLD code. The **`:8392`
|
||||
composite provider is RUNNING on NEW code** (`ratatoskr-combined-provider`, pid started Jun19,
|
||||
`RATATOSKR_OPFEED_PATH=/tmp/ratatoskr-combined-opfeed.jsonl`, shared `affect.db`/`memory.db`) — now
|
||||
`:8392`-allowlisted and WT-turn-proven. To see the full web stack on new code, RESTART `:8390`/`:8765`
|
||||
from current code (D2 web needs `RATATOSKR_AFFECT_READ_URL`). Consumer/owner key = `wt_live_d81b…`
|
||||
(`~/.config/ratatoskr/provider.env`, mode 600, rotate via infra-ops); providers SQLite + sqlite-vec,
|
||||
`memory.db`/`affect.db` at repo root (live sindra PAD: vuong + the `resmoke-choco-1` smoke fixture).
|
||||
**Keys (env-only, mode 600; rotate via infra-ops):** consumer/Heimdall key at
|
||||
`~/.config/ratatoskr/provider.env`; **admin key `RATATOSKR_ADMIN_API_KEY`** (Heimdall user
|
||||
`ratatoskr-readonly`, tier `readonly-admin`, **7 read scopes** incl. `admin.sessions.read` +
|
||||
`admin.events.read` — verified 2026-07-01, **personal `:8081` only**) in `env.sh` — powers the
|
||||
BifrostState + AdminEvents admin panes. Heimdall keys are PER-INSTANCE (a personal-minted key 401s
|
||||
on demo).
|
||||
|
||||
**Tier-3 memory PROVEN end-to-end** (earlier this session): `ratatoskr:terse-probe`
|
||||
cold-recalled a seeded user fact (scope_any → 1 hit @ cosine 0.6994), and the verbose
|
||||
`sindra-probe` too under #296 Stage 2 (v0.36.0). The #296 extraction-quality arc closed
|
||||
(Stage 1 v0.35.19 gate + Stage 2 v0.36.0 user-only extraction at worldtree-codex; hard-
|
||||
linguistic layer → Worldtree #305). `:8081` runs v0.36.0.
|
||||
**Provider identity (the second, still-live role):** ratatoskr owns BOTH ends of the Bifrost
|
||||
round-trip — the combined `:8392` provider fronts the memory (`:8391`) + affect (`:8390`) stores
|
||||
(SQLite + sqlite-vec, `memory.db`/`affect.db` at repo root); consumer/owner key `wt_live_d81b…`.
|
||||
`ratatoskr:sindra` is the owner-scoped Tier-3 agent (invisible to `GET /agents`; check via
|
||||
`GET /agents/<owner>:<name>` with the owner key). This provider surface is settled/converged — no
|
||||
in-flight work on it.
|
||||
|
||||
**Sindra:** `ratatoskr:sindra`, `thoughtful-character` role → `mistral-small-4-reasoning`
|
||||
(DELETE+redefined on v0.35.16; `memory:{}` block trips the promotion gate). Owner-scoped
|
||||
(separate `consumer_agents` table) — invisible to `GET /agents`; check via
|
||||
`GET /agents/<owner>:<name>` with the owner key.
|
||||
**Open / deferred (nothing blocking):** #10 (subject-migration watch on Worldtree #196). Design
|
||||
note: bare-TUI + 0-sessions → error (§4-clause-consistent; the friendlier auto-fall-to-new is
|
||||
deferred, operator-preference). heid-code-review was run on the b1 resume slice only (panel: zero
|
||||
findings, cross-model-verified); b2 + the later slices were offered but not reviewed. `graphify-out/`
|
||||
runs dirty (auto-regen, not chased — never stage it). Contract-skip was invoked for the low-effort
|
||||
GET wrappers + `stream_admin_events`, but contract #2 / #1 / #6 were amended to stay canonical.
|
||||
|
||||
**Standing:** Worldtree spec pin v0.35.16 (`f1b59f8`); **bifrost 0.10.0 / wire v0.6**
|
||||
(`scope_all`+`scope_any`). Heimdall key env-only at `~/.config/ratatoskr/provider.env` (mode
|
||||
600); rotate via infra-ops. `graphify-out/` runs dirty (auto-regen, not chased). **Open issues:
|
||||
#11** (AdminEvents pane — the next-reachable Worldtree-I/O coverage gap, blocked on an
|
||||
`admin.events.read` scope request) and **#10** (subject-migration watch on Worldtree #196) — both
|
||||
deferred. **#17 + #18 CLOSED.** Codex-first pilot dormant. No in-flight implementation work — repo
|
||||
is at a converged checkpoint; v1 advances when Worldtree does (v1 = full Worldtree I/O coverage).
|
||||
|
||||
Branch: `main` (tag `v0.18.0`, `359dbb1`) — **in sync with `origin/main`** (the full #17+#18 arc is
|
||||
pushed). This `/snapshot` commit will sit one ahead of origin until pushed (push is the operator's
|
||||
call). Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
|
||||
Branch: `main` — **in sync with `origin/main`** at **`v0.19.1`** (`af07a23`); the whole session's arc
|
||||
is pushed. Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
|
||||
|
||||
## Recent decisions
|
||||
|
||||
@@ -163,6 +153,8 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
|
||||
- `[2026-07-01]` **AdminEvents pane SHIPPED (`v0.18.11`) — `GET /admin/events` SSE in a new TUI pane; #11 closed-by-build; Tier 1 (debug-observability core) COMPLETE.** `stream_admin_events(client, *, admin_key, last_event_id=None)` (sse_client.py) — a NEW long-lived SSE consumer for the admin lifecycle broadcast (envelope `{id,type,timestamp,data}`, 17-event v0 vocab), admin-scoped (`admin.events.read`, bearer-override), Last-Event-ID resume; non-200→SseConnectFailed, mid-drop→SseConnectionDropped; new `AdminEvent` dataclass (distinct from the turn `Event` union). New "AdminEvents" TabPane + `_format_admin_event` + `_admin_event_matches` (design-brief §6 filter: active-session events + non-heartbeat `system.*`) + `_stream_admin_events` long-lived best-effort worker (unconditional on_mount, cancelled on app exit; self-labels "not configured"/"unavailable"/"stream ended"). Reuses the admin key from the BifrostState slice. **Contract-SKIPPED** for `stream_admin_events` (out of contract #1's turn-SSE scope; spec § Admin Event Stream is the reference; well-TDD'd). TDD: 4 sse_client tests (multi-event+bearer-override, Last-Event-ID header, 403, malformed-skip) + 5 tui (format, filter, worker success/no-key/403). Suite **561 green**; my code ruff-clean (pre-existing tui.py ruff debt untouched, incl. the dead `RichText` import in `_hydrate_persona`). **LIVE-AUTH-PROVEN**: `GET /admin/events` on :8081 → HTTP 200 under the admin key (connected + streamed, idle in the 4s window — no 401/403). **Coverage: REST 12/40 ✅. Tier 1 admin/debug-observability core COMPLETE** (Persona · Tools · BifrostState · AdminEvents). AdminEvents work landed as patch `v0.18.11`; then **`v0.19.0` MINOR cut (operator-approved 2026-07-01)** publishing the milestone: **the debug-observability core is complete** (Persona · Tools · BifrostState · AdminEvents all built + consuming real endpoints — the design-brief's headline deliverable). Pre-1.0 minor = release-note-worthy (no downstream althing push needed pre-1.0); lightweight tag per the SemVer mechanics (annotated reserved for major cuts). Remaining in-scope client I/O: only Tier-2 (transient-characters routing + `POST /sessions/{id}/persona_state`).
|
||||
|
||||
- `[2026-07-01]` **Tier-2 SHIPPED (`v0.19.1`) — transient-characters CRUD + persona-state write; the v1 coverage-audit CONVERGES (zero in-scope gaps).** 5 wrappers in sessions.py: `list_character_models`/`create_character`/`get_character_state`/`delete_character` (#161, `character.read`/`.write` scopes) + `set_persona_state` (`POST /sessions/{id}/persona_state` — **FREEFORM body: unpinned in the frozen OpenAPI 2.2.0 + absent from the prose spec**, so the caller supplies the snapshot shape). Two one-shot CLI probes (mirror `--whoami`): `--characters` (models→create→get-state→delete lifecycle report) + `--set-persona-pad "p,a,d"` (requires `--session`; POSTs `{pad:[…]}`). New `ParsedArgs.characters`/`set_persona_pad` + probe-mode mutual-exclusion validation + `_probe_client` helper. Contract #2 amended (5 FNs, validated OK) + TDD (7 wrapper respx + 5 cli tests). Suite **573 green**; touched code ruff-clean. NOT live-proven (character scopes + the persona-write body shape unverified — the probes degrade gracefully on 403/422). **THE v1 COVERAGE-AUDIT HAS CONVERGED: REST 17/40 ✅ with ZERO in-scope gaps** (23 REST path-groups excluded-by-design + rationale), SSE 11/11, Bifrost provider planes 8/8. Scope-A "done" (every frozen I/O point classified, zero unaccounted) is **MET** — ratatoskr cuts v1 when Worldtree tags 1.0. Only not-consumed in-scope sub-method: `GET /agents/{id}` (consumer-agent lookup, manual-curl-only, on an already-✅ path group). Patch bump (Tier-2 tail; `v0.19.0` already published the core-complete milestone — a 2nd minor would be cadence-too-fast).
|
||||
|
||||
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
|
||||
|
||||
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+108
-3
@@ -22,10 +22,15 @@ from ratatoskr.sessions import (
|
||||
BifrostConsumerKeyMissing,
|
||||
BifrostHandshakeFailed,
|
||||
SessionApiFailed,
|
||||
create_character,
|
||||
create_session,
|
||||
delete_character,
|
||||
endpoint_for_plane,
|
||||
get_capabilities,
|
||||
get_character_state,
|
||||
get_me,
|
||||
list_character_models,
|
||||
set_persona_state,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
AffectUpdate,
|
||||
@@ -106,6 +111,11 @@ class ParsedArgs:
|
||||
# admin-scoped inspection reads (BifrostState pane, GET /admin/sessions/…).
|
||||
# None when unset — the BifrostState pane then shows "admin key not configured".
|
||||
admin_key: str | None = None
|
||||
# Tier-2 one-shot probes (like --whoami). --characters runs the transient-
|
||||
# character CRUD lifecycle; --set-persona-pad "p,a,d" (with --session) writes
|
||||
# a session's persona state (affect injection).
|
||||
characters: bool = False
|
||||
set_persona_pad: str | None = None
|
||||
|
||||
|
||||
class _ArgparseError(Exception):
|
||||
@@ -132,6 +142,8 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
parser.add_argument("--raw", action="store_true")
|
||||
parser.add_argument("--whoami", action="store_true")
|
||||
parser.add_argument("--admin-key", dest="admin_key")
|
||||
parser.add_argument("--characters", action="store_true")
|
||||
parser.add_argument("--set-persona-pad", dest="set_persona_pad", default=None)
|
||||
# Issue #5: required for per-end-user agents (lofn etc.); optional otherwise (mimir).
|
||||
parser.add_argument("--end-user-id", dest="end_user_id", default=None)
|
||||
# Issue #17: bind the created session to our own Bifrost provider plane.
|
||||
@@ -152,12 +164,23 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
# Issue #5 INV-001: --end-user-id, if passed, MUST be non-empty (mirrors --send).
|
||||
if ns.end_user_id is not None and not ns.end_user_id:
|
||||
raise UsageError("--end-user-id must be non-empty when passed")
|
||||
if ns.whoami:
|
||||
# Standalone boot-time probe (GET /me + /capabilities): opens no session.
|
||||
if sum([ns.whoami, ns.characters, bool(ns.set_persona_pad)]) > 1:
|
||||
raise UsageError("--whoami / --characters / --set-persona-pad are mutually exclusive")
|
||||
if ns.whoami or ns.characters:
|
||||
# Standalone one-shot probes: open no session.
|
||||
if ns.send is not None or ns.session or ns.new or ns.agent:
|
||||
raise UsageError(
|
||||
"--whoami is a standalone probe (no --send/--session/--new/--agent)"
|
||||
"--whoami / --characters are standalone probes "
|
||||
"(no --send/--session/--new/--agent)"
|
||||
)
|
||||
elif ns.set_persona_pad is not None:
|
||||
# Session-scoped write probe: needs a target session, nothing else.
|
||||
if not ns.set_persona_pad:
|
||||
raise UsageError("--set-persona-pad must be non-empty (e.g. '0.4,0.1,-0.2')")
|
||||
if not ns.session:
|
||||
raise UsageError("--set-persona-pad requires --session <id>")
|
||||
if ns.send is not None or ns.new or ns.agent:
|
||||
raise UsageError("--set-persona-pad takes only --session")
|
||||
else:
|
||||
if ns.session and ns.new:
|
||||
raise UsageError("--session and --new are mutually exclusive")
|
||||
@@ -231,6 +254,8 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
consumer_key=consumer_key,
|
||||
whoami=ns.whoami,
|
||||
admin_key=admin_key,
|
||||
characters=ns.characters,
|
||||
set_persona_pad=ns.set_persona_pad,
|
||||
)
|
||||
|
||||
|
||||
@@ -631,6 +656,82 @@ async def _whoami(args: ParsedArgs) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _probe_client(args: ParsedArgs) -> httpx.AsyncClient:
|
||||
"""AsyncClient for the one-shot probes (--whoami / --characters / --set-persona-pad)."""
|
||||
return httpx.AsyncClient(
|
||||
base_url=args.server_url,
|
||||
headers={"Authorization": f"Bearer {args.api_key}", "User-Agent": USER_AGENT},
|
||||
timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=10.0),
|
||||
)
|
||||
|
||||
|
||||
async def _characters_probe(args: ParsedArgs) -> int:
|
||||
"""--characters one-shot: exercise the transient-character CRUD lifecycle
|
||||
(models → create → get-state → delete), print a report, exit. A reference-
|
||||
consumer smoke of the #161 character surface (needs character.read/write)."""
|
||||
assert isinstance(args, ParsedArgs)
|
||||
async with _probe_client(args) as client:
|
||||
try:
|
||||
models = await list_character_models(client)
|
||||
names = ", ".join(m.get("name", "?") for m in models.get("items", []))
|
||||
sys.stdout.write(f"character models: {names or '(none)'}\n")
|
||||
created = await create_character(
|
||||
client,
|
||||
{
|
||||
"schema_version": "1",
|
||||
"name": "ratatoskr-probe",
|
||||
"ocean": {
|
||||
"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.0,
|
||||
"agreeableness": 0.5, "neuroticism": 0.5,
|
||||
},
|
||||
"description": "ratatoskr --characters lifecycle probe",
|
||||
"narrative": "A throwaway probe character.",
|
||||
"voice_profile_block": "plain",
|
||||
},
|
||||
)
|
||||
cid = created["character_id"]
|
||||
sys.stdout.write(f"created: {cid} (ttl {created.get('ttl_expires_at')})\n")
|
||||
state = await get_character_state(client, cid)
|
||||
sys.stdout.write(f"state: pad={state.get('pad')}\n")
|
||||
await delete_character(client, cid)
|
||||
sys.stdout.write(f"deleted: {cid}\n")
|
||||
except SessionApiFailed as exc:
|
||||
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
|
||||
return 20
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
||||
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
||||
return 21
|
||||
return 0
|
||||
|
||||
|
||||
async def _set_persona_probe(args: ParsedArgs) -> int:
|
||||
"""--set-persona-pad one-shot: POST a PAD to /sessions/{id}/persona_state
|
||||
(affect injection), print the result, exit. Requires --session."""
|
||||
assert isinstance(args, ParsedArgs)
|
||||
assert args.session_id is not None and args.set_persona_pad is not None
|
||||
try:
|
||||
pad = [float(x) for x in args.set_persona_pad.split(",")]
|
||||
except ValueError:
|
||||
sys.stderr.write(
|
||||
"[usage_error] --set-persona-pad must be comma-separated floats "
|
||||
"(e.g. '0.4,0.1,-0.2')\n"
|
||||
)
|
||||
return 10
|
||||
async with _probe_client(args) as client:
|
||||
try:
|
||||
await set_persona_state(client, args.session_id, {"pad": pad})
|
||||
except SessionApiFailed as exc:
|
||||
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
|
||||
return 20
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
||||
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
||||
return 21
|
||||
sys.stdout.write(
|
||||
f"persona_state set: session={args.session_id[-8:]} pad={pad} (204)\n"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Sync entry point. Maps UsageError/_AuthError to exit codes BEFORE the event loop."""
|
||||
assert argv is None or all(isinstance(a, str) for a in argv)
|
||||
@@ -648,6 +749,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return int(exc.code) if exc.code is not None else 0
|
||||
if args.whoami:
|
||||
return asyncio.run(_whoami(args))
|
||||
if args.characters:
|
||||
return asyncio.run(_characters_probe(args))
|
||||
if args.set_persona_pad is not None:
|
||||
return asyncio.run(_set_persona_probe(args))
|
||||
if args.send_content is None:
|
||||
# TUI mode — lazy import preserves INV-001 (no textual in cli at module scope).
|
||||
from ratatoskr.tui import run_tui
|
||||
|
||||
@@ -425,6 +425,83 @@ async def get_me(client: httpx.AsyncClient) -> dict[str, Any]:
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def list_character_models(client: httpx.AsyncClient) -> dict[str, Any]:
|
||||
"""GET /models/available-for-characters — character-capable model profiles (#161).
|
||||
|
||||
Requires `character.read`. Returns `{items: [{name, description, thinking}]}`.
|
||||
Parsed dict verbatim; any non-200 → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
resp = await client.get("/models/available-for-characters")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def create_character(
|
||||
client: httpx.AsyncClient, character: dict[str, Any], *, state: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""POST /characters — create a transient character (#161). Requires `character.write`.
|
||||
|
||||
Body is `{character, state}` (state optional — a CharacterStateSchema for
|
||||
mid-conversation rehydration). Returns 201 `{character_id, ttl_expires_at}`;
|
||||
any non-201 → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
assert isinstance(character, dict) and character
|
||||
resp = await client.post("/characters", json={"character": character, "state": state})
|
||||
if resp.status_code == 201:
|
||||
return resp.json()
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def get_character_state(client: httpx.AsyncClient, character_id: str) -> dict[str, Any]:
|
||||
"""GET /characters/{character_id}/state — live runtime state (#161). Requires `character.read`.
|
||||
|
||||
Returns `{schema_version, pad, emotions_active, mood_drift, goal_signal_history}`;
|
||||
refreshes the character's TTL. Any non-200 → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
assert character_id and isinstance(character_id, str)
|
||||
resp = await client.get(f"/characters/{character_id}/state")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def delete_character(client: httpx.AsyncClient, character_id: str) -> None:
|
||||
"""DELETE /characters/{character_id} — remove a transient character (#161).
|
||||
|
||||
Requires `character.write`. Bound sessions detach (next turn → 410
|
||||
character_not_found). 200/204 → None; any other status → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
assert character_id and isinstance(character_id, str)
|
||||
resp = await client.delete(f"/characters/{character_id}")
|
||||
if resp.status_code in (200, 204):
|
||||
return None
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def set_persona_state(
|
||||
client: httpx.AsyncClient, session_id: str, snapshot: dict[str, Any]
|
||||
) -> None:
|
||||
"""POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection).
|
||||
|
||||
The request body is FREEFORM: the frozen OpenAPI 2.2.0 declares no request
|
||||
schema and the prose spec documents only the GET counterpart — so the caller
|
||||
supplies the snapshot shape (e.g. `{pad: [p, a, d]}`, mirroring the GET
|
||||
`snapshot`). 204 No Content → None; any other status → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
assert session_id and isinstance(session_id, str)
|
||||
assert isinstance(snapshot, dict)
|
||||
resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot)
|
||||
if resp.status_code == 204:
|
||||
return None
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def get_session_bifrost(
|
||||
client: httpx.AsyncClient, session_id: str, *, admin_key: str
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -68,6 +68,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
affect_read_url = os.environ.get(
|
||||
"RATATOSKR_AFFECT_READ_URL", "http://127.0.0.1:8390"
|
||||
)
|
||||
# Admin observability panes (BifrostState + AdminEvents): the readonly-admin
|
||||
# key stays SERVER-SIDE — the server proxies admin-scoped reads; the browser
|
||||
# never receives the key, only the session-filtered result.
|
||||
admin_key = os.environ.get("RATATOSKR_ADMIN_API_KEY")
|
||||
|
||||
# INV-001: lazy import. Users without [web] extras get a clean hint
|
||||
# instead of a raw ImportError. Scoped narrowly to the OPTIONAL
|
||||
@@ -108,6 +112,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
bifrost_consumer_key=bifrost_consumer_key,
|
||||
bifrost_visible_host=bifrost_visible_host,
|
||||
affect_read_url=affect_read_url,
|
||||
admin_key=admin_key,
|
||||
)
|
||||
|
||||
# Boot banner to stderr (so stdout stays clean for piping).
|
||||
|
||||
+112
-1
@@ -18,7 +18,12 @@ from importlib.metadata import version as _pkg_version
|
||||
import httpx
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from starlette.responses import (
|
||||
FileResponse,
|
||||
JSONResponse,
|
||||
Response,
|
||||
StreamingResponse,
|
||||
)
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
@@ -35,9 +40,12 @@ from ratatoskr.sessions import (
|
||||
create_session,
|
||||
endpoint_for_plane,
|
||||
get_persona_state,
|
||||
get_session_bifrost,
|
||||
get_session_tools,
|
||||
list_agents,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
AdminEvent,
|
||||
CancelAlreadyCompleted,
|
||||
Cancelled,
|
||||
CancelFailed,
|
||||
@@ -50,6 +58,7 @@ from ratatoskr.sse_client import (
|
||||
SseConnectionDropped,
|
||||
TurnIdFlip,
|
||||
cancel_turn,
|
||||
stream_admin_events,
|
||||
stream_turn_resilient,
|
||||
)
|
||||
|
||||
@@ -416,6 +425,100 @@ async def _affect_state_endpoint(request: Request) -> JSONResponse:
|
||||
return JSONResponse(r.json(), status_code=r.status_code)
|
||||
|
||||
|
||||
async def _session_tools_endpoint(request: Request) -> JSONResponse:
|
||||
"""GET /api/sessions/{session_id}/tools → owner-scoped tool inventory (spec #183).
|
||||
|
||||
Proxies get_session_tools with the client's CONSUMER bearer (no admin scope):
|
||||
the merged {agent_id, builtin_tools, bifrost_tools} the LLM saw at turn-fire.
|
||||
Any non-200 upstream → surfaced as a status-preserving error envelope."""
|
||||
session_id = request.path_params["session_id"]
|
||||
client_factory = request.app.state.client_factory
|
||||
try:
|
||||
async with client_factory() as client:
|
||||
info = await get_session_tools(client, session_id)
|
||||
except SessionApiFailed as exc:
|
||||
return JSONResponse(
|
||||
{"error_code": "session_tools_unavailable", "status": exc.status},
|
||||
status_code=exc.status,
|
||||
)
|
||||
return JSONResponse(info, status_code=200)
|
||||
|
||||
|
||||
async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
|
||||
"""GET /api/sessions/{session_id}/bifrost → admin-scoped Bifrost dispatch state (#176).
|
||||
|
||||
The admin key is SERVER-HELD (app.state.admin_key) and never reaches the
|
||||
browser (INV-003 precedent — upstream credentials stay server-side); the
|
||||
wrapper overrides the Authorization header with it. Fail-visible when the
|
||||
admin key isn't configured (never a silent empty pane)."""
|
||||
session_id = request.path_params["session_id"]
|
||||
admin_key = request.app.state.admin_key
|
||||
if not admin_key: # PRE-001: fail-visible, never silent
|
||||
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:
|
||||
return JSONResponse(
|
||||
{"error_code": "bifrost_state_unavailable", "status": exc.status},
|
||||
status_code=exc.status,
|
||||
)
|
||||
return JSONResponse(bstate, status_code=200)
|
||||
|
||||
|
||||
def _admin_event_matches_web(ev: AdminEvent, session_id: str | None) -> bool:
|
||||
"""AdminEvents filter (design-brief §6, mirrors the TUI): forward non-heartbeat
|
||||
system.* (stream-integrity signals) + events for the active session; drop the
|
||||
rest so the browser sees only session-relevant lifecycle, never the full
|
||||
cross-session admin firehose."""
|
||||
if ev.type == "system.heartbeat":
|
||||
return False
|
||||
if ev.type.startswith("system."):
|
||||
return True
|
||||
return session_id is not None and ev.data.get("session_id") == session_id
|
||||
|
||||
|
||||
async def _admin_events_endpoint(request: Request) -> Response:
|
||||
"""GET /api/admin/events?session_id=... → SSE proxy of GET /admin/events (#11).
|
||||
|
||||
The admin key is SERVER-HELD; the browser only ever receives the session-filtered
|
||||
stream (never the key, never the cross-session firehose). Long-lived + best-effort:
|
||||
a connect failure or mid-stream drop emits a labeled `stream_error` event and ends."""
|
||||
admin_key = request.app.state.admin_key
|
||||
if not admin_key: # PRE-001: fail-visible, never silent
|
||||
return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
|
||||
session_id = request.query_params.get("session_id")
|
||||
client_factory = request.app.state.client_factory
|
||||
|
||||
async def gen() -> AsyncIterator[bytes]:
|
||||
client = client_factory()
|
||||
try:
|
||||
async for ev in stream_admin_events(client, admin_key=admin_key):
|
||||
if not _admin_event_matches_web(ev, session_id):
|
||||
continue
|
||||
# Fixed SSE event name so the browser renders EVERY admin type
|
||||
# with one listener (no per-type enumeration → nothing silently
|
||||
# dropped); the real dotted type rides in the payload.
|
||||
yield _format_sse(
|
||||
"admin_event",
|
||||
{"id": ev.id, "type": ev.type, "timestamp": ev.timestamp,
|
||||
"data": ev.data},
|
||||
)
|
||||
except (SseConnectFailed, SseConnectionDropped, MalformedSseId,
|
||||
MalformedSseData) as exc:
|
||||
yield _format_sse(
|
||||
"stream_error",
|
||||
{"exception": type(exc).__name__, "message": str(exc)},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise # browser disconnect — let the generator unwind
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
|
||||
def create_app(
|
||||
client_factory: Callable[[], httpx.AsyncClient],
|
||||
*,
|
||||
@@ -423,6 +526,7 @@ def create_app(
|
||||
bifrost_consumer_key: str | None = None,
|
||||
bifrost_visible_host: str | None = None,
|
||||
affect_read_url: str | None = None,
|
||||
admin_key: str | None = None,
|
||||
) -> Starlette:
|
||||
"""Construct the Starlette app — wire routes + state per FN create_app.
|
||||
|
||||
@@ -483,6 +587,9 @@ def create_app(
|
||||
Route("/api/sessions", _create_session_endpoint, methods=["POST"]),
|
||||
Route("/api/agents/{agent_id}/persona_state", _persona_state_endpoint),
|
||||
Route("/api/affect/{agent_id}", _affect_state_endpoint),
|
||||
Route("/api/sessions/{session_id}/tools", _session_tools_endpoint),
|
||||
Route("/api/sessions/{session_id}/bifrost", _session_bifrost_endpoint),
|
||||
Route("/api/admin/events", _admin_events_endpoint),
|
||||
Route("/api/turns/{session_id}", _submit_turn_endpoint, methods=["POST"]),
|
||||
Route("/api/turns/{session_id}/stream", _stream_turn_endpoint),
|
||||
Route("/api/turns/{session_id}/cancel", _cancel_turn_endpoint, methods=["POST"]),
|
||||
@@ -498,6 +605,10 @@ def create_app(
|
||||
# Issue #18 (Deliverable 2): the provider affect-read base URL (server→provider hop,
|
||||
# same dev box) — distinct from the WT-visible host used for binding.
|
||||
app.state.affect_read_url = affect_read_url
|
||||
# Admin observability panes (BifrostState + AdminEvents): the admin key is
|
||||
# SERVER-HELD (RATATOSKR_ADMIN_API_KEY) and never reaches the browser — the
|
||||
# server proxies admin-scoped reads and forwards only the session-filtered result.
|
||||
app.state.admin_key = admin_key
|
||||
# INV-002: turn registry is in-process memory, keyed (session_id, turn_id)
|
||||
app.state.turn_registry = {}
|
||||
return app
|
||||
|
||||
@@ -231,6 +231,26 @@ body {
|
||||
50% { content: "··"; } 75% { content: "···"; }
|
||||
}
|
||||
|
||||
/* reasoning indicator — a UI affordance in the transcript, visually distinct
|
||||
from the agent's response (.response, left-bordered). Italic + a ✦ glyph so
|
||||
it reads as "the app telling you inference is happening", never as engine
|
||||
output. Ephemeral: appears on reasoning tokens, gone the moment real text
|
||||
begins or the turn ends. */
|
||||
.thinking-note {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
color: var(--blue); font-style: italic; font-size: 12px;
|
||||
margin: 6px 0; padding-left: 18px; opacity: 0.9;
|
||||
animation: rise 0.3s ease both;
|
||||
}
|
||||
.thinking-note::before {
|
||||
content: "✦"; font-style: normal; color: var(--cyan);
|
||||
text-shadow: 0 0 10px var(--glow-cyan);
|
||||
}
|
||||
.thinking-note::after {
|
||||
content: ""; width: 16px; text-align: left;
|
||||
animation: dots 1.4s steps(4, end) infinite;
|
||||
}
|
||||
|
||||
/* terminal status chips */
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
@@ -487,6 +507,8 @@ body {
|
||||
<button class="tab" data-pane="debug">debug <span class="kbd">⌃2</span><span class="badge">0</span></button>
|
||||
<button class="tab" data-pane="thinking">think <span class="kbd">⌃3</span><span class="badge">0</span></button>
|
||||
<button class="tab" data-pane="persona">persona <span class="kbd">⌃4</span></button>
|
||||
<button class="tab" data-pane="bifrost">bifrost <span class="kbd">⌃5</span></button>
|
||||
<button class="tab" data-pane="admin">admin <span class="kbd">⌃6</span><span class="badge">0</span></button>
|
||||
</nav>
|
||||
<div class="pane-head">
|
||||
<span id="pane-name">tools</span>
|
||||
@@ -497,6 +519,8 @@ body {
|
||||
<div class="pane" id="pane-debug"><div class="empty">waiting for wire telemetry…</div></div>
|
||||
<div class="pane" id="pane-thinking"><div class="empty">no chain-of-thought captured yet</div></div>
|
||||
<div class="pane" id="pane-persona"><div class="empty">persona state loads on session open</div></div>
|
||||
<div class="pane" id="pane-bifrost"><div class="empty">bifrost dispatch state loads on session open</div></div>
|
||||
<div class="pane" id="pane-admin"><div class="empty">admin lifecycle events stream on session open</div></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -542,7 +566,7 @@ body {
|
||||
"use strict";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const state = { sessionId: null, agentId: null, turnId: null, eventSource: null };
|
||||
const state = { sessionId: null, agentId: null, turnId: null, eventSource: null, lastAffectAt: null, adminES: null };
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement("div");
|
||||
@@ -603,8 +627,46 @@ function markdownSafe(raw) {
|
||||
// per-turn live buffers (reset at turn open)
|
||||
const LIVE = { resp: "", think: "" };
|
||||
|
||||
// ---- reasoning indicator (a UI affordance — NOT engine output) ----------
|
||||
// When the model streams reasoning/chain-of-thought, show an ephemeral
|
||||
// "<Agent> is pondering…" line in the transcript so the user knows inference
|
||||
// is happening. Rotates phrasing for liveliness; removed the instant real text
|
||||
// begins or the turn ends. agentDisplayName is rendered via textContent (never
|
||||
// innerHTML) so an adversarial agent_id can't inject markup (INV-004).
|
||||
const THINK_PHRASES = ["is thinking", "is pondering", "appears thoughtful",
|
||||
"is reasoning", "is turning it over"];
|
||||
let thinkRotator = null;
|
||||
function agentDisplayName() {
|
||||
if (!state.agentId) return "the agent";
|
||||
const tail = String(state.agentId).split(":").pop() || "the agent";
|
||||
return tail.charAt(0).toUpperCase() + tail.slice(1);
|
||||
}
|
||||
function showThinkingNote() {
|
||||
// reasoning tokens ARE the first tokens — supersede the awaiting-first heartbeat
|
||||
const aw = document.querySelector("#transcript .awaiting.live");
|
||||
if (aw) aw.remove();
|
||||
let el = document.querySelector("#transcript .thinking-note");
|
||||
if (!el) {
|
||||
el = document.createElement("div");
|
||||
el.className = "thinking-note";
|
||||
el.appendChild(document.createTextNode(""));
|
||||
$("transcript").appendChild(el);
|
||||
let i = 0;
|
||||
const paint = () => { el.firstChild.textContent =
|
||||
`${agentDisplayName()} ${THINK_PHRASES[i % THINK_PHRASES.length]}`; i++; };
|
||||
paint();
|
||||
thinkRotator = setInterval(paint, 2600);
|
||||
}
|
||||
$("transcript").scrollTop = $("transcript").scrollHeight;
|
||||
}
|
||||
function hideThinkingNote() {
|
||||
if (thinkRotator) { clearInterval(thinkRotator); thinkRotator = null; }
|
||||
const el = document.querySelector("#transcript .thinking-note");
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
// ---- pane helpers ----
|
||||
const PANE_BADGE = { tools: 0, debug: 0, thinking: 0 };
|
||||
const PANE_BADGE = { tools: 0, debug: 0, thinking: 0, admin: 0 };
|
||||
function bumpBadge(pane) {
|
||||
if (!(pane in PANE_BADGE)) return;
|
||||
PANE_BADGE[pane] += 1;
|
||||
@@ -773,6 +835,7 @@ async function loadAffect(agentId) {
|
||||
const snap = await r.json();
|
||||
renderAffectPane(snap);
|
||||
setPersonaStrip(snap); // pad bars are the live signal
|
||||
state.lastAffectAt = snap.emitted_at || state.lastAffectAt; // post-turn poll stop-signal
|
||||
} else {
|
||||
let code = "";
|
||||
try { code = (await r.json()).error_code || ""; } catch (_) {}
|
||||
@@ -794,6 +857,93 @@ async function loadAffect(agentId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tools inventory (#183): what the LLM HAS at turn-fire (static), rendered
|
||||
// at the TOP of the tools pane; live tool_start/result events append below it. ---
|
||||
function renderToolsInventory(inv) {
|
||||
const row = (k, v) => `<div><span class="pk">${esc(k)}</span> <span class="pv">${esc(v)}</span></div>`;
|
||||
const head = (t) => `<div class="ph">${esc(t)}</div>`;
|
||||
const names = (arr) => (arr || []).map((t) => (typeof t === "string" ? t : (t && t.name) || "?"));
|
||||
const builtin = inv.builtin_tools || [], bifrost = inv.bifrost_tools || [];
|
||||
const html =
|
||||
head("tool inventory · " + (inv.agent_id || "?")) +
|
||||
row("builtin (" + builtin.length + ")", names(builtin).join(", ") || "none") +
|
||||
row("bifrost (" + bifrost.length + ")", names(bifrost).join(", ") || "none") +
|
||||
`<div class="rule">— live tool events —</div>`;
|
||||
const pane = $("pane-tools");
|
||||
const empty = pane.querySelector(".empty");
|
||||
if (empty) empty.remove();
|
||||
let block = pane.querySelector(".tools-inventory");
|
||||
if (!block) {
|
||||
block = document.createElement("div");
|
||||
block.className = "tools-inventory";
|
||||
pane.insertBefore(block, pane.firstChild);
|
||||
}
|
||||
block.innerHTML = html;
|
||||
}
|
||||
async function loadSessionTools(sessionId) {
|
||||
try {
|
||||
const r = await fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/tools");
|
||||
if (r.status === 200) renderToolsInventory(await r.json());
|
||||
// non-200 → best-effort hydrate; leave the live tool pane as-is (mirrors TUI)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ---- Bifrost dispatch state (#176): admin-scoped, server-proxied (admin key
|
||||
// stays server-side; the browser only receives the state). ----
|
||||
function renderBifrostState(b) {
|
||||
const row = (k, v) => `<div><span class="pk">${esc(k)}</span> <span class="pv">${esc(v)}</span></div>`;
|
||||
const head = (t) => `<div class="ph">${esc(t)}</div>`;
|
||||
const tools = b.tools || [];
|
||||
$("pane-bifrost").innerHTML =
|
||||
head("bifrost dispatch state") +
|
||||
row("endpoint", b.endpoint_url || "?") +
|
||||
row("consumer", b.consumer_id || "?") +
|
||||
row("connected", JSON.stringify(b.connected)) +
|
||||
row("caps", (b.capabilities_granted || []).join(", ") || "none") +
|
||||
`<div> </div>` + head("tools (" + tools.length + ")") +
|
||||
(tools.map((t) => row("·", (t.name || "?") + (t.description ? " — " + t.description : ""))).join("")
|
||||
|| `<div class="empty">none</div>`);
|
||||
}
|
||||
async function loadBifrostState(sessionId) {
|
||||
try {
|
||||
const r = await fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/bifrost");
|
||||
if (r.status === 200) { renderBifrostState(await r.json()); return; }
|
||||
let code = ""; try { code = (await r.json()).error_code || ""; } catch (_) {}
|
||||
let msg;
|
||||
if (code === "admin_key_not_configured") msg = "bifrost state needs the readonly-admin key (RATATOSKR_ADMIN_API_KEY) server-side.";
|
||||
else if (r.status === 404) msg = "session is not Bifrost-bound (no live dispatch client).";
|
||||
else if (r.status === 403) msg = "admin key lacks the admin.sessions.read scope.";
|
||||
else msg = `bifrost state unavailable (HTTP ${esc(r.status)}${code ? " · " + esc(code) : ""}).`;
|
||||
$("pane-bifrost").innerHTML = `<div class="empty">${msg}</div>`;
|
||||
} catch (_) {
|
||||
$("pane-bifrost").innerHTML = `<div class="empty">bifrost state fetch failed</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Admin lifecycle events (#11): admin-scoped SSE, session-filtered SERVER-side.
|
||||
// One fixed "admin_event" listener renders every type; the dotted type is in data. ---
|
||||
function openAdminEvents(sessionId) {
|
||||
if (state.adminES) { state.adminES.close(); state.adminES = null; }
|
||||
const es = new EventSource("/api/admin/events?session_id=" + encodeURIComponent(sessionId));
|
||||
state.adminES = es;
|
||||
es.addEventListener("admin_event", (e) => {
|
||||
let d; try { d = JSON.parse(e.data); } catch (_) { return; }
|
||||
appendTo("pane-admin",
|
||||
`<div>[${ts()}] <span style="color:var(--blue)">${esc(d.type || "event")}</span> `
|
||||
+ `${esc(JSON.stringify(d.data || {}))}</div>`);
|
||||
});
|
||||
es.addEventListener("stream_error", (e) => {
|
||||
let d = {}; try { d = JSON.parse(e.data); } catch (_) {}
|
||||
appendTo("pane-admin", `<div class="rule">— admin stream ended: ${esc(d.exception || "error")} —</div>`);
|
||||
});
|
||||
es.onerror = () => {
|
||||
const pane = $("pane-admin");
|
||||
if (pane.querySelector(".empty")) {
|
||||
pane.innerHTML = `<div class="empty">admin stream unavailable — needs the readonly-admin key + admin.events.read scope.</div>`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---- session lifecycle ----
|
||||
async function startSession() {
|
||||
const agentId = $("agent-picker").value;
|
||||
@@ -837,6 +987,11 @@ async function startSession() {
|
||||
$("setup").style.display = "none";
|
||||
$("workspace").classList.add("live");
|
||||
await loadPersona(agentId);
|
||||
// Admin/debug surfaces — best-effort hydrate + live stream (all self-render on
|
||||
// failure; the admin key is server-held, never sent from here).
|
||||
loadSessionTools(state.sessionId);
|
||||
loadBifrostState(state.sessionId);
|
||||
openAdminEvents(state.sessionId);
|
||||
$("prompt-input").focus();
|
||||
} catch (e) {
|
||||
$("setup-err").textContent = "network error opening session";
|
||||
@@ -943,11 +1098,13 @@ async function submitPrompt() {
|
||||
es.addEventListener("thinking", (e) => {
|
||||
const d = JSON.parse(e.data);
|
||||
thinkingDeltas += 1;
|
||||
showThinkingNote(); // ephemeral "<Agent> is pondering…" in the transcript
|
||||
appendThinking(d.content);
|
||||
});
|
||||
es.addEventListener("text", (e) => {
|
||||
const d = JSON.parse(e.data);
|
||||
textDeltas += 1;
|
||||
hideThinkingNote(); // real text begins — reasoning display is done
|
||||
appendResponse(d.content);
|
||||
});
|
||||
es.addEventListener("text_boundary", (e) => {
|
||||
@@ -990,6 +1147,7 @@ async function submitPrompt() {
|
||||
function terminal(label, cls, e) {
|
||||
const aw = document.querySelector("#transcript .awaiting.live");
|
||||
if (aw) aw.remove();
|
||||
hideThinkingNote();
|
||||
finalizeResponse();
|
||||
document.querySelectorAll("#pane-thinking .think-live").forEach((b) => b.classList.remove("think-live"));
|
||||
let meta = "";
|
||||
@@ -1006,9 +1164,18 @@ async function submitPrompt() {
|
||||
$("composer").classList.remove("streaming");
|
||||
setConn(cls === "error" ? "error" : "idle", cls === "error" ? "error" : "connected");
|
||||
if (cls === "done" && state.agentId) {
|
||||
// Tier-3 affect.emit is POST-TURN ASYNC — it lands in our store a couple seconds
|
||||
// after [done]. Refresh the pane on a short delay to catch the new PAD (issue #18).
|
||||
setTimeout(() => loadPersona(state.agentId), 2000);
|
||||
// Tier-3 affect.emit is POST-TURN ASYNC and can land well after [done] — a single
|
||||
// fixed refresh races it (issue #18 foot-gun). Poll a short window, stopping once
|
||||
// the snapshot's emitted_at advances past the pre-turn value (or a new turn starts).
|
||||
const beforeAt = state.lastAffectAt;
|
||||
let settled = false;
|
||||
for (const delay of [1500, 3500, 6500, 10500]) {
|
||||
setTimeout(async () => {
|
||||
if (settled || state.turnId) return;
|
||||
await loadPersona(state.agentId);
|
||||
if (state.lastAffectAt && state.lastAffectAt !== beforeAt) settled = true;
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
$("prompt-input").focus();
|
||||
}
|
||||
@@ -1053,7 +1220,7 @@ document.querySelectorAll(".tab").forEach((t) =>
|
||||
|
||||
// ---- keyboard ----
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey && ["1", "2", "3", "4"].includes(e.key)) {
|
||||
if (e.ctrlKey && ["1", "2", "3", "4", "5", "6"].includes(e.key)) {
|
||||
const tabs = document.querySelectorAll(".tab");
|
||||
const idx = parseInt(e.key, 10) - 1;
|
||||
if (tabs[idx]) { activateTab(tabs[idx]); e.preventDefault(); }
|
||||
|
||||
@@ -1633,3 +1633,63 @@ class TestWhoami:
|
||||
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
|
||||
assert rc == 20
|
||||
assert "[session_api_failed]" in capsys.readouterr().err
|
||||
|
||||
|
||||
class TestTier2Probes:
|
||||
"""--characters + --set-persona-pad one-shot probes (Tier-2: #161 + persona_state-write)."""
|
||||
|
||||
def test_characters_standalone_accepted(self) -> None:
|
||||
"""characters_standalone: --characters alone → valid."""
|
||||
args = _parse_args(["--characters", "--api-key", "k"])
|
||||
assert args.characters is True
|
||||
assert args.session_id is None
|
||||
|
||||
def test_set_persona_requires_session(self) -> None:
|
||||
"""set_persona_requires_session [adversarial]: --set-persona-pad needs --session."""
|
||||
with pytest.raises(UsageError, match="requires --session"):
|
||||
_parse_args(["--set-persona-pad", "0.4,0.1,-0.2", "--api-key", "k"])
|
||||
|
||||
def test_probes_mutually_exclusive(self) -> None:
|
||||
"""probes_mutually_exclusive [adversarial]: --whoami + --characters → UsageError."""
|
||||
with pytest.raises(UsageError, match="mutually exclusive"):
|
||||
_parse_args(["--whoami", "--characters", "--api-key", "k"])
|
||||
|
||||
@respx.mock
|
||||
def test_characters_probe_lifecycle(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""characters_probe [happy,tracer]: models → create → state → delete; report to stdout."""
|
||||
respx.get("https://w.example/models/available-for-characters").mock(
|
||||
return_value=httpx.Response(200, json={"items": [{"name": "fast"}]})
|
||||
)
|
||||
respx.post("https://w.example/characters").mock(
|
||||
return_value=httpx.Response(201, json={"character_id": "char_z", "ttl_expires_at": "t"})
|
||||
)
|
||||
respx.get("https://w.example/characters/char_z/state").mock(
|
||||
return_value=httpx.Response(200, json={"schema_version": "1", "pad": [0.1, 0.2, 0.3]})
|
||||
)
|
||||
del_route = respx.delete("https://w.example/characters/char_z").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: fast" in out
|
||||
assert "created: char_z" in out
|
||||
assert "pad=[0.1, 0.2, 0.3]" in out
|
||||
assert "deleted: char_z" in out
|
||||
assert del_route.call_count == 1 # lifecycle cleaned up
|
||||
|
||||
@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."""
|
||||
import json as _json
|
||||
|
||||
route = respx.post("https://w.example/sessions/s1/persona_state").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
rc = main(
|
||||
["--set-persona-pad", "0.4,0.1,-0.2", "--session", "s1",
|
||||
"--api-key", "k", "--server", "https://w.example"]
|
||||
)
|
||||
assert rc == 0
|
||||
assert "persona_state set" in capsys.readouterr().out
|
||||
assert _json.loads(route.calls[0].request.content) == {"pad": [0.4, 0.1, -0.2]}
|
||||
|
||||
@@ -16,15 +16,20 @@ from ratatoskr.sessions import (
|
||||
PersonaNotConfigured,
|
||||
SessionApiFailed,
|
||||
SessionPage,
|
||||
create_character,
|
||||
create_session,
|
||||
delete_character,
|
||||
endpoint_for_plane,
|
||||
get_capabilities,
|
||||
get_character_state,
|
||||
get_me,
|
||||
get_persona_state,
|
||||
get_session_bifrost,
|
||||
get_session_tools,
|
||||
list_agents,
|
||||
list_character_models,
|
||||
list_sessions,
|
||||
set_persona_state,
|
||||
)
|
||||
|
||||
|
||||
@@ -1102,3 +1107,89 @@ class TestGetSessionBifrost:
|
||||
with pytest.raises(AssertionError):
|
||||
await get_session_bifrost(client, "s1", admin_key="")
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
class TestTransientCharacters:
|
||||
"""docs/contracts/issues/2.contract.md — transient-character wrappers (#161)."""
|
||||
|
||||
@respx.mock
|
||||
async def test_list_models(self) -> None:
|
||||
"""list_models [happy,tracer]: 200 → {items:[...]} verbatim."""
|
||||
respx.get("https://w.example/models/available-for-characters").mock(
|
||||
return_value=httpx.Response(200, json={"items": [{"name": "fast", "thinking": False}]})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
models = await list_character_models(client)
|
||||
assert models["items"][0]["name"] == "fast"
|
||||
|
||||
@respx.mock
|
||||
async def test_create_body_and_response(self) -> None:
|
||||
"""create [happy]: body is {character, state}; 201 → {character_id, ttl_expires_at}."""
|
||||
import json as _json
|
||||
|
||||
route = respx.post("https://w.example/characters").mock(
|
||||
return_value=httpx.Response(201, json={"character_id": "char_x", "ttl_expires_at": "t"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
out = await create_character(client, {"schema_version": "1", "name": "H"})
|
||||
assert out["character_id"] == "char_x"
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
assert body == {"character": {"schema_version": "1", "name": "H"}, "state": None}
|
||||
|
||||
@respx.mock
|
||||
async def test_get_state(self) -> None:
|
||||
"""get_state [happy]: 200 → live PAD/emotions snapshot."""
|
||||
respx.get("https://w.example/characters/char_x/state").mock(
|
||||
return_value=httpx.Response(200, json={"schema_version": "1", "pad": [0.4, 0.1, -0.2]})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
state = await get_character_state(client, "char_x")
|
||||
assert state["pad"] == [0.4, 0.1, -0.2]
|
||||
|
||||
@respx.mock
|
||||
async def test_delete_204(self) -> None:
|
||||
"""delete [happy]: 204 → None."""
|
||||
respx.delete("https://w.example/characters/char_x").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
assert await delete_character(client, "char_x") is None
|
||||
|
||||
@respx.mock
|
||||
async def test_create_403_scope(self) -> None:
|
||||
"""create_403 [error]: key lacks character.write → SessionApiFailed(403)."""
|
||||
respx.post("https://w.example/characters").mock(
|
||||
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await create_character(client, {"name": "H"})
|
||||
assert exc.value.status == 403
|
||||
|
||||
|
||||
class TestSetPersonaState:
|
||||
"""#2 contract — set_persona_state (POST /sessions/{id}/persona_state)."""
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_204(self) -> None:
|
||||
"""happy [happy,tracer]: freeform snapshot body; 204 → None."""
|
||||
import json as _json
|
||||
|
||||
route = respx.post("https://w.example/sessions/s1/persona_state").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
result = await set_persona_state(client, "s1", {"pad": [0.4, 0.1, -0.2]})
|
||||
assert result is None
|
||||
assert _json.loads(route.calls[0].request.content) == {"pad": [0.4, 0.1, -0.2]}
|
||||
|
||||
@respx.mock
|
||||
async def test_non_204_raises(self) -> None:
|
||||
"""non_204 [error]: 422 (bad snapshot shape) → SessionApiFailed(422)."""
|
||||
respx.post("https://w.example/sessions/s1/persona_state").mock(
|
||||
return_value=httpx.Response(422, json={"error_code": "validation_failed"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await set_persona_state(client, "s1", {"pad": [1, 2, 3]})
|
||||
assert exc.value.status == 422
|
||||
|
||||
@@ -1062,3 +1062,123 @@ class TestAffectStateEndpoint:
|
||||
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error_code"] == "missing_end_user_id"
|
||||
|
||||
|
||||
class TestSessionToolsEndpoint:
|
||||
"""session_tools_endpoint — proxy owner-scoped GET /sessions/{id}/tools (#183)."""
|
||||
|
||||
@respx.mock
|
||||
def test_happy_returns_inventory(self) -> None:
|
||||
"""happy [tracer]: 200 inventory → 200 verbatim."""
|
||||
respx.get("https://w.example/sessions/s-1/tools").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"agent_id": "ratatoskr:sindra",
|
||||
"builtin_tools": ["echo"],
|
||||
"bifrost_tools": [{"name": "memory.search"}],
|
||||
})
|
||||
)
|
||||
from ratatoskr.web.server import create_app
|
||||
resp = TestClient(create_app(_mock_client_factory())).get("/api/sessions/s-1/tools")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["agent_id"] == "ratatoskr:sindra"
|
||||
|
||||
@respx.mock
|
||||
def test_upstream_404_status_preserving_envelope(self) -> None:
|
||||
"""error: upstream 404 → 404 session_tools_unavailable envelope."""
|
||||
respx.get("https://w.example/sessions/s-1/tools").mock(
|
||||
return_value=httpx.Response(404, content=b"nope")
|
||||
)
|
||||
from ratatoskr.web.server import create_app
|
||||
resp = TestClient(create_app(_mock_client_factory())).get("/api/sessions/s-1/tools")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error_code"] == "session_tools_unavailable"
|
||||
|
||||
|
||||
class TestSessionBifrostEndpoint:
|
||||
"""session_bifrost_endpoint — proxy admin-scoped GET /admin/sessions/{id}/bifrost (#176)."""
|
||||
|
||||
@respx.mock
|
||||
def test_happy_overrides_with_admin_bearer(self) -> None:
|
||||
"""happy [tracer]: 200 state → 200; request carries the ADMIN bearer, not consumer."""
|
||||
route = respx.get("https://w.example/admin/sessions/s-1/bifrost").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"endpoint_url": "http://x:8392", "connected": True,
|
||||
"capabilities_granted": ["memory", "affect"], "tools": [],
|
||||
})
|
||||
)
|
||||
from ratatoskr.web.server import create_app
|
||||
app = create_app(_mock_client_factory(), admin_key="adm-key")
|
||||
resp = TestClient(app).get("/api/sessions/s-1/bifrost")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["connected"] is True
|
||||
assert route.calls.last.request.headers["Authorization"] == "Bearer adm-key"
|
||||
|
||||
def test_no_admin_key_fails_visible_400(self) -> None:
|
||||
"""error: no admin key configured → 400 admin_key_not_configured, no upstream call."""
|
||||
from ratatoskr.web.server import create_app
|
||||
app = create_app(_mock_client_factory()) # no admin_key
|
||||
resp = TestClient(app).get("/api/sessions/s-1/bifrost")
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error_code"] == "admin_key_not_configured"
|
||||
|
||||
@respx.mock
|
||||
def test_upstream_404_status_preserving_envelope(self) -> None:
|
||||
"""error: upstream 404 (not bound) → 404 bifrost_state_unavailable envelope."""
|
||||
respx.get("https://w.example/admin/sessions/s-1/bifrost").mock(
|
||||
return_value=httpx.Response(404, content=b"nope")
|
||||
)
|
||||
from ratatoskr.web.server import create_app
|
||||
app = create_app(_mock_client_factory(), admin_key="adm-key")
|
||||
resp = TestClient(app).get("/api/sessions/s-1/bifrost")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error_code"] == "bifrost_state_unavailable"
|
||||
|
||||
|
||||
class TestAdminEventsEndpoint:
|
||||
"""admin_events_endpoint — SSE proxy of GET /admin/events, session-filtered (#11)."""
|
||||
|
||||
def test_filter_semantics(self) -> None:
|
||||
"""unit: heartbeats drop, system.* pass, else match on session_id."""
|
||||
from ratatoskr.sse_client import AdminEvent
|
||||
from ratatoskr.web.server import _admin_event_matches_web
|
||||
|
||||
def mk(t: str, sid: "str | None" = None) -> AdminEvent:
|
||||
return AdminEvent(id=1, type=t, timestamp=None,
|
||||
data={"session_id": sid} if sid else {})
|
||||
|
||||
assert _admin_event_matches_web(mk("system.heartbeat"), "s-1") is False
|
||||
assert _admin_event_matches_web(mk("system.degraded"), "s-1") is True
|
||||
assert _admin_event_matches_web(mk("session.created", "s-1"), "s-1") is True
|
||||
assert _admin_event_matches_web(mk("session.created", "other"), "s-1") is False
|
||||
assert _admin_event_matches_web(mk("session.created", "s-1"), None) is False
|
||||
|
||||
def test_no_admin_key_fails_visible_400(self) -> None:
|
||||
"""error: no admin key → 400 admin_key_not_configured (no stream opened)."""
|
||||
from ratatoskr.web.server import create_app
|
||||
app = create_app(_mock_client_factory())
|
||||
resp = TestClient(app).get("/api/admin/events?session_id=s-1")
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error_code"] == "admin_key_not_configured"
|
||||
|
||||
@respx.mock
|
||||
def test_streams_filtered_events_fixed_name(self) -> None:
|
||||
"""happy: SSE → only session-matching + system.* forwarded, as `admin_event`."""
|
||||
stream = (
|
||||
b'event: session.created\n'
|
||||
b'data: {"type":"session.created","data":{"session_id":"s-1"}}\n\n'
|
||||
b'event: system.heartbeat\n'
|
||||
b'data: {"type":"system.heartbeat","data":{}}\n\n'
|
||||
b'event: turn.started\n'
|
||||
b'data: {"type":"turn.started","data":{"session_id":"other"}}\n\n'
|
||||
b'event: system.degraded\n'
|
||||
b'data: {"type":"system.degraded","data":{}}\n\n'
|
||||
)
|
||||
respx.get("https://w.example/admin/events").mock(return_value=_sse_resp(stream))
|
||||
from ratatoskr.web.server import create_app
|
||||
app = create_app(_mock_client_factory(), admin_key="adm-key")
|
||||
body = TestClient(app).get("/api/admin/events?session_id=s-1").text
|
||||
assert "event: admin_event" in body # fixed browser-facing name
|
||||
assert '"type": "session.created"' in body # matches active session → forwarded
|
||||
assert "system.degraded" in body # system.* → forwarded
|
||||
assert "system.heartbeat" not in body # heartbeat → dropped
|
||||
assert "turn.started" not in body # other session → dropped
|
||||
|
||||
Reference in New Issue
Block a user