diff --git a/pyproject.toml b/pyproject.toml
index 5675358..8311d93 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
-version = "0.19.1"
+version = "0.19.2"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
diff --git a/src/ratatoskr/web/entrypoint.py b/src/ratatoskr/web/entrypoint.py
index abc6b4d..604533b 100644
--- a/src/ratatoskr/web/entrypoint.py
+++ b/src/ratatoskr/web/entrypoint.py
@@ -68,6 +68,10 @@ def main(argv: list[str] | None = None) -> int:
affect_read_url = os.environ.get(
"RATATOSKR_AFFECT_READ_URL", "http://127.0.0.1:8390"
)
+ # Admin observability panes (BifrostState + AdminEvents): the readonly-admin
+ # key stays SERVER-SIDE — the server proxies admin-scoped reads; the browser
+ # never receives the key, only the session-filtered result.
+ admin_key = os.environ.get("RATATOSKR_ADMIN_API_KEY")
# INV-001: lazy import. Users without [web] extras get a clean hint
# instead of a raw ImportError. Scoped narrowly to the OPTIONAL
@@ -108,6 +112,7 @@ def main(argv: list[str] | None = None) -> int:
bifrost_consumer_key=bifrost_consumer_key,
bifrost_visible_host=bifrost_visible_host,
affect_read_url=affect_read_url,
+ admin_key=admin_key,
)
# Boot banner to stderr (so stdout stays clean for piping).
diff --git a/src/ratatoskr/web/server.py b/src/ratatoskr/web/server.py
index f5912b4..e6f505c 100644
--- a/src/ratatoskr/web/server.py
+++ b/src/ratatoskr/web/server.py
@@ -18,7 +18,12 @@ from importlib.metadata import version as _pkg_version
import httpx
from starlette.applications import Starlette
from starlette.requests import Request
-from starlette.responses import FileResponse, JSONResponse, StreamingResponse
+from starlette.responses import (
+ FileResponse,
+ JSONResponse,
+ Response,
+ StreamingResponse,
+)
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
@@ -35,9 +40,12 @@ from ratatoskr.sessions import (
create_session,
endpoint_for_plane,
get_persona_state,
+ get_session_bifrost,
+ get_session_tools,
list_agents,
)
from ratatoskr.sse_client import (
+ AdminEvent,
CancelAlreadyCompleted,
Cancelled,
CancelFailed,
@@ -50,6 +58,7 @@ from ratatoskr.sse_client import (
SseConnectionDropped,
TurnIdFlip,
cancel_turn,
+ stream_admin_events,
stream_turn_resilient,
)
@@ -416,6 +425,100 @@ async def _affect_state_endpoint(request: Request) -> JSONResponse:
return JSONResponse(r.json(), status_code=r.status_code)
+async def _session_tools_endpoint(request: Request) -> JSONResponse:
+ """GET /api/sessions/{session_id}/tools → owner-scoped tool inventory (spec #183).
+
+ Proxies get_session_tools with the client's CONSUMER bearer (no admin scope):
+ the merged {agent_id, builtin_tools, bifrost_tools} the LLM saw at turn-fire.
+ Any non-200 upstream → surfaced as a status-preserving error envelope."""
+ session_id = request.path_params["session_id"]
+ client_factory = request.app.state.client_factory
+ try:
+ async with client_factory() as client:
+ info = await get_session_tools(client, session_id)
+ except SessionApiFailed as exc:
+ return JSONResponse(
+ {"error_code": "session_tools_unavailable", "status": exc.status},
+ status_code=exc.status,
+ )
+ return JSONResponse(info, status_code=200)
+
+
+async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
+ """GET /api/sessions/{session_id}/bifrost → admin-scoped Bifrost dispatch state (#176).
+
+ The admin key is SERVER-HELD (app.state.admin_key) and never reaches the
+ browser (INV-003 precedent — upstream credentials stay server-side); the
+ wrapper overrides the Authorization header with it. Fail-visible when the
+ admin key isn't configured (never a silent empty pane)."""
+ session_id = request.path_params["session_id"]
+ admin_key = request.app.state.admin_key
+ if not admin_key: # PRE-001: fail-visible, never silent
+ return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
+ client_factory = request.app.state.client_factory
+ try:
+ async with client_factory() as client:
+ bstate = await get_session_bifrost(client, session_id, admin_key=admin_key)
+ except SessionApiFailed as exc:
+ return JSONResponse(
+ {"error_code": "bifrost_state_unavailable", "status": exc.status},
+ status_code=exc.status,
+ )
+ return JSONResponse(bstate, status_code=200)
+
+
+def _admin_event_matches_web(ev: AdminEvent, session_id: str | None) -> bool:
+ """AdminEvents filter (design-brief §6, mirrors the TUI): forward non-heartbeat
+ system.* (stream-integrity signals) + events for the active session; drop the
+ rest so the browser sees only session-relevant lifecycle, never the full
+ cross-session admin firehose."""
+ if ev.type == "system.heartbeat":
+ return False
+ if ev.type.startswith("system."):
+ return True
+ return session_id is not None and ev.data.get("session_id") == session_id
+
+
+async def _admin_events_endpoint(request: Request) -> Response:
+ """GET /api/admin/events?session_id=... → SSE proxy of GET /admin/events (#11).
+
+ The admin key is SERVER-HELD; the browser only ever receives the session-filtered
+ stream (never the key, never the cross-session firehose). Long-lived + best-effort:
+ a connect failure or mid-stream drop emits a labeled `stream_error` event and ends."""
+ admin_key = request.app.state.admin_key
+ if not admin_key: # PRE-001: fail-visible, never silent
+ return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
+ session_id = request.query_params.get("session_id")
+ client_factory = request.app.state.client_factory
+
+ async def gen() -> AsyncIterator[bytes]:
+ client = client_factory()
+ try:
+ async for ev in stream_admin_events(client, admin_key=admin_key):
+ if not _admin_event_matches_web(ev, session_id):
+ continue
+ # Fixed SSE event name so the browser renders EVERY admin type
+ # with one listener (no per-type enumeration → nothing silently
+ # dropped); the real dotted type rides in the payload.
+ yield _format_sse(
+ "admin_event",
+ {"id": ev.id, "type": ev.type, "timestamp": ev.timestamp,
+ "data": ev.data},
+ )
+ except (SseConnectFailed, SseConnectionDropped, MalformedSseId,
+ MalformedSseData) as exc:
+ yield _format_sse(
+ "stream_error",
+ {"exception": type(exc).__name__, "message": str(exc)},
+ )
+ except asyncio.CancelledError:
+ raise # browser disconnect — let the generator unwind
+ finally:
+ await client.aclose()
+
+ return StreamingResponse(gen(), media_type="text/event-stream")
+
+
def create_app(
client_factory: Callable[[], httpx.AsyncClient],
*,
@@ -423,6 +526,7 @@ def create_app(
bifrost_consumer_key: str | None = None,
bifrost_visible_host: str | None = None,
affect_read_url: str | None = None,
+ admin_key: str | None = None,
) -> Starlette:
"""Construct the Starlette app — wire routes + state per FN create_app.
@@ -483,6 +587,9 @@ def create_app(
Route("/api/sessions", _create_session_endpoint, methods=["POST"]),
Route("/api/agents/{agent_id}/persona_state", _persona_state_endpoint),
Route("/api/affect/{agent_id}", _affect_state_endpoint),
+ Route("/api/sessions/{session_id}/tools", _session_tools_endpoint),
+ Route("/api/sessions/{session_id}/bifrost", _session_bifrost_endpoint),
+ Route("/api/admin/events", _admin_events_endpoint),
Route("/api/turns/{session_id}", _submit_turn_endpoint, methods=["POST"]),
Route("/api/turns/{session_id}/stream", _stream_turn_endpoint),
Route("/api/turns/{session_id}/cancel", _cancel_turn_endpoint, methods=["POST"]),
@@ -498,6 +605,10 @@ def create_app(
# Issue #18 (Deliverable 2): the provider affect-read base URL (server→provider hop,
# same dev box) — distinct from the WT-visible host used for binding.
app.state.affect_read_url = affect_read_url
+ # Admin observability panes (BifrostState + AdminEvents): the admin key is
+ # SERVER-HELD (RATATOSKR_ADMIN_API_KEY) and never reaches the browser — the
+ # server proxies admin-scoped reads and forwards only the session-filtered result.
+ app.state.admin_key = admin_key
# INV-002: turn registry is in-process memory, keyed (session_id, turn_id)
app.state.turn_registry = {}
return app
diff --git a/src/ratatoskr/web/static/index.html b/src/ratatoskr/web/static/index.html
index 5be3a22..ae7dd30 100644
--- a/src/ratatoskr/web/static/index.html
+++ b/src/ratatoskr/web/static/index.html
@@ -231,6 +231,26 @@ body {
50% { content: "··"; } 75% { content: "···"; }
}
+/* reasoning indicator — a UI affordance in the transcript, visually distinct
+ from the agent's response (.response, left-bordered). Italic + a ✦ glyph so
+ it reads as "the app telling you inference is happening", never as engine
+ output. Ephemeral: appears on reasoning tokens, gone the moment real text
+ begins or the turn ends. */
+.thinking-note {
+ display: inline-flex; align-items: center; gap: 8px;
+ color: var(--blue); font-style: italic; font-size: 12px;
+ margin: 6px 0; padding-left: 18px; opacity: 0.9;
+ animation: rise 0.3s ease both;
+}
+.thinking-note::before {
+ content: "✦"; font-style: normal; color: var(--cyan);
+ text-shadow: 0 0 10px var(--glow-cyan);
+}
+.thinking-note::after {
+ content: ""; width: 16px; text-align: left;
+ animation: dots 1.4s steps(4, end) infinite;
+}
+
/* terminal status chips */
.chip {
display: inline-flex; align-items: center; gap: 7px;
@@ -487,6 +507,8 @@ body {
+
+
tools
@@ -497,6 +519,8 @@ body {
waiting for wire telemetry…
no chain-of-thought captured yet
persona state loads on session open
+
bifrost dispatch state loads on session open
+
admin lifecycle events stream on session open
@@ -542,7 +566,7 @@ body {
"use strict";
const $ = (id) => document.getElementById(id);
-const state = { sessionId: null, agentId: null, turnId: null, eventSource: null };
+const state = { sessionId: null, agentId: null, turnId: null, eventSource: null, lastAffectAt: null, adminES: null };
function esc(s) {
const d = document.createElement("div");
@@ -603,8 +627,46 @@ function markdownSafe(raw) {
// per-turn live buffers (reset at turn open)
const LIVE = { resp: "", think: "" };
+// ---- reasoning indicator (a UI affordance — NOT engine output) ----------
+// When the model streams reasoning/chain-of-thought, show an ephemeral
+// " is pondering…" line in the transcript so the user knows inference
+// is happening. Rotates phrasing for liveliness; removed the instant real text
+// begins or the turn ends. agentDisplayName is rendered via textContent (never
+// innerHTML) so an adversarial agent_id can't inject markup (INV-004).
+const THINK_PHRASES = ["is thinking", "is pondering", "appears thoughtful",
+ "is reasoning", "is turning it over"];
+let thinkRotator = null;
+function agentDisplayName() {
+ if (!state.agentId) return "the agent";
+ const tail = String(state.agentId).split(":").pop() || "the agent";
+ return tail.charAt(0).toUpperCase() + tail.slice(1);
+}
+function showThinkingNote() {
+ // reasoning tokens ARE the first tokens — supersede the awaiting-first heartbeat
+ const aw = document.querySelector("#transcript .awaiting.live");
+ if (aw) aw.remove();
+ let el = document.querySelector("#transcript .thinking-note");
+ if (!el) {
+ el = document.createElement("div");
+ el.className = "thinking-note";
+ el.appendChild(document.createTextNode(""));
+ $("transcript").appendChild(el);
+ let i = 0;
+ const paint = () => { el.firstChild.textContent =
+ `${agentDisplayName()} ${THINK_PHRASES[i % THINK_PHRASES.length]}`; i++; };
+ paint();
+ thinkRotator = setInterval(paint, 2600);
+ }
+ $("transcript").scrollTop = $("transcript").scrollHeight;
+}
+function hideThinkingNote() {
+ if (thinkRotator) { clearInterval(thinkRotator); thinkRotator = null; }
+ const el = document.querySelector("#transcript .thinking-note");
+ if (el) el.remove();
+}
+
// ---- pane helpers ----
-const PANE_BADGE = { tools: 0, debug: 0, thinking: 0 };
+const PANE_BADGE = { tools: 0, debug: 0, thinking: 0, admin: 0 };
function bumpBadge(pane) {
if (!(pane in PANE_BADGE)) return;
PANE_BADGE[pane] += 1;
@@ -773,6 +835,7 @@ async function loadAffect(agentId) {
const snap = await r.json();
renderAffectPane(snap);
setPersonaStrip(snap); // pad bars are the live signal
+ state.lastAffectAt = snap.emitted_at || state.lastAffectAt; // post-turn poll stop-signal
} else {
let code = "";
try { code = (await r.json()).error_code || ""; } catch (_) {}
@@ -794,6 +857,93 @@ async function loadAffect(agentId) {
}
}
+// ---- tools inventory (#183): what the LLM HAS at turn-fire (static), rendered
+// at the TOP of the tools pane; live tool_start/result events append below it. ---
+function renderToolsInventory(inv) {
+ const row = (k, v) => `${esc(k)} ${esc(v)}
`;
+ const head = (t) => `${esc(t)}
`;
+ const names = (arr) => (arr || []).map((t) => (typeof t === "string" ? t : (t && t.name) || "?"));
+ const builtin = inv.builtin_tools || [], bifrost = inv.bifrost_tools || [];
+ const html =
+ head("tool inventory · " + (inv.agent_id || "?")) +
+ row("builtin (" + builtin.length + ")", names(builtin).join(", ") || "none") +
+ row("bifrost (" + bifrost.length + ")", names(bifrost).join(", ") || "none") +
+ `— live tool events —
`;
+ const pane = $("pane-tools");
+ const empty = pane.querySelector(".empty");
+ if (empty) empty.remove();
+ let block = pane.querySelector(".tools-inventory");
+ if (!block) {
+ block = document.createElement("div");
+ block.className = "tools-inventory";
+ pane.insertBefore(block, pane.firstChild);
+ }
+ block.innerHTML = html;
+}
+async function loadSessionTools(sessionId) {
+ try {
+ const r = await fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/tools");
+ if (r.status === 200) renderToolsInventory(await r.json());
+ // non-200 → best-effort hydrate; leave the live tool pane as-is (mirrors TUI)
+ } catch (_) {}
+}
+
+// ---- Bifrost dispatch state (#176): admin-scoped, server-proxied (admin key
+// stays server-side; the browser only receives the state). ----
+function renderBifrostState(b) {
+ const row = (k, v) => `${esc(k)} ${esc(v)}
`;
+ const head = (t) => `${esc(t)}
`;
+ const tools = b.tools || [];
+ $("pane-bifrost").innerHTML =
+ head("bifrost dispatch state") +
+ row("endpoint", b.endpoint_url || "?") +
+ row("consumer", b.consumer_id || "?") +
+ row("connected", JSON.stringify(b.connected)) +
+ row("caps", (b.capabilities_granted || []).join(", ") || "none") +
+ `
` + head("tools (" + tools.length + ")") +
+ (tools.map((t) => row("·", (t.name || "?") + (t.description ? " — " + t.description : ""))).join("")
+ || `none
`);
+}
+async function loadBifrostState(sessionId) {
+ try {
+ const r = await fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/bifrost");
+ if (r.status === 200) { renderBifrostState(await r.json()); return; }
+ let code = ""; try { code = (await r.json()).error_code || ""; } catch (_) {}
+ let msg;
+ if (code === "admin_key_not_configured") msg = "bifrost state needs the readonly-admin key (RATATOSKR_ADMIN_API_KEY) server-side.";
+ else if (r.status === 404) msg = "session is not Bifrost-bound (no live dispatch client).";
+ else if (r.status === 403) msg = "admin key lacks the admin.sessions.read scope.";
+ else msg = `bifrost state unavailable (HTTP ${esc(r.status)}${code ? " · " + esc(code) : ""}).`;
+ $("pane-bifrost").innerHTML = `${msg}
`;
+ } catch (_) {
+ $("pane-bifrost").innerHTML = `bifrost state fetch failed
`;
+ }
+}
+
+// ---- Admin lifecycle events (#11): admin-scoped SSE, session-filtered SERVER-side.
+// One fixed "admin_event" listener renders every type; the dotted type is in data. ---
+function openAdminEvents(sessionId) {
+ if (state.adminES) { state.adminES.close(); state.adminES = null; }
+ const es = new EventSource("/api/admin/events?session_id=" + encodeURIComponent(sessionId));
+ state.adminES = es;
+ es.addEventListener("admin_event", (e) => {
+ let d; try { d = JSON.parse(e.data); } catch (_) { return; }
+ appendTo("pane-admin",
+ `[${ts()}] ${esc(d.type || "event")} `
+ + `${esc(JSON.stringify(d.data || {}))}
`);
+ });
+ es.addEventListener("stream_error", (e) => {
+ let d = {}; try { d = JSON.parse(e.data); } catch (_) {}
+ appendTo("pane-admin", `— admin stream ended: ${esc(d.exception || "error")} —
`);
+ });
+ es.onerror = () => {
+ const pane = $("pane-admin");
+ if (pane.querySelector(".empty")) {
+ pane.innerHTML = `admin stream unavailable — needs the readonly-admin key + admin.events.read scope.
`;
+ }
+ };
+}
+
// ---- session lifecycle ----
async function startSession() {
const agentId = $("agent-picker").value;
@@ -837,6 +987,11 @@ async function startSession() {
$("setup").style.display = "none";
$("workspace").classList.add("live");
await loadPersona(agentId);
+ // Admin/debug surfaces — best-effort hydrate + live stream (all self-render on
+ // failure; the admin key is server-held, never sent from here).
+ loadSessionTools(state.sessionId);
+ loadBifrostState(state.sessionId);
+ openAdminEvents(state.sessionId);
$("prompt-input").focus();
} catch (e) {
$("setup-err").textContent = "network error opening session";
@@ -943,11 +1098,13 @@ async function submitPrompt() {
es.addEventListener("thinking", (e) => {
const d = JSON.parse(e.data);
thinkingDeltas += 1;
+ showThinkingNote(); // ephemeral " is pondering…" in the transcript
appendThinking(d.content);
});
es.addEventListener("text", (e) => {
const d = JSON.parse(e.data);
textDeltas += 1;
+ hideThinkingNote(); // real text begins — reasoning display is done
appendResponse(d.content);
});
es.addEventListener("text_boundary", (e) => {
@@ -990,6 +1147,7 @@ async function submitPrompt() {
function terminal(label, cls, e) {
const aw = document.querySelector("#transcript .awaiting.live");
if (aw) aw.remove();
+ hideThinkingNote();
finalizeResponse();
document.querySelectorAll("#pane-thinking .think-live").forEach((b) => b.classList.remove("think-live"));
let meta = "";
@@ -1006,9 +1164,18 @@ async function submitPrompt() {
$("composer").classList.remove("streaming");
setConn(cls === "error" ? "error" : "idle", cls === "error" ? "error" : "connected");
if (cls === "done" && state.agentId) {
- // Tier-3 affect.emit is POST-TURN ASYNC — it lands in our store a couple seconds
- // after [done]. Refresh the pane on a short delay to catch the new PAD (issue #18).
- setTimeout(() => loadPersona(state.agentId), 2000);
+ // Tier-3 affect.emit is POST-TURN ASYNC and can land well after [done] — a single
+ // fixed refresh races it (issue #18 foot-gun). Poll a short window, stopping once
+ // the snapshot's emitted_at advances past the pre-turn value (or a new turn starts).
+ const beforeAt = state.lastAffectAt;
+ let settled = false;
+ for (const delay of [1500, 3500, 6500, 10500]) {
+ setTimeout(async () => {
+ if (settled || state.turnId) return;
+ await loadPersona(state.agentId);
+ if (state.lastAffectAt && state.lastAffectAt !== beforeAt) settled = true;
+ }, delay);
+ }
}
$("prompt-input").focus();
}
@@ -1053,7 +1220,7 @@ document.querySelectorAll(".tab").forEach((t) =>
// ---- keyboard ----
document.addEventListener("keydown", (e) => {
- if (e.ctrlKey && ["1", "2", "3", "4"].includes(e.key)) {
+ if (e.ctrlKey && ["1", "2", "3", "4", "5", "6"].includes(e.key)) {
const tabs = document.querySelectorAll(".tab");
const idx = parseInt(e.key, 10) - 1;
if (tabs[idx]) { activateTab(tabs[idx]); e.preventDefault(); }
diff --git a/tests/test_web_server.py b/tests/test_web_server.py
index 641065e..35cc8a1 100644
--- a/tests/test_web_server.py
+++ b/tests/test_web_server.py
@@ -1062,3 +1062,123 @@ class TestAffectStateEndpoint:
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
assert resp.status_code == 400
assert resp.json()["error_code"] == "missing_end_user_id"
+
+
+class TestSessionToolsEndpoint:
+ """session_tools_endpoint — proxy owner-scoped GET /sessions/{id}/tools (#183)."""
+
+ @respx.mock
+ def test_happy_returns_inventory(self) -> None:
+ """happy [tracer]: 200 inventory → 200 verbatim."""
+ respx.get("https://w.example/sessions/s-1/tools").mock(
+ return_value=httpx.Response(200, json={
+ "agent_id": "ratatoskr:sindra",
+ "builtin_tools": ["echo"],
+ "bifrost_tools": [{"name": "memory.search"}],
+ })
+ )
+ from ratatoskr.web.server import create_app
+ resp = TestClient(create_app(_mock_client_factory())).get("/api/sessions/s-1/tools")
+ assert resp.status_code == 200
+ assert resp.json()["agent_id"] == "ratatoskr:sindra"
+
+ @respx.mock
+ def test_upstream_404_status_preserving_envelope(self) -> None:
+ """error: upstream 404 → 404 session_tools_unavailable envelope."""
+ respx.get("https://w.example/sessions/s-1/tools").mock(
+ return_value=httpx.Response(404, content=b"nope")
+ )
+ from ratatoskr.web.server import create_app
+ resp = TestClient(create_app(_mock_client_factory())).get("/api/sessions/s-1/tools")
+ assert resp.status_code == 404
+ assert resp.json()["error_code"] == "session_tools_unavailable"
+
+
+class TestSessionBifrostEndpoint:
+ """session_bifrost_endpoint — proxy admin-scoped GET /admin/sessions/{id}/bifrost (#176)."""
+
+ @respx.mock
+ def test_happy_overrides_with_admin_bearer(self) -> None:
+ """happy [tracer]: 200 state → 200; request carries the ADMIN bearer, not consumer."""
+ route = respx.get("https://w.example/admin/sessions/s-1/bifrost").mock(
+ return_value=httpx.Response(200, json={
+ "endpoint_url": "http://x:8392", "connected": True,
+ "capabilities_granted": ["memory", "affect"], "tools": [],
+ })
+ )
+ from ratatoskr.web.server import create_app
+ app = create_app(_mock_client_factory(), admin_key="adm-key")
+ resp = TestClient(app).get("/api/sessions/s-1/bifrost")
+ assert resp.status_code == 200
+ assert resp.json()["connected"] is True
+ assert route.calls.last.request.headers["Authorization"] == "Bearer adm-key"
+
+ def test_no_admin_key_fails_visible_400(self) -> None:
+ """error: no admin key configured → 400 admin_key_not_configured, no upstream call."""
+ from ratatoskr.web.server import create_app
+ app = create_app(_mock_client_factory()) # no admin_key
+ resp = TestClient(app).get("/api/sessions/s-1/bifrost")
+ assert resp.status_code == 400
+ assert resp.json()["error_code"] == "admin_key_not_configured"
+
+ @respx.mock
+ def test_upstream_404_status_preserving_envelope(self) -> None:
+ """error: upstream 404 (not bound) → 404 bifrost_state_unavailable envelope."""
+ respx.get("https://w.example/admin/sessions/s-1/bifrost").mock(
+ return_value=httpx.Response(404, content=b"nope")
+ )
+ from ratatoskr.web.server import create_app
+ app = create_app(_mock_client_factory(), admin_key="adm-key")
+ resp = TestClient(app).get("/api/sessions/s-1/bifrost")
+ assert resp.status_code == 404
+ assert resp.json()["error_code"] == "bifrost_state_unavailable"
+
+
+class TestAdminEventsEndpoint:
+ """admin_events_endpoint — SSE proxy of GET /admin/events, session-filtered (#11)."""
+
+ def test_filter_semantics(self) -> None:
+ """unit: heartbeats drop, system.* pass, else match on session_id."""
+ from ratatoskr.sse_client import AdminEvent
+ from ratatoskr.web.server import _admin_event_matches_web
+
+ def mk(t: str, sid: "str | None" = None) -> AdminEvent:
+ return AdminEvent(id=1, type=t, timestamp=None,
+ data={"session_id": sid} if sid else {})
+
+ assert _admin_event_matches_web(mk("system.heartbeat"), "s-1") is False
+ assert _admin_event_matches_web(mk("system.degraded"), "s-1") is True
+ assert _admin_event_matches_web(mk("session.created", "s-1"), "s-1") is True
+ assert _admin_event_matches_web(mk("session.created", "other"), "s-1") is False
+ assert _admin_event_matches_web(mk("session.created", "s-1"), None) is False
+
+ def test_no_admin_key_fails_visible_400(self) -> None:
+ """error: no admin key → 400 admin_key_not_configured (no stream opened)."""
+ from ratatoskr.web.server import create_app
+ app = create_app(_mock_client_factory())
+ resp = TestClient(app).get("/api/admin/events?session_id=s-1")
+ assert resp.status_code == 400
+ assert resp.json()["error_code"] == "admin_key_not_configured"
+
+ @respx.mock
+ def test_streams_filtered_events_fixed_name(self) -> None:
+ """happy: SSE → only session-matching + system.* forwarded, as `admin_event`."""
+ stream = (
+ b'event: session.created\n'
+ b'data: {"type":"session.created","data":{"session_id":"s-1"}}\n\n'
+ b'event: system.heartbeat\n'
+ b'data: {"type":"system.heartbeat","data":{}}\n\n'
+ b'event: turn.started\n'
+ b'data: {"type":"turn.started","data":{"session_id":"other"}}\n\n'
+ b'event: system.degraded\n'
+ b'data: {"type":"system.degraded","data":{}}\n\n'
+ )
+ respx.get("https://w.example/admin/events").mock(return_value=_sse_resp(stream))
+ from ratatoskr.web.server import create_app
+ app = create_app(_mock_client_factory(), admin_key="adm-key")
+ body = TestClient(app).get("/api/admin/events?session_id=s-1").text
+ assert "event: admin_event" in body # fixed browser-facing name
+ assert '"type": "session.created"' in body # matches active session → forwarded
+ assert "system.degraded" in body # system.* → forwarded
+ assert "system.heartbeat" not in body # heartbeat → dropped
+ assert "turn.started" not in body # other session → dropped
diff --git a/uv.lock b/uv.lock
index 1f94ece..b1d7db6 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1052,7 +1052,7 @@ wheels = [
[[package]]
name = "ratatoskr"
-version = "0.19.1"
+version = "0.19.2"
source = { editable = "." }
dependencies = [
{ name = "httpx" },