"""Tests for ratatoskr.web.server per docs/contracts/issues/16.contract.md. The TestClient drives the Starlette app with a respx-mocked upstream httpx client. No live network. See INV-002: create_app takes a client_factory callable; tests pass a factory returning a respx-mocked AsyncClient. """ import httpx import pytest import respx from starlette.testclient import TestClient def _mock_client_factory() -> "object": """A client_factory that returns a no-base-url AsyncClient suitable for respx-mocking absolute URLs. Endpoints that hit upstream use the same factory in tests as in prod; respx intercepts at the transport layer. """ def factory() -> httpx.AsyncClient: return httpx.AsyncClient(base_url="https://w.example") return factory class TestVersionEndpoint: """version_endpoint FN — tracer per contract issue #16.""" def test_happy_returns_current_version(self) -> None: """happy [tracer]: GET /version → 200, body == {"ratatoskr": ""}. Validates: Starlette app boots, route registers, JSON shape correct, version derived from package metadata (importlib.metadata). """ from importlib.metadata import version from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) client = TestClient(app) resp = client.get("/version") assert resp.status_code == 200 assert resp.json() == {"ratatoskr": version("ratatoskr")} class TestAgentsEndpoint: """agents_endpoint FN — proxy upstream /agents + merge with local tier3 index.""" @respx.mock def test_happy_merges_upstream_and_local(self, monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: """happy [tracer]: respx mock /agents 200 → response merges upstream + local index.""" # Isolate local agents index to tmp monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json")) respx.get("https://w.example/agents").mock( return_value=httpx.Response( 200, json=[ {"agent_id": "mimir", "name": "Mimir", "description": "k"}, ], ) ) # Add one local tier3 agent to the index from ratatoskr.local_agents import LocalAgentEntry, add_local_agent add_local_agent( LocalAgentEntry( agent_id="ratatoskr:sindra", agent_name="sindra", model="artemis-31b-v1i", description="(tier 3) IDENTITY", defined_at="2026-05-28T00:00:00+00:00", ) ) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) client = TestClient(app) resp = client.get("/api/agents") assert resp.status_code == 200 body = resp.json() ids = [a["agent_id"] for a in body] assert "mimir" in ids assert "ratatoskr:sindra" in ids @respx.mock def test_upstream_500_returns_500_envelope(self, monkeypatch, tmp_path) -> None: """upstream_500 [error]: respx 500 → 500 with error_code envelope.""" monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json")) respx.get("https://w.example/agents").mock(return_value=httpx.Response(500, content=b"boom")) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) resp = TestClient(app).get("/api/agents") assert resp.status_code == 500 assert resp.json()["error_code"] == "session_api_failed" @respx.mock def test_network_error_returns_502(self, monkeypatch, tmp_path) -> None: """network_error [error]: connection refused → 502 with network_error envelope.""" monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json")) respx.get("https://w.example/agents").mock(side_effect=httpx.ConnectError("refused")) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) resp = TestClient(app).get("/api/agents") assert resp.status_code == 502 assert resp.json()["error_code"] == "network_error" @respx.mock def test_local_dedup(self, monkeypatch, tmp_path) -> None: """local_dedup [scenario]: local entry with same agent_id as upstream → no duplicate.""" monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json")) respx.get("https://w.example/agents").mock( return_value=httpx.Response( 200, json=[{"agent_id": "ratatoskr:sindra", "name": "Sindra-from-server", "description": ""}], ) ) from ratatoskr.local_agents import LocalAgentEntry, add_local_agent add_local_agent( LocalAgentEntry( agent_id="ratatoskr:sindra", agent_name="sindra", model="m", description="local-tier3", defined_at="2026-05-28T00:00:00+00:00", ) ) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) resp = TestClient(app).get("/api/agents") assert resp.status_code == 200 body = resp.json() ids = [a["agent_id"] for a in body] assert ids.count("ratatoskr:sindra") == 1 # Upstream entry wins (it's first in the merge); local is deduped assert body[0]["name"] == "Sindra-from-server" _CREATE_OK = { "session_id": "s-1", "agent_id": "mimir", "message_count": 0, "created_at": "2026-05-28T00:00:00+00:00", "last_active": "2026-05-28T00:00:00+00:00", "metadata": {}, } class TestCreateSessionEndpoint: """create_session_endpoint FN — proxy POST /sessions to upstream.""" @respx.mock def test_happy_returns_201(self) -> None: """happy [tracer]: respx mock 201 → endpoint returns 201 with session JSON.""" respx.post("https://w.example/sessions").mock(return_value=httpx.Response(201, json=_CREATE_OK)) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) resp = TestClient(app).post("/api/sessions", json={"agent_id": "mimir"}) assert resp.status_code == 201 assert resp.json()["session_id"] == "s-1" @respx.mock def test_unknown_agent_returns_404(self) -> None: """unknown_agent [error]: respx 404 → 404 with agent_not_found envelope.""" respx.post("https://w.example/sessions").mock( return_value=httpx.Response(404, json={"error": "unknown_agent_id"}) ) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) resp = TestClient(app).post("/api/sessions", json={"agent_id": "ghost"}) assert resp.status_code == 404 assert resp.json()["error_code"] == "agent_not_found" def test_missing_agent_id_returns_400(self) -> None: """missing_agent_id [adversarial]: body without agent_id → 400.""" from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) resp = TestClient(app).post("/api/sessions", json={}) assert resp.status_code == 400 _SNAPSHOT = { "agent_id": "mimir", "pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5}, "dominant_emotion": "curiosity", } class TestPersonaStateEndpoint: """persona_state_endpoint FN — proxy upstream GET /agents/{id}/persona_state.""" @respx.mock def test_happy_returns_snapshot(self) -> None: """happy [tracer]: respx 200 → 200 with snapshot.""" respx.get("https://w.example/agents/mimir/persona_state").mock( return_value=httpx.Response(200, json=_SNAPSHOT) ) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) resp = TestClient(app).get("/api/agents/mimir/persona_state") assert resp.status_code == 200 assert resp.json()["dominant_emotion"] == "curiosity" @respx.mock def test_persona_not_configured(self) -> None: """persona_not_configured [error]: 404 + persona_not_configured → 404 envelope.""" respx.get("https://w.example/agents/domari/persona_state").mock( return_value=httpx.Response(404, json={"error_code": "persona_not_configured"}) ) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) resp = TestClient(app).get("/api/agents/domari/persona_state") assert resp.status_code == 404 assert resp.json()["error_code"] == "persona_not_configured" @respx.mock def test_agent_not_available(self) -> None: """agent_not_available [error]: 404 + agent_not_available → 404 envelope.""" respx.get("https://w.example/agents/bogus/persona_state").mock( return_value=httpx.Response(404, json={"error_code": "agent_not_available"}) ) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) resp = TestClient(app).get("/api/agents/bogus/persona_state") assert resp.status_code == 404 assert resp.json()["error_code"] == "agent_not_available" @respx.mock def test_auth_scope_denied(self) -> None: """auth_scope_denied [error]: 403 + auth_scope_denied → 403 envelope.""" respx.get("https://w.example/agents/mimir/persona_state").mock( return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"}) ) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) resp = TestClient(app).get("/api/agents/mimir/persona_state") assert resp.status_code == 403 assert resp.json()["error_code"] == "auth_scope_denied" class TestSubmitTurnEndpoint: """submit_turn_endpoint FN — allocate turn_id, register in turn_registry.""" def test_happy_returns_turn_id(self) -> None: """happy [tracer]: POST {"content": "hi"} → 200 with turn_id; registry populated.""" from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) resp = TestClient(app).post("/api/turns/s-1", json={"content": "hello"}) assert resp.status_code == 200 body = resp.json() assert isinstance(body["turn_id"], int) assert body["turn_id"] > 0 # Registry has the entry handle = app.state.turn_registry[("s-1", body["turn_id"])] assert handle.content == "hello" assert handle.status == "queued" def test_missing_content_returns_400(self) -> None: """missing_content [adversarial]: body without content → 400.""" from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) resp = TestClient(app).post("/api/turns/s-1", json={}) assert resp.status_code == 400 def test_monotonic_turn_ids(self) -> None: """monotonic_turn_ids [trace]: two submits → second turn_id > first.""" from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) c = TestClient(app) first = c.post("/api/turns/s-1", json={"content": "a"}).json()["turn_id"] second = c.post("/api/turns/s-2", json={"content": "b"}).json()["turn_id"] assert second > first _DONE_BODY = { "type": "done", "phase": "succeeded", "response": "hello", "model": "m", "duration_ms": 1, "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "cached_input_tokens": 0}, } def _sse_chunk(sse_id: str, body: dict) -> bytes: import json as _j return f"id: {sse_id}\ndata: {_j.dumps(body)}\n\n".encode() def _sse_resp(stream: bytes) -> httpx.Response: return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=stream) def _sse_resp_stream(body: httpx.AsyncByteStream) -> httpx.Response: """SSE response backed by a live AsyncByteStream (for gated/hanging streams in disconnect tests).""" return httpx.Response( 200, headers={"content-type": "text/event-stream"}, stream=body ) def _parse_browser_sse(raw: bytes) -> list[dict]: """Parse a server-to-browser SSE stream into [{"event": str, "data": dict}, ...].""" import json as _j events: list[dict] = [] for block in raw.decode().split("\n\n"): block = block.strip() if not block: continue event_type = None data_str = None for line in block.splitlines(): if line.startswith("event: "): event_type = line[len("event: "):] elif line.startswith("data: "): data_str = line[len("data: "):] if event_type and data_str is not None: events.append({"event": event_type, "data": _j.loads(data_str)}) return events class TestStreamTurnEndpoint: """stream_turn_endpoint FN — open upstream SSE, proxy events to browser.""" @respx.mock def test_happy_text_done(self) -> None: """happy [tracer]: respx mock one text+done → SSE stream yields text + done events.""" stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk("42:2", _DONE_BODY) respx.post("https://w.example/sessions/s-1/messages").mock(return_value=_sse_resp(stream)) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) c = TestClient(app) turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"] with c.stream("GET", f"/api/turns/s-1/stream?turn_id={turn_id}") as resp: assert resp.status_code == 200 raw = b"".join(resp.iter_bytes()) events = _parse_browser_sse(raw) types = [e["event"] for e in events] assert "text" in types assert "done" in types # Registry cleaned up assert ("s-1", turn_id) not in app.state.turn_registry def test_unknown_turn_returns_404(self) -> None: """unknown_turn [error]: GET with turn_id not in registry → 404.""" from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) resp = TestClient(app).get("/api/turns/s-x/stream?turn_id=999") assert resp.status_code == 404 @respx.mock def test_upstream_error_synthetic_event(self) -> None: """upstream_error [error]: respx 500 → synthetic error SSE event.""" respx.post("https://w.example/sessions/s-1/messages").mock( return_value=httpx.Response(500, content=b"boom") ) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) c = TestClient(app) turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"] with c.stream("GET", f"/api/turns/s-1/stream?turn_id={turn_id}") as resp: raw = b"".join(resp.iter_bytes()) events = _parse_browser_sse(raw) types = [e["event"] for e in events] assert "error" in types # Surfaces upstream's exception type for the operator err = next(e for e in events if e["event"] == "error") assert err["data"]["exception"] == "SseConnectFailed" _CANCEL_OK = {"turn_id": 42, "cancelled": True, "reason": None, "partial_message_id": None} class TestCancelTurnEndpoint: """cancel_turn_endpoint FN — proxy upstream cancel for registered turn.""" @respx.mock def test_happy_cancels(self) -> None: """happy [tracer]: registered turn (upstream started) → POST cancel → 200, upstream cancel called against the upstream turn_id. """ from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) c = TestClient(app) turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"] # Simulate that the upstream stream has started (turn_id 42 upstream). app.state.turn_registry[("s-1", turn_id)].status = "streaming" app.state.turn_registry[("s-1", turn_id)].upstream_turn_id = 42 respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( return_value=httpx.Response(200, json={**_CANCEL_OK, "turn_id": 42}) ) resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}") assert resp.status_code == 200 assert resp.json()["cancelled"] is True assert ("s-1", turn_id) not in app.state.turn_registry def test_unknown_turn_returns_404(self) -> None: """unknown_turn [error]: not in registry → 404.""" from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) resp = TestClient(app).post("/api/turns/s-x/cancel?turn_id=999") assert resp.status_code == 404 @respx.mock def test_already_completed_race(self) -> None: """already_completed [race]: upstream 409 → 200 reason=race_or_completed.""" from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) c = TestClient(app) turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"] app.state.turn_registry[("s-1", turn_id)].status = "streaming" app.state.turn_registry[("s-1", turn_id)].upstream_turn_id = 42 respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( return_value=httpx.Response(409) ) resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}") assert resp.status_code == 200 assert resp.json()["cancelled"] is False assert resp.json()["reason"] == "race_or_completed" @respx.mock def test_cancel_failed_500(self) -> None: """cancel_failed [error]: upstream 500 → 500 with cancel_failed envelope.""" from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) c = TestClient(app) turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"] app.state.turn_registry[("s-1", turn_id)].status = "streaming" app.state.turn_registry[("s-1", turn_id)].upstream_turn_id = 42 respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( return_value=httpx.Response(500, content=b"boom") ) resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}") assert resp.status_code == 500 assert resp.json()["error_code"] == "cancel_failed" assert ("s-1", turn_id) not in app.state.turn_registry class TestStaticServing: """root_endpoint FN + /static mount — index.html + static asset serving.""" def test_root_returns_html(self) -> None: """happy [tracer]: GET / → 200, content-type text/html, body contains ' None: """happy [tracer]: 2 in-flight turns + shutdown → upstream cancels called.""" from ratatoskr.web.server import TurnHandle, create_app cancel_routes = [] for tid in (101, 102): cancel_routes.append( respx.post(f"https://w.example/sessions/s-1/turns/{tid}/cancel").mock( return_value=httpx.Response(200, json={ "turn_id": tid, "cancelled": True, "reason": None, "partial_message_id": None, }) ) ) app = create_app(_mock_client_factory()) with TestClient(app) as client: # Two turns in-flight (status=streaming, upstream_turn_id set = # local tid here for test simplicity). for tid in (101, 102): app.state.turn_registry[("s-1", tid)] = TurnHandle( session_id="s-1", turn_id=tid, content="x", status="streaming", upstream_turn_id=tid, ) # The exit of the `with` triggers lifespan shutdown # After lifespan shutdown: for route in cancel_routes: assert route.called, "upstream cancel should have been issued for each in-flight turn" assert app.state.turn_registry == {} class TestUpstreamTurnIdCancel: """v0.16.0 — cancel paths must target the UPSTREAM turn_id, not the browser-local turn_id. The local _TURN_COUNTER allocates 1,2,3…; the real upstream turn_id only arrives via the first SSE event's sse_id.turn_id. Heid panel (Hulda) load-bearing finding. """ @respx.mock def test_cancel_targets_upstream_turn_id(self) -> None: """A registered handle whose local turn_id (1) differs from its captured upstream_turn_id (42) → POST cancel hits the UPSTREAM URL /sessions/s-1/turns/42/cancel, not /turns/1/cancel. """ from ratatoskr.web.server import TurnHandle, create_app # Only the upstream-id cancel URL is mocked. If the code uses the # local id (1), it'll miss this route → the test catches the bug. upstream_route = respx.post( "https://w.example/sessions/s-1/turns/42/cancel" ).mock(return_value=httpx.Response(200, json={ "turn_id": 42, "cancelled": True, "reason": None, "partial_message_id": None, })) app = create_app(_mock_client_factory()) app.state.turn_registry[("s-1", 1)] = TurnHandle( session_id="s-1", turn_id=1, content="x", status="streaming", upstream_turn_id=42, ) resp = TestClient(app).post("/api/turns/s-1/cancel?turn_id=1") assert resp.status_code == 200 assert resp.json()["cancelled"] is True assert upstream_route.called, "cancel must target the upstream turn_id" assert ("s-1", 1) not in app.state.turn_registry def test_cancel_before_upstream_started_is_noop(self) -> None: """A handle with upstream_turn_id still None (turn never opened the upstream stream) → cancel is a no-op: 200 {cancelled: false, reason: not_started}, no upstream call, registry cleaned. """ from ratatoskr.web.server import TurnHandle, create_app app = create_app(_mock_client_factory()) app.state.turn_registry[("s-1", 1)] = TurnHandle( session_id="s-1", turn_id=1, content="x", status="queued", upstream_turn_id=None, ) resp = TestClient(app).post("/api/turns/s-1/cancel?turn_id=1") assert resp.status_code == 200 assert resp.json()["cancelled"] is False assert resp.json()["reason"] == "not_started" assert ("s-1", 1) not in app.state.turn_registry @respx.mock def test_stream_captures_upstream_turn_id(self) -> None: """The stream generator captures upstream turn_id from the first event's sse_id. After a happy text+done stream against upstream turn 42 (local turn 1), the cancel mid-stream would have targeted 42. Verified indirectly: drive the stream, assert events carry turn 42. """ stream = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk("42:2", _DONE_BODY) respx.post("https://w.example/sessions/s-1/messages").mock(return_value=_sse_resp(stream)) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) c = TestClient(app) turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"] assert turn_id == 1 or turn_id > 0 # local counter with c.stream("GET", f"/api/turns/s-1/stream?turn_id={turn_id}") as resp: raw = b"".join(resp.iter_bytes()) events = _parse_browser_sse(raw) # Every browser-facing event carries the upstream turn_id (42), not local text_ev = next(e for e in events if e["event"] == "text") assert text_ev["data"]["sse_id"].startswith("42:") class TestServerSideEndUserId: """v0.16.0 — end_user_id is server-configured (RATATOSKR_END_USER_ID via create_app), NOT accepted from the browser body. Heid panel finding + contract FN main STEPS 2. """ @respx.mock def test_create_session_uses_server_end_user_id(self) -> None: """create_app(end_user_id=...) → POST /api/sessions threads that id into the upstream POST body even when the browser sends none. """ import json as _j route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, json={**_CREATE_OK, "agent_id": "lofn"}) ) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory(), end_user_id="ratatoskr-tui") resp = TestClient(app).post("/api/sessions", json={"agent_id": "lofn"}) assert resp.status_code == 201 sent = _j.loads(route.calls[0].request.content) assert sent == {"agent_id": "lofn", "end_user_id": "ratatoskr-tui"} @respx.mock def test_create_session_ignores_body_end_user_id(self) -> None: """A browser-supplied end_user_id is IGNORED — the server's configured value wins. Prevents a client from impersonating an arbitrary end-user partition. """ import json as _j route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, json={**_CREATE_OK, "agent_id": "lofn"}) ) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory(), end_user_id="ratatoskr-tui") TestClient(app).post( "/api/sessions", json={"agent_id": "lofn", "end_user_id": "attacker"} ) sent = _j.loads(route.calls[0].request.content) assert sent.get("end_user_id") == "ratatoskr-tui" @respx.mock def test_create_session_no_end_user_id_when_unset(self) -> None: """When create_app gets no end_user_id, the upstream body omits it (matches create_session's default-omit shape).""" import json as _j route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, json=_CREATE_OK) ) from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) # no end_user_id TestClient(app).post("/api/sessions", json={"agent_id": "mimir"}) sent = _j.loads(route.calls[0].request.content) assert "end_user_id" not in sent class TestCreateAppShape: """create_app FN — route registration + state wiring (contract TESTS).""" def test_routes_registered(self) -> None: """routes_registered [tracer]: app.routes contains all path patterns, including the #18 affect-read proxy.""" from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) paths = {getattr(r, "path", None) for r in app.routes} for expected in ( "/", "/version", "/api/agents", "/api/sessions", "/api/agents/{agent_id}/persona_state", "/api/affect/{agent_id}", # v0.19.2 debug-surface parity (create_app POST-002) "/api/sessions/{session_id}/tools", "/api/sessions/{session_id}/bifrost", "/api/admin/events", "/api/turns/{session_id}", "/api/turns/{session_id}/stream", "/api/turns/{session_id}/cancel", ): assert expected in paths, f"missing route {expected}" # /static is a Mount — its path is "/static" assert "/static" in paths def test_state_attached(self) -> None: """state_attached [trace]: app.state.turn_registry is empty dict; admin_key stored.""" from ratatoskr.web.server import create_app app = create_app(_mock_client_factory()) assert app.state.turn_registry == {} # create_app POST-001: admin_key defaults None (admin routes fail-visible) assert app.state.admin_key is None app2 = create_app(_mock_client_factory(), admin_key="adm-key") assert app2.state.admin_key == "adm-key" def test_factory_stored(self) -> None: """factory_stored [trace]: app.state.client_factory is the same callable.""" from ratatoskr.web.server import create_app f = _mock_client_factory() app = create_app(f) assert app.state.client_factory is f class TestStreamFullEventVocab: """stream_turn_endpoint full_event_vocab — one of each Event type proxied.""" def _drive_stream(self, app, stream: bytes) -> list[str]: respx.post("https://w.example/sessions/s-1/messages").mock( return_value=_sse_resp(stream) ) c = TestClient(app) tid = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"] with c.stream("GET", f"/api/turns/s-1/stream?turn_id={tid}") as resp: raw = b"".join(resp.iter_bytes()) return [e["event"] for e in _parse_browser_sse(raw)] @respx.mock def test_full_event_vocab(self) -> None: """full_event_vocab [scenario]: a stream with the non-terminal Event types (incl. AffectUpdate) + Done → each serialized to its fixture- shaped browser event. Error / Cancelled are terminal and exclusive with Done, so they get dedicated tests below. """ from ratatoskr.web.server import create_app stream = b"".join([ _sse_chunk("42:1", {"type": "affect_update", "status": "current", "turn_id": 42, "snapshot": {"agent_id": "mimir", "pad": {}, "dominant_emotion": "x"}}), _sse_chunk("42:2", {"type": "worker_phase", "phase": "BuildingPrompt", "turn_id": 42}), _sse_chunk("42:3", {"type": "thinking", "content": "hmm"}), _sse_chunk("42:4", {"type": "text", "content": "hi"}), _sse_chunk("42:5", {"type": "text_boundary", "kind": "sentence", "char_offset": 2, "ts": "t"}), _sse_chunk("42:6", {"type": "tool_start", "name": "s", "arguments": {"q": "x"}}), _sse_chunk("42:7", {"type": "tool_result", "name": "s", "result": {"n": 1}, "duration_ms": 3}), _sse_chunk("42:8", {"type": "awaiting_llm_first_token", "turn_id": 42, "elapsed_ms_since_building_prompt": 5000.0}), _sse_chunk("42:9", _DONE_BODY), ]) types = self._drive_stream(create_app(_mock_client_factory()), stream) for expected in ("affect_update", "worker_phase", "thinking", "text", "text_boundary", "tool_start", "tool_result", "awaiting_llm_first_token", "done"): assert expected in types, f"missing browser event {expected}" @respx.mock def test_error_terminal_event(self) -> None: """error terminal [scenario]: an SSE `error` event (distinct from the synthetic connection-error event) proxies to a browser `error` event. """ from ratatoskr.web.server import create_app stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk( "42:2", {"type": "error", "phase": "failed", "error_code": "llm_output_invalid", "message": "boom"}, ) types = self._drive_stream(create_app(_mock_client_factory()), stream) assert "error" in types @respx.mock def test_cancelled_terminal_event(self) -> None: """cancelled terminal [scenario]: an SSE `cancelled` event proxies to a browser `cancelled` event.""" from ratatoskr.web.server import create_app stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk( "42:2", {"type": "cancelled", "phase": "cancelled", "turn_id": 42, "reason": "user_cancel", "partial_message_id": None}, ) types = self._drive_stream(create_app(_mock_client_factory()), stream) assert "cancelled" in types class TestDisconnectCancel: """stream_turn_endpoint INV-005 — browser disconnect mid-stream triggers upstream cancel against the UPSTREAM turn_id. The load-bearing test that would have caught the v0.15.x turn_id bug. Drives the endpoint's StreamingResponse body_iterator directly and cancels the consuming task to simulate the disconnect. This avoids the ASGITransport stream-context-exit deadlock against a gated upstream generator, while exercising the real `except asyncio.CancelledError` cleanup path inside the generator. """ @respx.mock async def test_disconnect_triggers_upstream_cancel(self) -> None: import asyncio from starlette.requests import Request from ratatoskr.web.server import TurnHandle, _stream_turn_endpoint, create_app gate = asyncio.Event() class _GatedAfterFirst(httpx.AsyncByteStream): async def __aiter__(self): yield _sse_chunk("42:1", {"type": "text", "content": "x"}) await gate.wait() async def aclose(self) -> None: return None respx.post("https://w.example/sessions/s-1/messages").mock( return_value=_sse_resp_stream(_GatedAfterFirst()) ) cancel_route = respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( return_value=httpx.Response(200, json={ "turn_id": 42, "cancelled": True, "reason": None, "partial_message_id": None, }) ) app = create_app(_mock_client_factory()) app.state.turn_registry[("s-1", 1)] = TurnHandle( session_id="s-1", turn_id=1, content="x", ) request = Request({ "type": "http", "method": "GET", "path": "/api/turns/s-1/stream", "path_params": {"session_id": "s-1"}, "query_string": b"turn_id=1", "headers": [], "app": app, }) response = await _stream_turn_endpoint(request) body_iter = response.body_iterator # type: ignore[attr-defined] async def consume() -> None: async for _chunk in body_iter: pass task = asyncio.create_task(consume()) # Wait until the generator has captured the upstream turn_id (first # event consumed). The handle is popped in finally, so check it # before cancelling. for _ in range(100): h = app.state.turn_registry.get(("s-1", 1)) if h is not None and h.upstream_turn_id == 42: break await asyncio.sleep(0.02) else: gate.set() raise AssertionError("upstream_turn_id was never captured") # Simulate browser disconnect: cancel the consuming task. task.cancel() try: await task except asyncio.CancelledError: pass for _ in range(50): if cancel_route.called: break await asyncio.sleep(0.02) gate.set() assert cancel_route.called, "browser disconnect must cancel the UPSTREAM turn (42)" class TestWebBifrostBind: """Issue #17 slice 3c — web bind split: the browser selects the PLANE; the consumer key + visible host are SERVER-HELD and never reach the browser (INV-008/INV-009).""" @respx.mock def test_bound_create_server_constructs_binding_key_never_leaks(self) -> None: """tracer: a plane from the browser → the server builds the binding with its OWN consumer key + host, sends the bifrost body + consumer-key bearer upstream, and returns bound-state WITHOUT the key.""" import json as _json from ratatoskr.web.server import create_app route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, json=_CREATE_OK) ) app = create_app( _mock_client_factory(), bifrost_consumer_key="server-ck", bifrost_visible_host="10.100.10.50", ) resp = TestClient(app).post( "/api/sessions", json={"agent_id": "ratatoskr:sindra", "bifrost_plane": "memory"} ) assert resp.status_code == 201 # bound-state echoed for the UI indicator — plane + endpoint, NO key assert resp.json()["bifrost"] == { "plane": "memory", "endpoint": "http://10.100.10.50:8391", "status": "bound", } assert "server-ck" not in resp.text # the key never reaches the browser # upstream got the bifrost body + the consumer-key bearer override upstream = route.calls[0].request body = _json.loads(upstream.content) assert body["bifrost"] == { "endpoint_url": "http://10.100.10.50:8391", "scope": None } assert upstream.headers["Authorization"] == "Bearer server-ck" @respx.mock def test_combined_plane_binds_to_8392(self) -> None: """combined [#18 composite]: a 'combined' plane from the browser → the server binds the :8392 both-plane endpoint; bound-state echoes plane='combined'.""" import json as _json from ratatoskr.web.server import create_app route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, json=_CREATE_OK) ) app = create_app( _mock_client_factory(), bifrost_consumer_key="server-ck", bifrost_visible_host="10.100.10.50", ) resp = TestClient(app).post( "/api/sessions", json={"agent_id": "ratatoskr:sindra", "bifrost_plane": "combined"}, ) assert resp.status_code == 201 assert resp.json()["bifrost"] == { "plane": "combined", "endpoint": "http://10.100.10.50:8392", "status": "bound", } upstream = route.calls[0].request body = _json.loads(upstream.content) assert body["bifrost"] == { "endpoint_url": "http://10.100.10.50:8392", "scope": None, } def test_dropdown_offers_combined_as_default(self) -> None: """(a)+default: the SPA plane dropdown offers a 'combined' (:8392) option, it is the DEFAULT-selected one, and single-plane memory/affect remain.""" from pathlib import Path import ratatoskr.web as web_pkg html = (Path(web_pkg.__file__).parent / "static" / "index.html").read_text() assert '