diff --git a/docs/contracts/issues/2.contract.md b/docs/contracts/issues/2.contract.md index f5a8550..7d2f066 100644 --- a/docs/contracts/issues/2.contract.md +++ b/docs/contracts/issues/2.contract.md @@ -280,3 +280,36 @@ TESTS: 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 (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 " (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 +``` diff --git a/docs/coverage-map.md b/docs/coverage-map.md index 036ca31..cf268e1 100644 --- a/docs/coverage-map.md +++ b/docs/coverage-map.md @@ -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 | 10 | 8 | 22 | +| REST (OpenAPI 2.2.0, path groups) | 40 | 11 | 7 | 22 | | SSE events | 11 | 11 | 0 | 0 | | Bifrost provider planes | 8 verbs | 8 | 0 | (10 gated verbs deferred) | @@ -78,6 +78,7 @@ sub-gap). | `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 | **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): @@ -95,9 +96,8 @@ 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 /admin/events` | ⬜ | design-brief §5 v1 **AdminEvents pane** (issue **#11**). **NO LONGER BLOCKED** — the `RATATOSKR_ADMIN_API_KEY` (`ratatoskr-readonly`) verified to carry `admin.events.read` (2026-07-01); pane just unbuilt. The last unbuilt §5 debug pane. | +| `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):** @@ -205,14 +205,13 @@ starts exercising them. 1. ✅ **DONE** — Session picker (`v0.18.7`) + SSE-resume (`v0.18.5`/`.6`). 2. ✅ **DONE** — `GET /capabilities` + `GET /me` (`v0.18.8`, `--whoami`). -3. **BifrostState + Tools widgets** (`GET /admin/sessions/{id}/bifrost` — the - admin session-bifrost inspection) — design-brief'd v1, unbuilt. Admin-key-gated. - *(The Tools half is effectively covered by the owner-scoped `GET /sessions/{id}/tools` - inventory, item 5 — the admin `/admin/sessions/{id}/tools` remains a gap only for - cross-user operator debug.)* -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. +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. **#11 — AdminEvents pane** (`GET /admin/events`) — **NO LONGER BLOCKED.** The + `RATATOSKR_ADMIN_API_KEY` was verified (2026-07-01) to carry `admin.events.read`; + the blocker (an admin key with the scope) is already satisfied. Only the pane + itself is unbuilt — an SSE-consuming admin pane, the last unbuilt §5 surface. **Tier 2 — rounds out coverage:** diff --git a/persistent-memory.md b/persistent-memory.md index 631a14f..ecc8c82 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -158,6 +158,9 @@ decision. Captures rationale that won't be obvious from code alone. - `[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). + _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._ diff --git a/pyproject.toml b/pyproject.toml index 493df61..f904ba9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.18.9" +version = "0.18.10" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/cli.py b/src/ratatoskr/cli.py index 40505eb..72060f8 100644 --- a/src/ratatoskr/cli.py +++ b/src/ratatoskr/cli.py @@ -102,6 +102,10 @@ class ParsedArgs: # 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): @@ -127,6 +131,7 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: 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. @@ -208,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, @@ -222,6 +230,7 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: bifrost_plane=bifrost_plane, consumer_key=consumer_key, whoami=ns.whoami, + admin_key=admin_key, ) diff --git a/src/ratatoskr/sessions.py b/src/ratatoskr/sessions.py index dee800a..080144c 100644 --- a/src/ratatoskr/sessions.py +++ b/src/ratatoskr/sessions.py @@ -425,6 +425,31 @@ async def get_me(client: httpx.AsyncClient) -> dict[str, Any]: 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). diff --git a/src/ratatoskr/tui.py b/src/ratatoskr/tui.py index d298182..56f2695 100644 --- a/src/ratatoskr/tui.py +++ b/src/ratatoskr/tui.py @@ -45,6 +45,7 @@ from ratatoskr.sessions import ( SessionInfo, create_session, get_persona_state, + get_session_bifrost, get_session_tools, list_agents, list_sessions, @@ -197,6 +198,19 @@ def _ts() -> str: return now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}" +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. @@ -1039,7 +1053,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 { background: $background; padding: 0 1; } @@ -1204,6 +1218,15 @@ 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, + ) # INV-002 + INV-003: visible identity + hint widgets (Footer-area). # pane-name widget displays current side-pane name. yield Static("", id="identity") @@ -1261,6 +1284,10 @@ class RatatoskrApp(App[int]): # 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()) async def _hydrate_persona(self) -> None: """Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state. @@ -1324,6 +1351,57 @@ class RatatoskrApp(App[int]): 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 _update_persona_surfaces(self, snapshot: dict) -> None: """Update sticky header + Persona pane from a fresh snapshot. diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 2e89a64..a8b6dbc 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -21,6 +21,7 @@ from ratatoskr.sessions import ( get_capabilities, get_me, get_persona_state, + get_session_bifrost, get_session_tools, list_agents, list_sessions, @@ -1039,3 +1040,65 @@ class TestGetSessionTools: 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 diff --git a/tests/test_tui.py b/tests/test_tui.py index bf4e7dc..35526b7 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -3235,3 +3235,98 @@ class TestSessionToolsHydration: 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 diff --git a/uv.lock b/uv.lock index c36d854..a15473c 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,7 +1052,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.18.9" +version = "0.18.10" source = { editable = "." } dependencies = [ { name = "httpx" },