feat(#11): AdminEvents pane — GET /admin/events SSE (session-filtered)
v1 coverage-audit: the last unbuilt design-brief §5 debug pane. #11's blocker was already satisfied (admin key carries admin.events.read). Completes the admin/debug-observability core. - sse_client.py: AdminEvent dataclass + stream_admin_events — a new long-lived SSE consumer for the admin lifecycle stream (envelope {id,type,timestamp,data}), admin-scoped (bearer-override), Last-Event-ID resume. non-200 -> SseConnectFailed; mid-drop -> SseConnectionDropped. - tui.py: "AdminEvents" TabPane + _format_admin_event + _admin_event_matches (design-brief §6 filter: active-session + non-heartbeat system.*) + _stream_admin_events long-lived best-effort worker (unconditional on_mount; self-labels not-configured / unavailable / stream-ended). - Contract-skipped for stream_admin_events (out of #1's turn-SSE scope; spec § Admin Event Stream is the reference). TDD: 4 sse_client + 5 tui tests. Suite 561 green. - LIVE-AUTH-PROVEN on :8081 (GET /admin/events -> HTTP 200 under admin key). Coverage: REST 12/40. Tier 1 debug-observability core complete.
This commit is contained in:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user