Files
ratatoskr/tests/test_web_server.py
T
vh 1228c37e6f feat(web): in-browser debug companion — issue #16 (v0.15.0)
Browser-based debug companion to the Ratatoskr TUI, reusing the
existing wire-layer modules unchanged. Same five surfaces (transcript,
thinking, tools, debug, persona) over the same Worldtree Conversation
API SSE wire, viewable from any device on the operator's LAN.

Per docs/contracts/issues/16.contract.md (full v2.1 module contract
with 11 FN blocks + 9 invariants + Heid panel review pass merged).

Architecture:
- New module `ratatoskr.web` with `server.py` (Starlette app, ~250 LOC),
  `entrypoint.py` (lazy-import gate, ~100 LOC), `static/index.html`
  (single-page vanilla JS UI, ~360 LOC)
- Optional-deps group `[web]` = starlette + uvicorn[standard]; dev
  pulls these in transitively
- New console script `ratatoskr-web`
- Streaming via browser-native `EventSource` GET; prompt-submit is a
  separate POST (load-bearing Hulda finding from R13 panel — EventSource
  is GET-only)
- Small in-memory turn registry maps (session_id, turn_id) → upstream
  request handle for cancel + browser-disconnect cleanup

Endpoint surface (9 routes):
- `GET /` → static index.html
- `GET /static/*` → static assets
- `GET /version` → {"ratatoskr": "<version>"}
- `GET /api/agents` → upstream /agents + local Tier 3 merge
- `POST /api/sessions` → upstream POST /sessions
- `GET /api/agents/{id}/persona_state` → upstream persona-state
- `POST /api/turns/{sid}` → allocate turn_id, register in turn registry
- `GET /api/turns/{sid}/stream?turn_id=N` → proxy upstream SSE to browser
- `POST /api/turns/{sid}/cancel?turn_id=N` → upstream cancel

Trust model: internal LAN debug surface. Binds 0.0.0.0:8765 default;
no auth, no CORS guard (operator direction). What stays disciplined
regardless of network trust:
- Transcript HTML-escapes assistant content (INV-004 — model output
  is untrusted text; adversarial HTML must not execute in browser)
- Upstream API key never reaches browser DOM (INV-003 — proxy-only)

Lifecycle:
- Browser disconnect mid-stream → upstream cancel (INV-005;
  asyncio.CancelledError caught in stream handler)
- Server Ctrl-C → lifespan shutdown drains turn registry within 5s
  budget (INV-006; structured-log line on timeout)

Tests (37 new, 356 total; previous 319 baseline preserved):
- tests/test_web_server.py (23 cases): endpoint contract via Starlette
  TestClient + respx mocks; covers each endpoint, browser-disconnect →
  upstream cancel, lifespan shutdown draining the registry
- tests/test_web_presentation_contract.py (11 cases): proxy
  serialization matches tests/fixtures/presentation_contract.json
  for one of each Event type — drift detection between server-side
  serializer and the JS presenter without forcing a shared abstraction
- tests/test_web_packaging.py (4 cases): static asset packaging via
  importlib.resources; AST-checked lazy-import discipline (no top-
  level starlette/uvicorn import in entrypoint.py); missing-API-key
  exit-11 path; missing-extras exit-12 path

Provenance:
- Scope v1 → Heid panel review (Gróa + Hulda, R13) → 8 load-bearing
  corrections (POST→GET split, Starlette > FastAPI, lazy-import
  discipline, browser-disconnect → upstream cancel, presentation-
  contract fixture, error event contract, static-asset packaging,
  escaped plain-text Markdown deferred) merged into scope v2
- Operator direction: internal-LAN debug surface; auth + CORS
  deliberately omitted

Not yet (deferred to v0.16.x+):
- Cross-reload session resume via Last-Event-ID
- Tier 3 lifecycle UI (define/patch/delete in browser)
- Markdown rendering with vendored safe-subset renderer
- TLS + real auth (only if a non-LAN use case ever surfaces)
2026-05-27 19:03:50 -07:00

451 lines
19 KiB
Python

"""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
_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 _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 → POST cancel → 200, upstream cancel called."""
route = respx.post("https://w.example/sessions/s-1/turns/").mock(
return_value=httpx.Response(200, json=_CANCEL_OK)
)
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"]
# Re-mock at the precise URL with the resolved turn_id
respx.post(f"https://w.example/sessions/s-1/turns/{turn_id}/cancel").mock(
return_value=httpx.Response(200, json={**_CANCEL_OK, "turn_id": turn_id})
)
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]: respx 409 → 200 with 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"]
respx.post(f"https://w.example/sessions/s-1/turns/{turn_id}/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"
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:
# Pretend two turns are in-flight (status=streaming)
for tid in (101, 102):
app.state.turn_registry[("s-1", tid)] = TurnHandle(
session_id="s-1", turn_id=tid, content="x", status="streaming",
)
# 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 == {}