1228c37e6f
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)
451 lines
19 KiB
Python
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 == {}
|