Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fbe353836 | |||
| a3c92b68dc | |||
| 9ce83d5fdc | |||
| e62208d8e3 | |||
| 0205b81319 | |||
| 387ac4ab2c |
@@ -206,3 +206,110 @@ TESTS:
|
||||
limit_above_max [adversarial]: limit=300 → AssertionError; no HTTP issued
|
||||
empty_cursor [adversarial]: cursor="" → AssertionError; no HTTP issued
|
||||
```
|
||||
|
||||
## Amendment 2026-06-30 — boot-time introspection reads (v1 coverage-audit: capabilities+me)
|
||||
|
||||
The v1 coverage-audit added two read-only server-introspection endpoints as
|
||||
cheap debug primitives (surfaced via a new `ratatoskr --whoami` one-shot). Both
|
||||
mirror `get_persona_state`: GET, 200 → parsed dict verbatim, any non-200 →
|
||||
`SessionApiFailed`. The frozen OpenAPI types both responses as freeform objects,
|
||||
so the wrappers return `dict[str, Any]` (not a typed dataclass).
|
||||
|
||||
```contract
|
||||
FN get_me(client: httpx.AsyncClient) -> dict[str, Any]
|
||||
BRIEF: GET /me — the authenticated principal's identity + key metadata (spec §GET /me). Boot-time whoami: verify the key without agent-config side effects. Returns parsed JSON verbatim; spec documents {user_id, scopes, tier, display_name?, key_id?, key_label?, ...} with optional fields OMITTED (not null). Read-only, rate-exempt, no audit emission.
|
||||
PRE: [PRE-001 hard] client is not None -- assert client is not None
|
||||
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
|
||||
ERROR_ROUTING:
|
||||
HTTP non-200 (incl. 401 bad/absent key when auth enabled):
|
||||
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
flow_control: abort
|
||||
state_recovery: none (caller decides: bad key → re-key; degraded tier="unknown" is still a 200)
|
||||
STEPS:
|
||||
1. [setup, prescriptive] assert client is not None
|
||||
2. [sequential, prescriptive] resp = await client.get("/me")
|
||||
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
|
||||
TESTS:
|
||||
happy_authenticated [happy,tracer]: 200 {user_id, scopes, tier, key_id} → dict returned verbatim
|
||||
anonymous_dev_mode: 200 {user_id:"anonymous", tier:"anonymous"} → dict; no key_* fields (omitted)
|
||||
401_raises [error]: 401 → SessionApiFailed(status=401)
|
||||
|
||||
FN get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]
|
||||
BRIEF: GET /capabilities — server capability discovery (spec §Ephemeral Templates). Returns {ephemeral_templates: {echo: {allowed_models, default_model, system_prompt_max_bytes}}}. Any authenticated caller may read it (no instantiate scope). Parsed dict verbatim; any non-200 → SessionApiFailed.
|
||||
PRE: [PRE-001 hard] client is not None -- assert client is not None
|
||||
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
|
||||
ERROR_ROUTING:
|
||||
HTTP non-200:
|
||||
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
flow_control: abort
|
||||
state_recovery: none
|
||||
STEPS:
|
||||
1. [setup, prescriptive] assert client is not None
|
||||
2. [sequential, prescriptive] resp = await client.get("/capabilities")
|
||||
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
|
||||
TESTS:
|
||||
happy [happy]: 200 {ephemeral_templates:{echo:{...}}} → dict returned verbatim
|
||||
non_200_raises [error]: 500 → SessionApiFailed(status=500)
|
||||
```
|
||||
|
||||
## Amendment 2026-07-01 — session tool introspection (v1 coverage-audit)
|
||||
|
||||
Owner-scoped tool-inventory read (spec #183, `GET /sessions/{id}/tools`),
|
||||
surfaced in the TUI Tools pane on session-attach. Same shape as the other
|
||||
introspection wrappers: GET, 200 → parsed dict verbatim, non-200 →
|
||||
`SessionApiFailed`. Reachable with the consumer key (no admin scope), unlike the
|
||||
admin variant `GET /admin/sessions/{id}/tools`.
|
||||
|
||||
```contract
|
||||
FN get_session_tools(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]
|
||||
BRIEF: GET /sessions/{session_id}/tools — owner-scoped merged tool inventory (spec #183) the LLM saw at turn-fire: {agent_id, builtin_tools: [...], bifrost_tools: [{name, description, parameters}, ...]}. Owner gate (ctx.user_id == session.user_id); cross-owner → 404 session_not_found (existence-hiding), revoked → 401 auth_revoked. Parsed dict verbatim; any non-200 → SessionApiFailed.
|
||||
PRE: [PRE-001 hard] client is not None -- assert client is not None
|
||||
PRE: [PRE-002 hard] session_id is non-empty str -- assert session_id and isinstance(session_id, str)
|
||||
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
|
||||
ERROR_ROUTING:
|
||||
HTTP non-200 (incl. 404 session_not_found cross-owner/unknown, 401 auth_revoked):
|
||||
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
flow_control: abort
|
||||
state_recovery: none
|
||||
STEPS:
|
||||
1. [setup, prescriptive] assert PRE-001, PRE-002
|
||||
2. [sequential, prescriptive] resp = await client.get(f"/sessions/{session_id}/tools")
|
||||
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
|
||||
TESTS:
|
||||
happy [happy,tracer]: 200 {agent_id, builtin_tools:[], bifrost_tools:[{name,...}]} → dict verbatim
|
||||
cross_owner_404 [error]: 404 session_not_found → SessionApiFailed(status=404)
|
||||
empty_session_id [adversarial]: "" → AssertionError; no HTTP issued
|
||||
```
|
||||
|
||||
## Amendment 2026-07-01 — admin BifrostState read (v1 coverage-audit)
|
||||
|
||||
Admin-scoped Bifrost dispatch-state read (spec #176, `GET /admin/sessions/{id}/bifrost`),
|
||||
surfaced in the TUI BifrostState pane on session-attach. The first admin-key
|
||||
consumer in ratatoskr: requires the `admin.sessions.read` scope, so the request
|
||||
OVERRIDES the Authorization header with the caller-supplied `admin_key` (distinct
|
||||
from the client's default consumer key). Same result-shape convention as the
|
||||
other introspection wrappers: 200 → parsed dict verbatim, non-200 → `SessionApiFailed`.
|
||||
|
||||
```contract
|
||||
FN get_session_bifrost(client: httpx.AsyncClient, session_id: str, *, admin_key: str) -> dict[str, Any]
|
||||
BRIEF: GET /admin/sessions/{session_id}/bifrost — admin-scoped live Bifrost binding (spec #176): {endpoint_url, consumer_id, connected, capabilities_granted, tools:[{name, description}]}. Requires admin.sessions.read; the request sets Authorization: Bearer <admin_key> (override), NOT the client's default consumer bearer. Parsed dict verbatim; any non-200 → SessionApiFailed — notably 403 auth_scope_denied and 404 session_not_bifrost_bound.
|
||||
PRE: [PRE-001 hard] client is not None -- assert client is not None
|
||||
PRE: [PRE-002 hard] session_id is non-empty str -- assert session_id and isinstance(session_id, str)
|
||||
PRE: [PRE-003 hard] admin_key is non-empty str -- assert admin_key and isinstance(admin_key, str)
|
||||
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
|
||||
POST: [POST-002 state_change] the outbound request Authorization header == f"Bearer {admin_key}" (override) -- assert request.headers["Authorization"] == "Bearer " + admin_key
|
||||
ERROR_ROUTING:
|
||||
HTTP non-200 (incl. 403 auth_scope_denied, 404 session_not_found / session_not_bifrost_bound):
|
||||
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
flow_control: abort
|
||||
state_recovery: none (caller decides: 403 → key lacks scope; 404 not-bound → benign unbound session)
|
||||
STEPS:
|
||||
1. [setup, prescriptive] assert PRE-001..PRE-003
|
||||
2. [sequential, prescriptive] resp = await client.get(f"/admin/sessions/{session_id}/bifrost", headers={"Authorization": f"Bearer {admin_key}"})
|
||||
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
|
||||
TESTS:
|
||||
happy_uses_admin_bearer [happy,tracer]: 200 {endpoint_url, connected, capabilities_granted, tools} → dict verbatim; request Authorization == "Bearer <admin_key>" (override)
|
||||
scope_denied_403 [error]: 403 → SessionApiFailed(status=403)
|
||||
not_bound_404 [error]: 404 session_not_bifrost_bound → SessionApiFailed(status=404)
|
||||
empty_admin_key [adversarial]: admin_key="" → AssertionError; no HTTP issued
|
||||
```
|
||||
|
||||
+23
-20
@@ -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 | 7 | 11 | 22 |
|
||||
| REST (OpenAPI 2.2.0, path groups) | 40 | 12 | 6 | 22 |
|
||||
| SSE events | 11 | 11 | 0 | 0 |
|
||||
| Bifrost provider planes | 8 verbs | 8 | 0 | (10 gated verbs deferred) |
|
||||
|
||||
@@ -75,6 +75,11 @@ sub-gap).
|
||||
| `POST /agents/define` | ✅ | `tier3.py:175` → `_run_define` | Tier-3 create |
|
||||
| `PATCH /agents/{id}` | ✅ | `tier3.py:219` → `_run_patch` | Tier-3 mutate (system_prompt/model) |
|
||||
| `DELETE /agents/{id}` | ✅ | `tier3.py:242` → `_run_delete` | Tier-3 hard-delete |
|
||||
| `GET /me` | ✅ | `sessions.py:411` `get_me` → `cli.py` `--whoami` | identity/whoami probe; 401→SessionApiFailed |
|
||||
| `GET /capabilities` | ✅ | `sessions.py:428` `get_capabilities` → `cli.py` `--whoami` | Echo ephemeral-template discovery |
|
||||
| `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 |
|
||||
|
||||
**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):
|
||||
@@ -92,18 +97,13 @@ on the same path is an unwired frontier item — see frontier Tier 1):
|
||||
|
||||
| Endpoint | Status | Why in-scope |
|
||||
|---|---|---|
|
||||
| `GET /admin/events` | ⬜ | design-brief §5 v1 **AdminEvents pane**; = issue **#11**, **blocked** on `admin.events.read` scope (infra-ops) |
|
||||
| `GET /admin/sessions/{id}/bifrost` | ⬜ | design-brief §5 v1 **BifrostState widget** — never built; admin-key-gated |
|
||||
| `GET /admin/sessions/{id}/tools` | ⬜ | design-brief §5 v1 **Tools widget** — never built; admin-key-gated |
|
||||
| `GET /capabilities` | ⬜ | server capability discovery — a turn flows through what's advertised |
|
||||
| `GET /me` | ⬜ | whoami / key-identity — "which key am I against" is a debug primitive |
|
||||
| `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 |
|
||||
|
||||
**Tier 2 — rounds out I/O coverage under A (postdates the design-brief):**
|
||||
|
||||
| Endpoint | Status | Why in-scope |
|
||||
|---|---|---|
|
||||
| `GET /sessions/{id}/tools` | ⬜ | Tier-3 owner-scoped tool introspection (#183) — reachable with the **consumer key** (no admin scope), the unblocked tool-introspection path |
|
||||
| `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 |
|
||||
|
||||
@@ -201,22 +201,25 @@ starts exercising them.
|
||||
|
||||
## Convergence frontier (the v1 to-do)
|
||||
|
||||
**Tier 1 — debug-observability core**, in dependency order:
|
||||
**Tier 1 — debug-observability core:**
|
||||
|
||||
1. **Session picker + SSE-resume** — wrappers exist (`list_sessions`,
|
||||
`reconnect_turn`), need presenter wiring only. **Cheapest; unblocked.**
|
||||
2. **`GET /capabilities` + `GET /me`** — cheap debug primitives. Unblocked.
|
||||
3. **BifrostState + Tools widgets** (`GET /admin/sessions/{id}/{bifrost,tools}`)
|
||||
— design-brief'd v1, unbuilt. Admin-key-gated reads.
|
||||
4. **#11 — AdminEvents pane** — **blocked** on an `admin.events.read` scope grant
|
||||
(infra-ops). The single externally-blocked item; everything else can ship
|
||||
without it.
|
||||
1. ✅ **DONE** — Session picker (`v0.18.7`) + SSE-resume (`v0.18.5`/`.6`).
|
||||
2. ✅ **DONE** — `GET /capabilities` + `GET /me` (`v0.18.8`, `--whoami`).
|
||||
3. ✅ **DONE** — BifrostState pane (`v0.18.10`, `GET /admin/sessions/{id}/bifrost`,
|
||||
admin-key; live-auth-proven). The Tools half was already covered by the
|
||||
owner-scoped `GET /sessions/{id}/tools` (item 5).
|
||||
4. ✅ **DONE** — AdminEvents pane (`v0.18.11`, `GET /admin/events` SSE,
|
||||
session-filtered; admin-key; live-auth-proven). #11's blocker was already
|
||||
satisfied (admin key carries `admin.events.read`). **Tier 1 complete** — the
|
||||
admin/debug-observability core (Persona · Tools · BifrostState · AdminEvents)
|
||||
is fully built.
|
||||
|
||||
**Tier 2 — rounds out coverage** (lower priority):
|
||||
**Tier 2 — rounds out coverage (all that remains):**
|
||||
|
||||
5. **`GET /sessions/{id}/tools`** — owner-scoped tool introspection; consumer-key
|
||||
reachable (no admin scope), so unblocked.
|
||||
6. **Transient-characters routing** (4 endpoints) + **`POST /sessions/{id}/persona_state`**.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -153,6 +153,16 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
|
||||
- `[2026-06-30]` **(b2) session-picker SHIPPED (`v0.18.7`) — bare TUI mode → startup picker (design-brief §4).** `list_sessions` had NO caller; now bare TUI mode (no `--session`/`--new`) resolves via `list_sessions` in `_resolve_then_run`: **0 sessions → `[no_sessions]` error, exit 14** (resume-only, honors §4 "no in-app session creation — `--new` flag only"); **exactly 1 → auto-resume** (§4 "picker only when >1"); **≥2 → new `SessionPickerApp`** (Textual `App[str|None]`, mirrors `AgentPickerApp`; ListView of sessions) → resume the pick (Esc/Ctrl-D → exit 0). cli `_parse` relaxed: bare TUI now VALID (was "pass exactly one" error); `--send` still requires one flag (non-interactive, no picker); `--agent` forbidden in bare mode; `run_tui` PRE-002 XOR→"not both". Direct in-session TDD (contract #6 amendment, validated OK): 3 widget pilot tests + 5 `_resolve_then_run` resolution tests + 3 cli validation tests. Suite **528 green**; touched code ruff-clean (mypy: only the `BINDINGS` list-invariance warning every App in tui.py already carries — consistent). **DESIGN NOTE — bare+0-sessions → error (clause-consistent). The friendlier auto-fall-through-to-new alternative is DEFERRED pending operator preference (it would create a session without `--new`, against the §4 negative clause).** **Frontier now: `GET /capabilities`+`GET /me` → BifrostState/Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key) → #11 AdminEvents (BLOCKED on `admin.events.read`).** heid-code-review NOT run on b1 or b2 (offered).
|
||||
|
||||
- `[2026-06-30]` **capabilities+me slice SHIPPED (`v0.18.8`) — `GET /me` + `GET /capabilities` consumed via a new `--whoami` one-shot.** `get_me`/`get_capabilities` added to sessions.py (mirror `get_persona_state`: 200→dict verbatim, non-200→`SessionApiFailed`; freeform dicts per the frozen OpenAPI). New `ratatoskr --whoami` CLI mode (mirrors `--send`'s non-interactive shape) fetches both + prints an identity+capabilities report; standalone probe (mutually exclusive with `--send`/`--session`/`--new`/`--agent`, opens no session; new `ParsedArgs.whoami` field + main() dispatch). **`/capabilities` is the Echo EPHEMERAL-TEMPLATE discovery endpoint** (`{ephemeral_templates:{echo:{allowed_models,default_model,system_prompt_max_bytes}}}`), NOT a generic server-caps endpoint (audit finding — the coverage-map's earlier "server capability discovery" framing was imprecise). `/me` = whoami (`{user_id,scopes,tier,key_id?,...}`, optionals omitted-not-null). Contract-skip privilege invoked (low-effort GET wrappers) but contract #2 amended (2 FNs, validated OK) to keep the sessions spec canonical + honest test citations. TDD: 5 wrapper tests + 5 cli tests (validation + mode + error). Suite **538 green**; touched code ruff-clean (mypy: only `no-any-return` on `resp.json()`→dict, identical to the pre-existing `get_persona_state`). **Coverage: REST 9/40 ✅ (up from 7).** TUI-surfacing of /me (footer identity line) + /capabilities DEFERRED — the one-shot is the minimal tracer. **Frontier now: BifrostState + Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key-gated) → #11 AdminEvents (BLOCKED on `admin.events.read`).**
|
||||
- `[2026-07-01]` **b1 (SSE-resume) heid-code-review panel: ZERO findings — cross-model-verified clean.** Gróa (Grok) + Hulda (Codex) + Regin (GLM-5.2) each independently reviewed `stream_turn_resilient` vs contract #1's amendment (artifact-only, firewall held) → all three ZERO findings; signature / PRE-001..004 / STEP 1-4 / POST-001..003 / ERROR_ROUTING / all-8-TESTS confirmed, incl. the subtle `seen = last_seen or drop.last_seen_sse_id` zero-event-drop fallback. Convergent meta-note: **TDD + the unusually-prescriptive contract (STEPS `flexibility=prescriptive` + explicit GOTO) left no room for compliant-but-different drift — confirmation, not discovery.** Calibration signal: for a thin wrapper with a tight prescriptive contract + comprehensive TDD, the panel confirms rather than discovers. **b2 (picker) + capabilities+me NOT yet reviewed** (higher-surface b2 is the better candidate if more review is wanted). Dispatch msg `01KWE2K99T…` / thread `01KWE2K99S…`; heid dispatch-log `2026-06.jsonl#01KWE2V3MMY8XS55FCJYXYV14B`.
|
||||
|
||||
- `[2026-07-01]` **`GET /sessions/{id}/tools` quick-win SHIPPED (`v0.18.9`) — owner-scoped tool inventory in the TUI Tools pane.** `get_session_tools` wrapper (sessions.py, mirror get_me: 200→dict, non-200→`SessionApiFailed`) + `_format_tool_inventory` helper + `_hydrate_session_tools` best-effort worker (mirror `_hydrate_persona`) wired UNCONDITIONALLY in `on_mount` → writes the merged `{agent_id, builtin_tools, bifrost_tools}` inventory (what the LLM saw at turn-fire) to the Tools pane + audits `session_tools_hydrated`, never crashes on failure. Owner-scoped (`ctx.user_id==session.user_id`) → reachable with the CONSUMER key, NO admin scope — so this **covers the design-brief §5 "Tools widget" via the reachable owner endpoint** (the admin `/admin/sessions/{id}/tools` variant stays a gap only for cross-user operator debug). Contract #2 amended (FN, validated OK) + TDD (3 wrapper respx tests + 1 format-helper unit + 2 hydrate integration tests via `_spy_writes`+pilot). Suite **544 green**; touched code ruff-clean (the tui.py ruff/mypy debt at other lines is pre-existing). **Coverage: REST 10/40 ✅.** **Frontier now: BifrostState widget (`GET /admin/sessions/{id}/bifrost`, admin-key) + #11 AdminEvents (BLOCKED on `admin.events.read`) + Tier-2 (transient-characters routing, `POST /sessions/{id}/persona_state`).**
|
||||
|
||||
- `[2026-07-01]` **BifrostState pane SHIPPED (`v0.18.10`) — `GET /admin/sessions/{id}/bifrost` in a new TUI "Bifrost" pane; the FIRST admin-key consumer in ratatoskr.** `get_session_bifrost(client, session_id, *, admin_key)` (sessions.py) — admin-scoped (`admin.sessions.read`); the request OVERRIDES Authorization with `admin_key` (distinct from the consumer bearer, asserted in a test); 200→dict, non-200→SessionApiFailed. Admin-key wiring: `--admin-key` flag + `RATATOSKR_ADMIN_API_KEY` env → new `ParsedArgs.admin_key`. New "Bifrost" TabPane + `_format_bifrost_state` + `_hydrate_bifrost_state` best-effort worker (mirror `_hydrate_session_tools`) UNCONDITIONALLY in on_mount → writes {endpoint, connected, caps_granted, tools} + audits; self-labels "not configured" (no admin key) / "not bound" (404) / graceful on 403 + error. Contract #2 amended (FN, validated OK) + TDD (4 wrapper respx tests incl. the admin-bearer-override assertion + 1 format unit + 3 hydrate integration). Suite **552 green**; my code ruff-clean (pre-existing tui.py ruff debt at other lines untouched, incl. a dead `RichText` import in `_hydrate_persona`). **LIVE-AUTH-PROVEN** on personal :8081: admin key authenticated (reached resource-layer 404 session_not_found, NOT 401/403) → `admin.sessions.read` works live; 200 full-state not exercised (no bound session on :8081 now — unit-covered). Patch bump (debug feature, no downstream coordination; consistent with the session's cadence — but the §5-core-completion angle is a possible minor, operator's call).
|
||||
- `[2026-07-01]` **LEDGER CORRECTION: #11 (AdminEvents) is NO LONGER BLOCKED.** Verified via `GET /me` on :8081 that `RATATOSKR_ADMIN_API_KEY` (`ratatoskr-readonly`, tier readonly-admin) carries ALL 7 read scopes INCLUDING **`admin.events.read`** (+ `admin.sessions.read`, admin.keys.read, admin.skuld.read, pending.read, search.read, tool_events.read). The coverage-map + prior memory had #11 "blocked on admin.events.read" — **STALE**; the admin key was minted (post-#11-filing, env.sh) WITH the scope, so the blocker is already satisfied. **Only the AdminEvents SSE pane itself is unbuilt** — the last unbuilt §5 debug pane (a live SSE-consuming admin pane, distinct from the hydrate-at-attach panes). Coverage-map updated. **Coverage: REST 11/40 ✅.** Consider building the AdminEvents pane and/or updating #11's tracker status (its stated blocker is gone).
|
||||
|
||||
- `[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`).
|
||||
|
||||
_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.18.7"
|
||||
version = "0.19.0"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+95
-18
@@ -12,7 +12,7 @@ import signal
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from typing import TextIO
|
||||
from typing import Any, TextIO
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -24,6 +24,8 @@ from ratatoskr.sessions import (
|
||||
SessionApiFailed,
|
||||
create_session,
|
||||
endpoint_for_plane,
|
||||
get_capabilities,
|
||||
get_me,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
AffectUpdate,
|
||||
@@ -97,6 +99,13 @@ class ParsedArgs:
|
||||
bifrost: BifrostBinding | None = None
|
||||
bifrost_plane: str | None = None
|
||||
consumer_key: str | None = None
|
||||
# Standalone boot-time orientation probe: GET /me + GET /capabilities, print,
|
||||
# exit. Mutually exclusive with the session/turn flags (opens no session).
|
||||
whoami: bool = False
|
||||
# Optional admin-tier key (RATATOSKR_ADMIN_API_KEY / --admin-key) for the
|
||||
# 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
|
||||
|
||||
|
||||
class _ArgparseError(Exception):
|
||||
@@ -121,6 +130,8 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
parser.add_argument("--api-key", dest="api_key")
|
||||
parser.add_argument("--server")
|
||||
parser.add_argument("--raw", action="store_true")
|
||||
parser.add_argument("--whoami", action="store_true")
|
||||
parser.add_argument("--admin-key", dest="admin_key")
|
||||
# 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.
|
||||
@@ -141,23 +152,32 @@ 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.session and ns.new:
|
||||
raise UsageError("--session and --new are mutually exclusive")
|
||||
if not ns.session and not ns.new:
|
||||
# Bare TUI mode → startup session picker (design-brief §4). --send is
|
||||
# non-interactive (no picker can open), so it still requires one flag;
|
||||
# --agent belongs with --new (bare mode resumes, it doesn't create).
|
||||
if ns.send is not None:
|
||||
raise UsageError("--send requires --session or --new (no interactive picker)")
|
||||
if ns.agent:
|
||||
raise UsageError("--agent belongs with --new; bare TUI mode opens the session picker")
|
||||
if ns.session and ns.agent:
|
||||
raise UsageError("--agent is required with --new and forbidden with --session")
|
||||
if ns.new and not ns.agent and ns.send is not None:
|
||||
# Issue #8: --agent stays required for --send --new (non-interactive,
|
||||
# cannot prompt). Bare --new (TUI mode) accepts None — picker drives
|
||||
# the choice via list_agents in _resolve_then_run.
|
||||
raise UsageError("--agent is required when --new is passed in --send mode")
|
||||
if ns.whoami:
|
||||
# Standalone boot-time probe (GET /me + /capabilities): opens 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)"
|
||||
)
|
||||
else:
|
||||
if ns.session and ns.new:
|
||||
raise UsageError("--session and --new are mutually exclusive")
|
||||
if not ns.session and not ns.new:
|
||||
# Bare TUI mode → startup session picker (design-brief §4). --send is
|
||||
# non-interactive (no picker can open), so it still requires one flag;
|
||||
# --agent belongs with --new (bare mode resumes, it doesn't create).
|
||||
if ns.send is not None:
|
||||
raise UsageError("--send requires --session or --new (no interactive picker)")
|
||||
if ns.agent:
|
||||
raise UsageError(
|
||||
"--agent belongs with --new; bare TUI mode opens the session picker"
|
||||
)
|
||||
if ns.session and ns.agent:
|
||||
raise UsageError("--agent is required with --new and forbidden with --session")
|
||||
if ns.new and not ns.agent and ns.send is not None:
|
||||
# Issue #8: --agent stays required for --send --new (non-interactive,
|
||||
# cannot prompt). Bare --new (TUI mode) accepts None — picker drives
|
||||
# the choice via list_agents in _resolve_then_run.
|
||||
raise UsageError("--agent is required when --new is passed in --send mode")
|
||||
|
||||
api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or ""
|
||||
if not api_key:
|
||||
@@ -193,6 +213,9 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
bifrost = BifrostBinding(endpoint_url=endpoint_for_plane(ns.bifrost_plane, host))
|
||||
bifrost_plane = ns.bifrost_plane
|
||||
consumer_key = os.environ.get("RATATOSKR_BIFROST_CONSUMER_KEY") or None
|
||||
# Admin-tier key for the admin-scoped inspection reads (BifrostState pane).
|
||||
# Flag > env > None; None leaves the admin panes showing "not configured".
|
||||
admin_key = ns.admin_key or os.environ.get("RATATOSKR_ADMIN_API_KEY") or None
|
||||
|
||||
return ParsedArgs(
|
||||
send_content=ns.send,
|
||||
@@ -206,6 +229,8 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
bifrost=bifrost,
|
||||
bifrost_plane=bifrost_plane,
|
||||
consumer_key=consumer_key,
|
||||
whoami=ns.whoami,
|
||||
admin_key=admin_key,
|
||||
)
|
||||
|
||||
|
||||
@@ -556,6 +581,56 @@ async def _amain(args: ParsedArgs) -> int:
|
||||
loop.remove_signal_handler(signal.SIGINT)
|
||||
|
||||
|
||||
def _format_whoami(me: dict[str, Any], caps: dict[str, Any]) -> str:
|
||||
"""Render the --whoami report: identity (GET /me) + server capabilities."""
|
||||
lines = ["identity:"]
|
||||
lines.append(f" user_id: {me.get('user_id', '?')}")
|
||||
lines.append(f" tier: {me.get('tier', '?')}")
|
||||
lines.append(f" scopes: {', '.join(me.get('scopes', [])) or '(none)'}")
|
||||
for k in ("display_name", "key_id", "key_label"):
|
||||
if k in me:
|
||||
lines.append(f" {k}: {me[k]}")
|
||||
lines.append("capabilities:")
|
||||
templates = caps.get("ephemeral_templates", {})
|
||||
if templates:
|
||||
for name, spec in templates.items():
|
||||
models = ", ".join(spec.get("allowed_models", []))
|
||||
lines.append(
|
||||
f" ephemeral_template {name}: default={spec.get('default_model', '?')} "
|
||||
f"max_bytes={spec.get('system_prompt_max_bytes', '?')} models=[{models}]"
|
||||
)
|
||||
else:
|
||||
lines.append(" (no ephemeral templates advertised)")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
async def _whoami(args: ParsedArgs) -> int:
|
||||
"""--whoami one-shot: GET /me + GET /capabilities, print a compact report, exit.
|
||||
|
||||
A boot-time orientation probe (mirrors --send's non-interactive shape):
|
||||
"who am I against this server, and what does it offer." Opens no session.
|
||||
Errors land on stderr with the same [session_api_failed] / [network_error]
|
||||
vocab + exit codes as the other modes.
|
||||
"""
|
||||
assert isinstance(args, ParsedArgs)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=args.server_url,
|
||||
headers={"Authorization": f"Bearer {args.api_key}", "User-Agent": USER_AGENT},
|
||||
timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
try:
|
||||
me = await get_me(client)
|
||||
caps = await get_capabilities(client)
|
||||
except SessionApiFailed as exc:
|
||||
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
|
||||
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(_format_whoami(me, caps))
|
||||
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)
|
||||
@@ -571,6 +646,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# argparse's --help / --version short-circuit via SystemExit(0). Pass the code
|
||||
# through verbatim — argparse already printed help to stdout.
|
||||
return int(exc.code) if exc.code is not None else 0
|
||||
if args.whoami:
|
||||
return asyncio.run(_whoami(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
|
||||
|
||||
@@ -406,3 +406,78 @@ async def get_persona_state(
|
||||
if resp.status_code == 403 and error_code == "auth_scope_denied":
|
||||
raise AuthScopeDenied(scope="persona.read")
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def get_me(client: httpx.AsyncClient) -> dict[str, Any]:
|
||||
"""GET /me — the authenticated principal's identity + key metadata (spec §GET /me).
|
||||
|
||||
Boot-time whoami: verify the key without agent-config side effects. Returns
|
||||
the parsed dict verbatim (freeform per the frozen OpenAPI; the spec documents
|
||||
`{user_id, scopes, tier, display_name?, key_id?, key_label?, ...}`, optional
|
||||
fields omitted-not-null). 401 (bad/absent key when auth is enabled) — like
|
||||
every other non-200 — surfaces as SessionApiFailed (get_persona_state
|
||||
precedent). Read-only, rate-exempt, no audit emission.
|
||||
"""
|
||||
assert client is not None
|
||||
resp = await client.get("/me")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def get_session_bifrost(
|
||||
client: httpx.AsyncClient, session_id: str, *, admin_key: str
|
||||
) -> dict[str, Any]:
|
||||
"""GET /admin/sessions/{session_id}/bifrost — admin-scoped Bifrost dispatch state (#176).
|
||||
|
||||
Returns the live Bifrost binding for a session: `{endpoint_url, consumer_id,
|
||||
connected, capabilities_granted, tools: [{name, description}]}`. Requires the
|
||||
`admin.sessions.read` scope (admin tier), so the request OVERRIDES the
|
||||
Authorization header with `admin_key` (distinct from the client's default
|
||||
consumer key). Read-only (audited server-side). Parsed dict verbatim; any
|
||||
non-200 → SessionApiFailed — notably 403 `auth_scope_denied` (key lacks the
|
||||
scope) and 404 `session_not_bifrost_bound` (session exists, no live client).
|
||||
"""
|
||||
assert client is not None
|
||||
assert session_id and isinstance(session_id, str)
|
||||
assert admin_key and isinstance(admin_key, str)
|
||||
resp = await client.get(
|
||||
f"/admin/sessions/{session_id}/bifrost",
|
||||
headers={"Authorization": f"Bearer {admin_key}"},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def get_session_tools(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]:
|
||||
"""GET /sessions/{session_id}/tools — owner-scoped tool inventory (spec #183).
|
||||
|
||||
Returns the merged tool list the LLM saw at turn-fire: `{agent_id,
|
||||
builtin_tools: [...], bifrost_tools: [{name, description, parameters}, ...]}`.
|
||||
Owner-scoped (`ctx.user_id == session.user_id`) — reachable with the consumer
|
||||
key, NO admin scope. Cross-owner access returns 404 `session_not_found`
|
||||
(existence-hiding); a revoked session returns 401 `auth_revoked`. Parsed dict
|
||||
verbatim; any non-200 → SessionApiFailed (mirrors get_persona_state).
|
||||
"""
|
||||
assert client is not None
|
||||
assert session_id and isinstance(session_id, str)
|
||||
resp = await client.get(f"/sessions/{session_id}/tools")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]:
|
||||
"""GET /capabilities — server capability discovery (spec §Ephemeral Templates).
|
||||
|
||||
Returns `{ephemeral_templates: {echo: {allowed_models, default_model,
|
||||
system_prompt_max_bytes}}}` — what the server offers before a client decides
|
||||
to instantiate. Any authenticated caller may read it (no scope). Parsed dict
|
||||
verbatim; any non-200 → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
resp = await client.get("/capabilities")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
@@ -175,6 +175,23 @@ Event = (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdminEvent:
|
||||
"""One `/admin/events` envelope (INV-046) — an admin-tier lifecycle event.
|
||||
|
||||
Distinct from the turn-stream `Event` union: this is the process-wide admin
|
||||
broadcast stream, not a per-turn stream. `id` is a plain monotonic int
|
||||
(resets on restart; heartbeats have id=0). `type` is a dotted namespace
|
||||
(session.* / turn.* / key.* / system.*). `data` is a type-specific dict —
|
||||
most carry `session_id`; per INV-049 it holds IDs + small metadata only.
|
||||
"""
|
||||
|
||||
id: int
|
||||
type: str
|
||||
timestamp: str | None
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
class MalformedSseId(Exception):
|
||||
"""Raised when an SSE event's `id:` wire field is missing or non-composite."""
|
||||
|
||||
@@ -598,6 +615,51 @@ async def stream_turn_resilient(
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _parse_sse_id(raw: str) -> SseId:
|
||||
"""Parse the SSE wire `id:` as composite `{turn_id}:{seq}`. See contract FN _parse_sse_id."""
|
||||
assert isinstance(raw, str)
|
||||
|
||||
+196
-1
@@ -45,10 +45,13 @@ from ratatoskr.sessions import (
|
||||
SessionInfo,
|
||||
create_session,
|
||||
get_persona_state,
|
||||
get_session_bifrost,
|
||||
get_session_tools,
|
||||
list_agents,
|
||||
list_sessions,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
AdminEvent,
|
||||
AffectUpdate,
|
||||
AwaitingLlmFirstToken,
|
||||
CancelAlreadyCompleted,
|
||||
@@ -70,6 +73,7 @@ from ratatoskr.sse_client import (
|
||||
TurnIdFlip,
|
||||
WorkerPhase,
|
||||
cancel_turn,
|
||||
stream_admin_events,
|
||||
stream_turn_resilient,
|
||||
)
|
||||
|
||||
@@ -196,6 +200,46 @@ def _ts() -> str:
|
||||
return now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}"
|
||||
|
||||
|
||||
def _format_admin_event(ev: AdminEvent) -> str:
|
||||
"""One-line render of an /admin/events envelope for the AdminEvents pane.
|
||||
|
||||
Drops `session_id` from the detail (the pane is already session-scoped) and
|
||||
shows HH:MM:SS from the ISO timestamp + the remaining small metadata fields.
|
||||
"""
|
||||
ts = (ev.timestamp or "")[11:19]
|
||||
extras = " ".join(f"{k}={v}" for k, v in ev.data.items() if k != "session_id")
|
||||
return f"[{ts}] {ev.type} {extras}".rstrip()
|
||||
|
||||
|
||||
def _format_bifrost_state(state: dict) -> list[str]:
|
||||
"""Render GET /admin/sessions/{id}/bifrost (#176) into BifrostState-pane lines."""
|
||||
tools = [t.get("name", "?") for t in state.get("tools", [])]
|
||||
caps = state.get("capabilities_granted", [])
|
||||
return [
|
||||
f"bifrost binding: connected={state.get('connected')} "
|
||||
f"consumer={state.get('consumer_id', '?')}",
|
||||
f" endpoint: {state.get('endpoint_url', '?')}",
|
||||
f" caps_granted: {', '.join(caps) or '(none)'}",
|
||||
f" tools ({len(tools)}): {', '.join(tools) or '(none)'}",
|
||||
]
|
||||
|
||||
|
||||
def _format_tool_inventory(tools: dict) -> list[str]:
|
||||
"""Render GET /sessions/{id}/tools (#183) into Tools-pane inventory lines.
|
||||
|
||||
The merged tool list the LLM saw at turn-fire — distinct from the live
|
||||
tool_start/tool_result events that stream into the same pane during a turn.
|
||||
"""
|
||||
builtin = [t.get("name", "?") for t in tools.get("builtin_tools", [])]
|
||||
bifrost = [t.get("name", "?") for t in tools.get("bifrost_tools", [])]
|
||||
return [
|
||||
f"session tool inventory: agent={tools.get('agent_id', '?')} "
|
||||
f"builtin={len(builtin)} bifrost={len(bifrost)}",
|
||||
f" builtin: {', '.join(builtin) or '(none)'}",
|
||||
f" bifrost: {', '.join(bifrost) or '(none)'}",
|
||||
]
|
||||
|
||||
|
||||
def _format_persona_header(snapshot: dict) -> str:
|
||||
"""One-line persona summary for the sticky header widget.
|
||||
|
||||
@@ -1022,7 +1066,7 @@ class RatatoskrApp(App[int]):
|
||||
/* v0.8.1: #current-text Static removed. Streaming text now coalesces
|
||||
on `\n` and writes directly to #transcript (same pattern as v0.7.1
|
||||
thinking fix). Eliminates the dock-bottom-growth-overlap bug. */
|
||||
#tools-log, #debug-log, #thinking-log {
|
||||
#tools-log, #debug-log, #thinking-log, #bifrost-log, #admin-events-log {
|
||||
background: $background;
|
||||
padding: 0 1;
|
||||
}
|
||||
@@ -1187,6 +1231,24 @@ class RatatoskrApp(App[int]):
|
||||
id="persona-log", wrap=True, markup=False,
|
||||
highlight=False, min_width=0,
|
||||
)
|
||||
with TabPane("Bifrost", id="bifrost-tab"):
|
||||
# #176: admin-scoped Bifrost dispatch state (endpoint,
|
||||
# connected, granted caps, tools) via
|
||||
# GET /admin/sessions/{id}/bifrost. Hydrated on mount
|
||||
# with the admin key; "not configured" when absent.
|
||||
yield RichLog(
|
||||
id="bifrost-log", wrap=True, markup=False,
|
||||
highlight=False, min_width=0,
|
||||
)
|
||||
with TabPane("AdminEvents", id="admin-events-tab"):
|
||||
# #11: live GET /admin/events SSE stream, admin-scoped,
|
||||
# FILTERED to the active session (design-brief §6). A
|
||||
# long-lived worker appends matching lifecycle events;
|
||||
# "not configured" when no admin key is set.
|
||||
yield RichLog(
|
||||
id="admin-events-log", wrap=True, markup=False,
|
||||
highlight=False, min_width=0,
|
||||
)
|
||||
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
|
||||
# pane-name widget displays current side-pane name.
|
||||
yield Static("", id="identity")
|
||||
@@ -1240,6 +1302,17 @@ class RatatoskrApp(App[int]):
|
||||
# surface (PersonaNotConfigured) get a placeholder + empty header.
|
||||
if self.agent_id is not None:
|
||||
self.run_worker(self._hydrate_persona())
|
||||
# #183: hydrate the Tools pane with the session's tool inventory via
|
||||
# GET /sessions/{id}/tools (owner-scoped — consumer key, no admin scope).
|
||||
# Unconditional: every session has a tool inventory to introspect.
|
||||
self.run_worker(self._hydrate_session_tools())
|
||||
# #176: hydrate the BifrostState pane via GET /admin/sessions/{id}/bifrost
|
||||
# (admin-scoped). Self-labels "not configured" when no admin key is set,
|
||||
# "not bound" for the common unbound-session 404 — always writes at mount.
|
||||
self.run_worker(self._hydrate_bifrost_state())
|
||||
# #11: long-lived worker streaming GET /admin/events into the AdminEvents
|
||||
# pane, filtered to this session. Admin-key-gated; cancelled on app exit.
|
||||
self.run_worker(self._stream_admin_events())
|
||||
|
||||
async def _hydrate_persona(self) -> None:
|
||||
"""Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state.
|
||||
@@ -1275,6 +1348,128 @@ class RatatoskrApp(App[int]):
|
||||
f"err={type(exc).__name__}: {exc!s:.120}"
|
||||
)
|
||||
|
||||
async def _hydrate_session_tools(self) -> None:
|
||||
"""Hydrate the Tools pane inventory via GET /sessions/{id}/tools (#183).
|
||||
|
||||
Best-effort observability (mirrors _hydrate_persona): on 200, writes the
|
||||
merged tool inventory (builtin + bifrost) the LLM saw at turn-fire into
|
||||
the Tools pane + audits; on any failure, audits and moves on — never
|
||||
crashes the TUI. Owner-scoped, so reachable with the consumer key.
|
||||
"""
|
||||
assert self.client is not None and self.session_id is not None
|
||||
from rich.text import Text as RichText
|
||||
|
||||
try:
|
||||
tools = await get_session_tools(self.client, self.session_id)
|
||||
except Exception as exc: # best-effort — never crash the TUI on hydrate
|
||||
self._audit(
|
||||
f"session_tools_hydration_failed session={self.session_id[-8:]} "
|
||||
f"err={type(exc).__name__}: {exc!s:.120}"
|
||||
)
|
||||
return
|
||||
log = self.query_one("#tools-log", RichLog)
|
||||
for line in _format_tool_inventory(tools):
|
||||
log.write(RichText(line))
|
||||
self._audit(
|
||||
f"session_tools_hydrated session={self.session_id[-8:]} "
|
||||
f"builtin={len(tools.get('builtin_tools', []))} "
|
||||
f"bifrost={len(tools.get('bifrost_tools', []))}"
|
||||
)
|
||||
|
||||
async def _hydrate_bifrost_state(self) -> None:
|
||||
"""Hydrate the BifrostState pane via GET /admin/sessions/{id}/bifrost (#176).
|
||||
|
||||
Admin-scoped (admin.sessions.read) — uses `self.args.admin_key`. Best-effort
|
||||
(mirrors _hydrate_session_tools): on 200 writes the live binding (endpoint,
|
||||
connected, granted caps, tools) + audits; on failure a labeled line + audit,
|
||||
never crashes. No admin key → "not configured". 404 session_not_bifrost_bound
|
||||
is the routine unbound-session case; 403 means the key lacks the scope.
|
||||
"""
|
||||
assert self.client is not None and self.session_id is not None
|
||||
from rich.text import Text as RichText
|
||||
|
||||
log = self.query_one("#bifrost-log", RichLog)
|
||||
admin_key = getattr(self.args, "admin_key", None)
|
||||
if not admin_key:
|
||||
log.write(
|
||||
RichText("(admin key not configured — set RATATOSKR_ADMIN_API_KEY)")
|
||||
)
|
||||
self._audit(
|
||||
f"bifrost_state_skipped session={self.session_id[-8:]} reason=no_admin_key"
|
||||
)
|
||||
return
|
||||
try:
|
||||
state = await get_session_bifrost(
|
||||
self.client, self.session_id, admin_key=admin_key
|
||||
)
|
||||
except SessionApiFailed as exc:
|
||||
label = (
|
||||
"(session not bound to Bifrost)"
|
||||
if exc.status == 404
|
||||
else f"(bifrost state unavailable: HTTP {exc.status})"
|
||||
)
|
||||
log.write(RichText(label))
|
||||
self._audit(
|
||||
f"bifrost_state_unavailable session={self.session_id[-8:]} status={exc.status}"
|
||||
)
|
||||
return
|
||||
except Exception as exc: # best-effort — never crash the TUI on hydrate
|
||||
log.write(RichText(f"(bifrost state hydration failed: {type(exc).__name__})"))
|
||||
self._audit(
|
||||
f"bifrost_state_hydration_failed session={self.session_id[-8:]} "
|
||||
f"err={type(exc).__name__}: {exc!s:.120}"
|
||||
)
|
||||
return
|
||||
for line in _format_bifrost_state(state):
|
||||
log.write(RichText(line))
|
||||
self._audit(
|
||||
f"bifrost_state_hydrated session={self.session_id[-8:]} "
|
||||
f"connected={state.get('connected')} tools={len(state.get('tools', []))}"
|
||||
)
|
||||
|
||||
def _admin_event_matches(self, ev: AdminEvent) -> bool:
|
||||
"""AdminEvents filter (design-brief §6): active-session events + non-heartbeat
|
||||
system.* (stream-integrity signals). Heartbeats are keepalive noise."""
|
||||
if ev.type == "system.heartbeat":
|
||||
return False
|
||||
if ev.type.startswith("system."):
|
||||
return True
|
||||
return ev.data.get("session_id") == self.session_id
|
||||
|
||||
async def _stream_admin_events(self) -> None:
|
||||
"""Stream GET /admin/events (admin-scoped) into the AdminEvents pane (#11).
|
||||
|
||||
Long-lived + best-effort (never crashes the TUI). Filtered to the active
|
||||
session (design-brief §6): appends matching lifecycle events as they
|
||||
arrive. No admin key → "not configured". On connect failure (e.g. 403
|
||||
scope-denied) or a mid-stream drop, writes a labeled line and stops.
|
||||
"""
|
||||
assert self.client is not None and self.session_id is not None
|
||||
from rich.text import Text as RichText
|
||||
|
||||
log = self.query_one("#admin-events-log", RichLog)
|
||||
admin_key = getattr(self.args, "admin_key", None)
|
||||
if not admin_key:
|
||||
log.write(RichText("(admin key not configured — set RATATOSKR_ADMIN_API_KEY)"))
|
||||
self._audit(
|
||||
f"admin_events_skipped session={self.session_id[-8:]} reason=no_admin_key"
|
||||
)
|
||||
return
|
||||
try:
|
||||
async for ev in stream_admin_events(self.client, admin_key=admin_key):
|
||||
if self._admin_event_matches(ev):
|
||||
log.write(RichText(_format_admin_event(ev)))
|
||||
except SseConnectFailed as exc:
|
||||
log.write(RichText(f"(admin events unavailable: HTTP {exc.status})"))
|
||||
self._audit(
|
||||
f"admin_events_unavailable session={self.session_id[-8:]} status={exc.status}"
|
||||
)
|
||||
except Exception as exc: # drop / best-effort — never crash the TUI
|
||||
log.write(RichText(f"(admin events stream ended: {type(exc).__name__})"))
|
||||
self._audit(
|
||||
f"admin_events_ended session={self.session_id[-8:]} err={type(exc).__name__}"
|
||||
)
|
||||
|
||||
def _update_persona_surfaces(self, snapshot: dict) -> None:
|
||||
"""Update sticky header + Persona pane from a fresh snapshot.
|
||||
|
||||
|
||||
@@ -1564,3 +1564,72 @@ class TestBifrostBindCli:
|
||||
)
|
||||
rc = await _amain(args)
|
||||
assert rc == 22
|
||||
|
||||
|
||||
class TestWhoami:
|
||||
"""--whoami one-shot probe (slice: capabilities+me): GET /me + GET /capabilities."""
|
||||
|
||||
def test_whoami_standalone_accepted(self) -> None:
|
||||
"""whoami_standalone_accepted: --whoami alone → valid; whoami=True, no turn flags."""
|
||||
args = _parse_args(["--whoami", "--api-key", "k"])
|
||||
assert args.whoami is True
|
||||
assert args.send_content is None
|
||||
assert args.session_id is None
|
||||
assert args.new is False
|
||||
|
||||
def test_whoami_with_send_rejected(self) -> None:
|
||||
"""whoami_with_send_rejected [adversarial]: --whoami + --send → UsageError."""
|
||||
with pytest.raises(UsageError, match="standalone probe"):
|
||||
_parse_args(["--whoami", "--send", "hi", "--api-key", "k"])
|
||||
|
||||
def test_whoami_with_new_rejected(self) -> None:
|
||||
"""whoami_with_new_rejected [adversarial]: --whoami + --new → UsageError."""
|
||||
with pytest.raises(UsageError, match="standalone probe"):
|
||||
_parse_args(["--whoami", "--new", "--agent", "m", "--api-key", "k"])
|
||||
|
||||
@respx.mock
|
||||
def test_whoami_mode_prints_report(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""whoami_mode_prints_report [happy,tracer]: /me + /capabilities → stdout report; exit 0."""
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"user_id": "alice",
|
||||
"scopes": ["conversations.read", "conversations.write"],
|
||||
"tier": "user",
|
||||
"key_id": "a1b2c3d4",
|
||||
},
|
||||
)
|
||||
)
|
||||
respx.get("https://w.example/capabilities").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ephemeral_templates": {
|
||||
"echo": {
|
||||
"allowed_models": ["glm5-turbo"],
|
||||
"default_model": "glm5-turbo",
|
||||
"system_prompt_max_bytes": 32768,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "user_id: alice" in out
|
||||
assert "tier: user" in out
|
||||
assert "key_id: a1b2c3d4" in out
|
||||
assert "ephemeral_template echo" in out
|
||||
assert "glm5-turbo" in out
|
||||
|
||||
@respx.mock
|
||||
def test_whoami_me_auth_failure_exits_20(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""whoami_me_auth_failure [error]: /me 401 → exit 20 [session_api_failed]."""
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(401, json={"detail": "auth_invalid"})
|
||||
)
|
||||
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
|
||||
assert rc == 20
|
||||
assert "[session_api_failed]" in capsys.readouterr().err
|
||||
|
||||
@@ -18,7 +18,11 @@ from ratatoskr.sessions import (
|
||||
SessionPage,
|
||||
create_session,
|
||||
endpoint_for_plane,
|
||||
get_capabilities,
|
||||
get_me,
|
||||
get_persona_state,
|
||||
get_session_bifrost,
|
||||
get_session_tools,
|
||||
list_agents,
|
||||
list_sessions,
|
||||
)
|
||||
@@ -896,3 +900,205 @@ class TestGetPersonaState:
|
||||
with pytest.raises(PersonaNotConfigured) as exc_info:
|
||||
await get_persona_state(client, "domari")
|
||||
assert exc_info.value.agent_id == "domari"
|
||||
|
||||
|
||||
class TestGetMe:
|
||||
"""docs/contracts/issues/2.contract.md FN get_me (slice: capabilities+me)."""
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_authenticated(self) -> None:
|
||||
"""happy_authenticated [happy,tracer]: 200 → parsed identity dict verbatim."""
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"user_id": "alice",
|
||||
"scopes": ["conversations.read", "conversations.write"],
|
||||
"tier": "user",
|
||||
"key_id": "a1b2c3d4",
|
||||
"key_label": "alice phone",
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
me = await get_me(client)
|
||||
assert me["user_id"] == "alice"
|
||||
assert me["tier"] == "user"
|
||||
assert me["key_id"] == "a1b2c3d4"
|
||||
assert me["scopes"] == ["conversations.read", "conversations.write"]
|
||||
|
||||
@respx.mock
|
||||
async def test_anonymous_dev_mode(self) -> None:
|
||||
"""anonymous_dev_mode: 200 anonymous shape → dict with tier=anonymous."""
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"user_id": "anonymous",
|
||||
"scopes": ["conversations.read"],
|
||||
"tier": "anonymous",
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
me = await get_me(client)
|
||||
assert me["tier"] == "anonymous"
|
||||
assert "key_id" not in me # optional fields omitted, not null
|
||||
|
||||
@respx.mock
|
||||
async def test_401_raises_session_api_failed(self) -> None:
|
||||
"""401_raises [error]: bad/absent key → SessionApiFailed(status=401)."""
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(401, json={"detail": "auth_invalid"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await get_me(client)
|
||||
assert exc.value.status == 401
|
||||
|
||||
|
||||
class TestGetCapabilities:
|
||||
"""docs/contracts/issues/2.contract.md FN get_capabilities (slice: capabilities+me)."""
|
||||
|
||||
@respx.mock
|
||||
async def test_happy(self) -> None:
|
||||
"""happy [happy]: 200 → ephemeral_templates dict verbatim."""
|
||||
respx.get("https://w.example/capabilities").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ephemeral_templates": {
|
||||
"echo": {
|
||||
"allowed_models": ["glm5-turbo", "glm4.7"],
|
||||
"default_model": "glm5-turbo",
|
||||
"system_prompt_max_bytes": 32768,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
caps = await get_capabilities(client)
|
||||
echo = caps["ephemeral_templates"]["echo"]
|
||||
assert echo["default_model"] == "glm5-turbo"
|
||||
assert echo["system_prompt_max_bytes"] == 32768
|
||||
|
||||
@respx.mock
|
||||
async def test_non_200_raises(self) -> None:
|
||||
"""non_200_raises [error]: 500 → SessionApiFailed(status=500)."""
|
||||
respx.get("https://w.example/capabilities").mock(
|
||||
return_value=httpx.Response(500, content=b"boom")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await get_capabilities(client)
|
||||
assert exc.value.status == 500
|
||||
|
||||
|
||||
class TestGetSessionTools:
|
||||
"""docs/contracts/issues/2.contract.md — get_session_tools (GET /sessions/{id}/tools, #183)."""
|
||||
|
||||
@respx.mock
|
||||
async def test_happy(self) -> None:
|
||||
"""happy [happy,tracer]: 200 → merged tool inventory dict verbatim."""
|
||||
respx.get("https://w.example/sessions/s1/tools").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"agent_id": "alice:wizard",
|
||||
"builtin_tools": [],
|
||||
"bifrost_tools": [
|
||||
{"name": "bifrost.alice.set_field", "description": "d", "parameters": {}}
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
tools = await get_session_tools(client, "s1")
|
||||
assert tools["agent_id"] == "alice:wizard"
|
||||
assert tools["builtin_tools"] == []
|
||||
assert tools["bifrost_tools"][0]["name"] == "bifrost.alice.set_field"
|
||||
|
||||
@respx.mock
|
||||
async def test_cross_owner_404_raises(self) -> None:
|
||||
"""cross_owner_404 [error]: 404 session_not_found → SessionApiFailed(404)."""
|
||||
respx.get("https://w.example/sessions/s1/tools").mock(
|
||||
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await get_session_tools(client, "s1")
|
||||
assert exc.value.status == 404
|
||||
|
||||
@respx.mock
|
||||
async def test_empty_session_id_asserts(self) -> None:
|
||||
"""empty_session_id [adversarial]: '' → AssertionError; no HTTP issued."""
|
||||
route = respx.get("https://w.example/sessions//tools").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_tools(client, "")
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
import respx
|
||||
|
||||
from ratatoskr.sse_client import (
|
||||
AdminEvent,
|
||||
AffectUpdate,
|
||||
AgentNotAvailable,
|
||||
AwaitingLlmFirstToken,
|
||||
@@ -27,6 +28,7 @@ from ratatoskr.sse_client import (
|
||||
_parse_sse_id,
|
||||
cancel_turn,
|
||||
reconnect_turn,
|
||||
stream_admin_events,
|
||||
stream_turn,
|
||||
stream_turn_resilient,
|
||||
)
|
||||
@@ -1261,3 +1263,73 @@ class TestStreamTurnResilient:
|
||||
collected.append(e)
|
||||
assert [e.sse_id for e in collected] == [SseId(42, 1)] # type: ignore[attr-defined]
|
||||
assert route.call_count == 2
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
@@ -3167,3 +3167,273 @@ class TestBareSessionPicker:
|
||||
assert rc == 20
|
||||
assert "[session_api_failed]" in capsys.readouterr().err
|
||||
assert not opened
|
||||
|
||||
|
||||
class TestSessionToolsHydration:
|
||||
"""get_session_tools + the #183 Tools-pane inventory hydrate (GET /sessions/{id}/tools)."""
|
||||
|
||||
def test_format_tool_inventory(self) -> None:
|
||||
"""format_tool_inventory [unit]: header + builtin + bifrost lines."""
|
||||
from ratatoskr.tui import _format_tool_inventory
|
||||
|
||||
lines = _format_tool_inventory(
|
||||
{
|
||||
"agent_id": "alice:wizard",
|
||||
"builtin_tools": [],
|
||||
"bifrost_tools": [{"name": "bifrost.x"}, {"name": "bifrost.y"}],
|
||||
}
|
||||
)
|
||||
joined = "\n".join(lines)
|
||||
assert "agent=alice:wizard" in joined
|
||||
assert "builtin=0 bifrost=2" in joined
|
||||
assert "builtin: (none)" in joined
|
||||
assert "bifrost.x, bifrost.y" in joined
|
||||
|
||||
async def test_hydrate_writes_inventory_and_audits(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""hydrate_writes_inventory [scenario,tracer]: 200 → inventory in Tools pane + audit."""
|
||||
import ratatoskr.tui as tui_mod
|
||||
|
||||
writes = _spy_writes(monkeypatch)
|
||||
|
||||
async def fake_tools(client, session_id):
|
||||
return {
|
||||
"agent_id": "alice:wizard",
|
||||
"builtin_tools": [],
|
||||
"bifrost_tools": [{"name": "bifrost.set_field"}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(tui_mod, "get_session_tools", fake_tools)
|
||||
app = _resolved_app(_args_existing(session_id="s-tools-01"))
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._hydrate_session_tools()
|
||||
await pilot.pause()
|
||||
joined = " ".join(_text_of(w) for w in writes)
|
||||
assert "session tool inventory" in joined
|
||||
assert "bifrost.set_field" in joined
|
||||
assert "session_tools_hydrated" in joined # audit line landed
|
||||
|
||||
async def test_hydrate_failure_audits_no_crash(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""hydrate_failure [error]: get_session_tools raises → failure audit; no crash."""
|
||||
import ratatoskr.tui as tui_mod
|
||||
from ratatoskr.sessions import SessionApiFailed
|
||||
|
||||
writes = _spy_writes(monkeypatch)
|
||||
|
||||
async def boom(client, session_id):
|
||||
raise SessionApiFailed(status=404, body=b"session_not_found")
|
||||
|
||||
monkeypatch.setattr(tui_mod, "get_session_tools", boom)
|
||||
app = _resolved_app(_args_existing(session_id="s-tools-02"))
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._hydrate_session_tools()
|
||||
await pilot.pause()
|
||||
joined = " ".join(_text_of(w) for w in writes)
|
||||
assert "session_tools_hydration_failed" in joined
|
||||
|
||||
|
||||
class TestBifrostStateHydration:
|
||||
"""get_session_bifrost + the #176 BifrostState pane (GET /admin/sessions/{id}/bifrost)."""
|
||||
|
||||
@staticmethod
|
||||
def _mute_tools(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Neutralize the on_mount Tools-pane worker so it makes no real call."""
|
||||
import ratatoskr.tui as tui_mod
|
||||
|
||||
async def noop(client, session_id):
|
||||
return {"agent_id": "x", "builtin_tools": [], "bifrost_tools": []}
|
||||
|
||||
monkeypatch.setattr(tui_mod, "get_session_tools", noop)
|
||||
|
||||
def test_format_bifrost_state(self) -> None:
|
||||
"""format_bifrost_state [unit]: connected / endpoint / caps / tools lines."""
|
||||
from ratatoskr.tui import _format_bifrost_state
|
||||
|
||||
lines = _format_bifrost_state(
|
||||
{
|
||||
"endpoint_url": "https://b/mcp",
|
||||
"consumer_id": "alice",
|
||||
"connected": True,
|
||||
"capabilities_granted": ["tools:call", "tools:read"],
|
||||
"tools": [{"name": "bifrost.echo"}],
|
||||
}
|
||||
)
|
||||
joined = "\n".join(lines)
|
||||
assert "connected=True" in joined
|
||||
assert "consumer=alice" in joined
|
||||
assert "https://b/mcp" in joined
|
||||
assert "tools:call, tools:read" in joined
|
||||
assert "bifrost.echo" in joined
|
||||
|
||||
async def test_hydrate_no_admin_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""hydrate_no_admin_key [scenario]: admin_key None → 'not configured' + skip audit."""
|
||||
self._mute_tools(monkeypatch)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
app = _resolved_app(_args_existing(session_id="s-bf-1")) # admin_key defaults None
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._hydrate_bifrost_state()
|
||||
await pilot.pause()
|
||||
joined = " ".join(_text_of(w) for w in writes)
|
||||
assert "admin key not configured" in joined
|
||||
assert "bifrost_state_skipped" in joined
|
||||
|
||||
async def test_hydrate_success(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""hydrate_success [scenario,tracer]: 200 → binding in BifrostState pane + audit."""
|
||||
import ratatoskr.tui as tui_mod
|
||||
|
||||
self._mute_tools(monkeypatch)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
|
||||
async def fake_bifrost(client, session_id, *, admin_key):
|
||||
return {
|
||||
"endpoint_url": "https://b/mcp",
|
||||
"consumer_id": "alice",
|
||||
"connected": True,
|
||||
"capabilities_granted": ["tools:call"],
|
||||
"tools": [{"name": "bifrost.echo"}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(tui_mod, "get_session_bifrost", fake_bifrost)
|
||||
app = _resolved_app(_args_existing(session_id="s-bf-2", admin_key="ak"))
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._hydrate_bifrost_state()
|
||||
await pilot.pause()
|
||||
joined = " ".join(_text_of(w) for w in writes)
|
||||
assert "bifrost binding" in joined
|
||||
assert "bifrost.echo" in joined
|
||||
assert "bifrost_state_hydrated" in joined
|
||||
|
||||
async def test_hydrate_404_not_bound(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""hydrate_404_not_bound [error]: 404 → 'not bound to Bifrost' + audit; no crash."""
|
||||
import ratatoskr.tui as tui_mod
|
||||
from ratatoskr.sessions import SessionApiFailed
|
||||
|
||||
self._mute_tools(monkeypatch)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
|
||||
async def not_bound(client, session_id, *, admin_key):
|
||||
raise SessionApiFailed(status=404, body=b"session_not_bifrost_bound")
|
||||
|
||||
monkeypatch.setattr(tui_mod, "get_session_bifrost", not_bound)
|
||||
app = _resolved_app(_args_existing(session_id="s-bf-3", admin_key="ak"))
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._hydrate_bifrost_state()
|
||||
await pilot.pause()
|
||||
joined = " ".join(_text_of(w) for w in writes)
|
||||
assert "not bound to Bifrost" in joined
|
||||
assert "bifrost_state_unavailable" in joined
|
||||
|
||||
|
||||
class TestAdminEventsStream:
|
||||
"""stream_admin_events + the #11 AdminEvents pane (GET /admin/events, session-filtered)."""
|
||||
|
||||
@staticmethod
|
||||
def _mute_hydrates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Neutralize the other on_mount workers (tools + bifrost) — no real calls."""
|
||||
import ratatoskr.tui as tui_mod
|
||||
from ratatoskr.sessions import SessionApiFailed
|
||||
|
||||
async def noop_tools(client, session_id):
|
||||
return {"agent_id": "x", "builtin_tools": [], "bifrost_tools": []}
|
||||
|
||||
async def noop_bifrost(client, session_id, *, admin_key):
|
||||
raise SessionApiFailed(status=404, body=b"nb")
|
||||
|
||||
monkeypatch.setattr(tui_mod, "get_session_tools", noop_tools)
|
||||
monkeypatch.setattr(tui_mod, "get_session_bifrost", noop_bifrost)
|
||||
|
||||
def test_format_admin_event(self) -> None:
|
||||
"""format_admin_event [unit]: HH:MM:SS + type + fields; session_id dropped."""
|
||||
from ratatoskr.sse_client import AdminEvent
|
||||
from ratatoskr.tui import _format_admin_event
|
||||
|
||||
line = _format_admin_event(
|
||||
AdminEvent(
|
||||
42, "turn.completed", "2026-05-06T10:00:05.000Z",
|
||||
{"session_id": "s1", "turn_id": 7, "duration_ms": 1200, "phase": "succeeded"},
|
||||
)
|
||||
)
|
||||
assert "turn.completed" in line
|
||||
assert "[10:00:05]" in line
|
||||
assert "turn_id=7" in line
|
||||
assert "session_id" not in line # dropped — pane is already session-scoped
|
||||
|
||||
def test_admin_event_matches_filter(self) -> None:
|
||||
"""admin_event_matches [unit]: active-session + non-heartbeat system.* pass (§6)."""
|
||||
from ratatoskr.sse_client import AdminEvent
|
||||
|
||||
E = AdminEvent
|
||||
app = _resolved_app(_args_existing(session_id="s-match"))
|
||||
assert app._admin_event_matches(E(1, "session.created", "t", {"session_id": "s-match"}))
|
||||
assert not app._admin_event_matches(E(2, "turn.started", "t", {"session_id": "other"}))
|
||||
assert not app._admin_event_matches(E(0, "system.heartbeat", "t", {}))
|
||||
assert app._admin_event_matches(E(3, "system.events_dropped", "t", {"count": 5}))
|
||||
|
||||
async def test_stream_writes_filtered_events(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""stream_filtered [scenario,tracer]: only active-session + non-heartbeat lines land."""
|
||||
import ratatoskr.tui as tui_mod
|
||||
from ratatoskr.sse_client import AdminEvent
|
||||
|
||||
self._mute_hydrates(monkeypatch)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
|
||||
async def fake_stream(client, *, admin_key, last_event_id=None):
|
||||
yield AdminEvent(41, "session.created", "t", {"session_id": "s-ae-2"})
|
||||
yield AdminEvent(0, "system.heartbeat", "t", {}) # filtered (noise)
|
||||
yield AdminEvent(42, "turn.started", "t", {"session_id": "other"}) # diff session
|
||||
yield AdminEvent(43, "session.deleted", "t", {"session_id": "s-ae-2"})
|
||||
|
||||
monkeypatch.setattr(tui_mod, "stream_admin_events", fake_stream)
|
||||
app = _resolved_app(_args_existing(session_id="s-ae-2", admin_key="ak"))
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._stream_admin_events()
|
||||
await pilot.pause()
|
||||
joined = " ".join(_text_of(w) for w in writes)
|
||||
assert "session.created" in joined
|
||||
assert "session.deleted" in joined
|
||||
assert "system.heartbeat" not in joined
|
||||
assert "turn.started" not in joined # different session → filtered
|
||||
|
||||
async def test_stream_no_admin_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""stream_no_admin_key [scenario]: admin_key None → 'not configured' + skip audit."""
|
||||
self._mute_hydrates(monkeypatch)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
app = _resolved_app(_args_existing(session_id="s-ae-3")) # admin_key None
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._stream_admin_events()
|
||||
await pilot.pause()
|
||||
joined = " ".join(_text_of(w) for w in writes)
|
||||
assert "admin key not configured" in joined
|
||||
assert "admin_events_skipped" in joined
|
||||
|
||||
async def test_stream_403_unavailable(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""stream_403 [error]: 403 scope-denied → 'unavailable' + audit; no crash."""
|
||||
import ratatoskr.tui as tui_mod
|
||||
from ratatoskr.sse_client import SseConnectFailed
|
||||
|
||||
self._mute_hydrates(monkeypatch)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
|
||||
async def denied(client, *, admin_key, last_event_id=None):
|
||||
raise SseConnectFailed(status=403, body=b"auth_scope_denied")
|
||||
yield # unreachable — makes this an async generator
|
||||
|
||||
monkeypatch.setattr(tui_mod, "stream_admin_events", denied)
|
||||
app = _resolved_app(_args_existing(session_id="s-ae-4", admin_key="ak"))
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._stream_admin_events()
|
||||
await pilot.pause()
|
||||
joined = " ".join(_text_of(w) for w in writes)
|
||||
assert "admin events unavailable: HTTP 403" in joined
|
||||
assert "admin_events_unavailable" in joined
|
||||
|
||||
Reference in New Issue
Block a user