"""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": "<current-version>"}.

        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

    @respx.mock
    def test_preset_agent_auto_seeds_first_message(self) -> None:
        """#347: a preset agent gets its opening seeded on create; a non-preset agent does not."""
        respx.post("https://w.example/sessions").mock(return_value=httpx.Response(201, json=_CREATE_OK))
        hist = respx.post("https://w.example/sessions/s-1/history").mock(
            return_value=httpx.Response(
                201,
                json={
                    "author": "assistant", "seq": 0, "phase": "seeded", "turn_id": "t1",
                    "session_id": "s-1", "content_chars": 1, "injected_at": "t",
                },
            )
        )
        from ratatoskr.web.server import create_app
        app = create_app(_mock_client_factory())
        client = TestClient(app)
        # preset agent → the endpoint seeds a first-message
        assert client.post("/api/sessions", json={"agent_id": "ratatoskr:sindra"}).status_code == 201
        assert hist.call_count == 1
        # non-preset agent → no seed (count unchanged)
        assert client.post("/api/sessions", json={"agent_id": "mimir"}).status_code == 201
        assert hist.call_count == 1


class TestSessionMessagesEndpoint:
    """GET /api/sessions/{id}/messages — proxy session history (renders the #347 seed)."""

    @respx.mock
    def test_happy_returns_history(self) -> None:
        """happy [tracer]: proxies GET /sessions/{id}/messages → 200 with the items verbatim."""
        payload = {
            "session_id": "s-1",
            "items": [{"seq": 0, "role": "assistant", "content": "Hey there."}],
            "next_cursor": None,
        }
        respx.get("https://w.example/sessions/s-1/messages").mock(
            return_value=httpx.Response(200, json=payload)
        )
        from ratatoskr.web.server import create_app
        app = create_app(_mock_client_factory())
        resp = TestClient(app).get("/api/sessions/s-1/messages")
        assert resp.status_code == 200
        assert resp.json()["items"][0]["content"] == "Hey there."

    @respx.mock
    def test_non_200_status_preserved(self) -> None:
        """error: upstream 404 → status-preserving session_messages_unavailable envelope."""
        respx.get("https://w.example/sessions/ghost/messages").mock(
            return_value=httpx.Response(404, json={"error_code": "session_not_found"})
        )
        from ratatoskr.web.server import create_app
        app = create_app(_mock_client_factory())
        resp = TestClient(app).get("/api/sessions/ghost/messages")
        assert resp.status_code == 404
        assert resp.json()["error_code"] == "session_messages_unavailable"


_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 '<html'."""
        from ratatoskr.web.server import create_app
        app = create_app(_mock_client_factory())
        resp = TestClient(app).get("/")
        assert resp.status_code == 200
        assert "text/html" in resp.headers["content-type"]
        assert "<html" in resp.text


class TestLifespanShutdown:
    """lifespan_shutdown FN — INV-006: drain turn_registry within 5s budget."""

    @respx.mock
    def test_happy_drains_registry(self) -> 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)
        )
        # sindra is a preset agent → the endpoint now auto-seeds a #347 first-message.
        respx.post("https://w.example/sessions/s-1/history").mock(
            return_value=httpx.Response(201, json={})
        )
        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)
        )
        # sindra is a preset agent → the endpoint now auto-seeds a #347 first-message.
        respx.post("https://w.example/sessions/s-1/history").mock(
            return_value=httpx.Response(201, json={})
        )
        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 '<option value="combined" selected>' in html
        assert 'value="memory"' in html and 'value="affect"' in html

    @respx.mock
    def test_plane_without_server_config_is_400(self) -> None:
        """A plane requested but no server-held key/host → bifrost_not_configured."""
        from ratatoskr.web.server import create_app

        app = create_app(_mock_client_factory())  # no bifrost config
        resp = TestClient(app).post(
            "/api/sessions", json={"agent_id": "a", "bifrost_plane": "memory"}
        )
        assert resp.status_code == 400
        assert resp.json()["error_code"] == "bifrost_not_configured"

    def test_invalid_plane_is_400(self) -> None:
        from ratatoskr.web.server import create_app

        app = create_app(
            _mock_client_factory(),
            bifrost_consumer_key="ck",
            bifrost_visible_host="h",
        )
        resp = TestClient(app).post(
            "/api/sessions", json={"agent_id": "a", "bifrost_plane": "persona"}
        )
        assert resp.status_code == 400
        assert resp.json()["error_code"] == "invalid_bifrost_plane"

    @respx.mock
    def test_handshake_failure_is_502(self) -> None:
        from ratatoskr.web.server import create_app

        respx.post("https://w.example/sessions").mock(
            return_value=httpx.Response(
                502,
                json={
                    "error_code": "bifrost_handshake_failed",
                    "detail": {"bifrost_error": "bifrost.auth_rejected"},
                },
            )
        )
        app = create_app(
            _mock_client_factory(),
            bifrost_consumer_key="ck",
            bifrost_visible_host="h",
        )
        resp = TestClient(app).post(
            "/api/sessions", json={"agent_id": "a", "bifrost_plane": "memory"}
        )
        assert resp.status_code == 502
        assert resp.json()["error_code"] == "bifrost_handshake_failed"
        assert resp.json()["bifrost_error"] == "bifrost.auth_rejected"

    @respx.mock
    def test_no_plane_is_unbound_no_bifrost_in_response(self) -> None:
        """regression: no bifrost_plane → pre-#17 unbound create, no bifrost key."""
        from ratatoskr.web.server import create_app

        respx.post("https://w.example/sessions").mock(
            return_value=httpx.Response(201, json=_CREATE_OK)
        )
        app = create_app(
            _mock_client_factory(),
            bifrost_consumer_key="ck",
            bifrost_visible_host="h",
        )
        resp = TestClient(app).post("/api/sessions", json={"agent_id": "mimir"})
        assert resp.status_code == 201
        assert "bifrost" not in resp.json()


class TestAffectStateEndpoint:
    """affect_state_endpoint FN — #18 Deliverable 2: web proxy to the provider PAD read."""

    @respx.mock
    def test_happy_proxies_and_supplies_server_end_user_id(self) -> None:
        """tracer: GET /api/affect/{id} → proxies to the configured provider read URL,
        supplying end_user_id SERVER-SIDE (INV-002); colon-id round-trips (INV-008)."""
        from ratatoskr.web.server import create_app

        snap = {
            "agent_id": "ratatoskr:sindra",
            "pad": {"pleasure": 0.15, "arousal": 0.08, "dominance": -0.01},
            "valence": [{"entity_id": "ratatoskr", "familiarity": 0.59, "regard": 0.15}],
            "emitted_at": "2026-06-18T15:58:12+00:00",
        }
        route = respx.get(url__regex=r"http://prov:8390/affect/state/.+").mock(
            return_value=httpx.Response(200, json=snap)
        )
        app = create_app(
            _mock_client_factory(),
            end_user_id="vuong",
            affect_read_url="http://prov:8390",
        )
        resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
        assert resp.status_code == 200
        assert resp.json() == snap
        assert route.calls.last.request.url.params["end_user_id"] == "vuong"
        # INV-008: the colon-id round-trips into the provider path — whether the wire
        # keeps %3A or normalizes it, it must unquote back to the exact agent_id.
        from urllib.parse import unquote
        seg = str(route.calls.last.request.url).split("/affect/state/")[1].split("?")[0]
        assert unquote(seg) == "ratatoskr:sindra"

    @respx.mock
    def test_browser_supplied_end_user_id_is_ignored(self) -> None:
        """INV-002: a browser-supplied end_user_id query is IGNORED; the server's
        configured partition is used."""
        from ratatoskr.web.server import create_app

        route = respx.get(url__regex=r"http://prov:8390/affect/state/.+").mock(
            return_value=httpx.Response(200, json={"agent_id": "ratatoskr:sindra"})
        )
        app = create_app(
            _mock_client_factory(), end_user_id="vuong", affect_read_url="http://prov:8390"
        )
        TestClient(app).get("/api/affect/ratatoskr:sindra?end_user_id=attacker")
        assert route.calls.last.request.url.params["end_user_id"] == "vuong"

    def test_unconfigured_returns_400(self) -> None:
        """PRE-001: no affect_read_url → 400 affect_not_configured (no silent attempt)."""
        from ratatoskr.web.server import create_app

        app = create_app(_mock_client_factory(), end_user_id="vuong")  # no affect_read_url
        resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
        assert resp.status_code == 400
        assert resp.json()["error_code"] == "affect_not_configured"

    def test_no_end_user_configured_returns_400(self) -> None:
        """PRE-001: affect_read_url set but server end_user_id unset → 400 (INV-003
        fail-visible, never a silent empty)."""
        from ratatoskr.web.server import create_app

        app = create_app(_mock_client_factory(), affect_read_url="http://prov:8390")
        resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
        assert resp.status_code == 400
        assert resp.json()["error_code"] == "affect_not_configured"

    @respx.mock
    def test_provider_unreachable_returns_502(self) -> None:
        """POST-003: a network error reaching the provider → 502 affect_provider_unreachable."""
        from ratatoskr.web.server import create_app

        respx.get(url__regex=r"http://prov:8390/affect/state/.+").mock(
            side_effect=httpx.ConnectError("refused")
        )
        app = create_app(
            _mock_client_factory(), end_user_id="vuong", affect_read_url="http://prov:8390"
        )
        resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
        assert resp.status_code == 502
        assert resp.json()["error_code"] == "affect_provider_unreachable"

    @respx.mock
    def test_provider_404_passes_through(self) -> None:
        """POST-002: provider no_affect_snapshot 404 surfaces to the browser verbatim."""
        from ratatoskr.web.server import create_app

        respx.get(url__regex=r"http://prov:8390/affect/state/.+").mock(
            return_value=httpx.Response(404, json={"error_code": "no_affect_snapshot"})
        )
        app = create_app(
            _mock_client_factory(), end_user_id="vuong", affect_read_url="http://prov:8390"
        )
        resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
        assert resp.status_code == 404
        assert resp.json()["error_code"] == "no_affect_snapshot"

    @respx.mock
    def test_provider_400_passes_through(self) -> None:
        """POST-002: a provider 400 (e.g. missing_end_user_id — unreachable in normal
        flow since the proxy always supplies it) still passes through verbatim."""
        from ratatoskr.web.server import create_app

        respx.get(url__regex=r"http://prov:8390/affect/state/.+").mock(
            return_value=httpx.Response(400, json={"error_code": "missing_end_user_id"})
        )
        app = create_app(
            _mock_client_factory(), end_user_id="vuong", affect_read_url="http://prov:8390"
        )
        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

    @respx.mock
    def test_stream_error_on_connect_failure(self) -> None:
        """error: upstream admin SSE non-200 -> ONE stream_error frame, stream ends (POST-003)."""
        respx.get("https://w.example/admin/events").mock(
            return_value=httpx.Response(500, content=b"boom")
        )
        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: stream_error" in body
        assert "SseConnectFailed" in body
        assert body.count("event: stream_error") == 1  # exactly one, then ends
