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" },