Files
ratatoskr/tests/test_web_server.py
T
vh 719e4d605b feat: web SPA bind — add 'combined' (:8392) both-plane option as default
The bind dropdown offered only memory/affect single-plane binds; #18's
composite endpoint (:8392, both planes in one session) was never reachable
from the SPA. Add 'combined' as the default-selected option, keeping
memory-only / affect-only for single-plane isolation diagnostics.

- endpoint_for_plane: combined -> :8392 (sessions.py)
- web server: accept bifrost_plane="combined" (server.py)
- dropdown: combined (:8392) default-selected, single-plane retained (index.html)
- #17 contract: endpoint_for_plane FN + plane-selector spec updated to combined
- tests: endpoint_for_plane combined, server combined bind -> :8392, dropdown default

Suite 506 green. Live-verified on :8765 (current code).
2026-06-20 16:47:03 -07:00

1065 lines
46 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 _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}",
"/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."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
assert app.state.turn_registry == {}
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 '<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"