feat(web): address Heid code-review findings — issue #16 (v0.16.0)

Heid panel review (Gróa + Hulda, thread 01KSP5P6CSJH) on v0.15.0/
v0.15.1 surfaced one load-bearing bug + several precision items. This
pass closes them.

Load-bearing fix — cancel paths targeted the wrong turn_id:
- `_TURN_COUNTER` allocates browser-local ids (1, 2, 3…); the real
  upstream Worldtree turn_id (e.g. 799) only arrives in the first SSE
  event. The v0.15.x cancel/disconnect/shutdown paths posted to
  /sessions/{sid}/turns/{LOCAL_ID}/cancel — wrong URL upstream.
- TurnHandle.upstream_response (dead field) → upstream_turn_id: int|None.
  Captured from the first event's sse_id.turn_id in the stream
  generator. All cancel paths now target it. Cancel before the upstream
  stream starts (upstream_turn_id None) is a no-op
  ({"cancelled": false, "reason": "not_started"}).
- The old cancel tests mocked the local-id URL, so they encoded the bug;
  rewritten to assert the UPSTREAM id is targeted.

Behavior change (minor-bump driver) — server-side end_user_id:
- create_app gains end_user_id kwarg; entrypoint reads
  RATATOSKR_END_USER_ID and threads it in. POST /api/sessions uses
  app.state.end_user_id, IGNORING any browser-supplied value (a client
  can't impersonate an arbitrary end-user partition). JS no longer
  sends end_user_id.

Precision fixes:
- Entrypoint missing-extras ImportError catch scoped to starlette/
  uvicorn ONLY; baseline-dep / first-party import failures now
  propagate as real tracebacks instead of masking as exit-12.
- Lifespan shutdown logs per-pending session_id + upstream_turn_id
  (was a single aggregate count).

Tests (+18; 376 total):
- disconnect_triggers_upstream_cancel (INV-005 load-bearing — drives
  the stream generator directly + cancels the consuming task; would
  have caught the turn_id bug)
- cancel_targets_upstream_turn_id, cancel_before_started_is_noop,
  cancel_failed_500
- server-side end_user_id: uses / ignores-body / omits-when-unset
- create_app: routes_registered / state_attached / factory_stored
- entrypoint: default_host / port_zero / happy_argv / open / no-open
- real_import_bug_propagates (precision guard)
- full_event_vocab at the stream-endpoint layer

Contract #16 amended: v0.16.0 amendment banner + INV-005/006 reworded
for upstream_turn_id + FN sketches corrected (server-side end_user_id,
upstream_response→upstream_turn_id, manual client lifecycle vs the
non-executable async-with sketch, not-started cancel branch).
This commit is contained in:
2026-05-27 20:53:13 -07:00
parent 0fbbeb171c
commit 369857d3f1
8 changed files with 538 additions and 69 deletions
+52 -22
View File
@@ -108,14 +108,39 @@ def main(argv: list[str] | None = None) -> int: ... # entrypoint.main
`entrypoint.main` is the console-script target — parses flags, builds the client factory from env, calls `create_app`, runs uvicorn. The lazy-import discipline lives here: `import starlette` does NOT happen at module top — it lands inside `main()` after arg parsing, with an `ImportError` catch that prints the `pip install ratatoskr[web]` hint and exits non-zero.
## v0.16.0 amendment (post-Heid-code-review)
Heid panel review (Gróa + Hulda, thread `01KSP5P6CSJH`) on the
v0.15.0/v0.15.1 implementation surfaced three contract-text issues
now corrected below:
1. **Upstream vs local turn_id.** Cancel paths (explicit cancel,
browser-disconnect, lifespan shutdown) MUST target the *upstream*
(Worldtree-assigned) turn_id captured from the first SSE event's
`sse_id.turn_id`, NOT the browser-local `_TURN_COUNTER` value (which
is only a registry key). The `TurnHandle.upstream_response` field is
replaced by `upstream_turn_id: int | None`. Cancel before the
upstream stream starts (upstream_turn_id is None) is a no-op
(`{"cancelled": false, "reason": "not_started"}`).
2. **`RATATOSKR_END_USER_ID` is server-configured.** `FN main` reads it
from env and threads it into `create_app(..., end_user_id=...)`; the
`POST /api/sessions` endpoint uses `app.state.end_user_id` server-
side. The browser NEVER supplies end_user_id — a client cannot
impersonate an arbitrary end-user partition.
3. **Stream client lifecycle.** The `async with client_factory() as
client:` sketch in `FN stream_turn_endpoint` is not executable for a
long-lived async generator that must outlive the handler frame; the
implementation uses manual `client = ...; try: ... finally: await
client.aclose()`. Sketch corrected below.
## Invariants
- **INV-001**: `ratatoskr.web.__init__` and `ratatoskr.web.entrypoint` MUST NOT import `starlette` or `uvicorn` at module top. Import is inside `main()` after flag parsing.
- **INV-001**: `ratatoskr.web.__init__` and `ratatoskr.web.entrypoint` MUST NOT import `starlette` or `uvicorn` at module top. Import is inside `main()` after flag parsing. The missing-extras `ImportError` catch is scoped to the OPTIONAL extras (`starlette` / `uvicorn`) ONLY — baseline-dep / first-party import failures propagate as real tracebacks rather than masking as exit-12.
- **INV-002**: `ratatoskr.web.server.create_app` MUST accept a `client_factory` callable. The app MUST NOT construct `httpx.AsyncClient` at module top or in route handlers; it MUST call the factory.
- **INV-003**: Upstream API key MUST never appear in any browser-visible response. Server proxies upstream calls using the client factory; only the upstream's JSON / SSE payload is forwarded. No header echo.
- **INV-004**: Transcript content from upstream `text` SSE events MUST be HTML-escaped before reaching the browser DOM (escape on the wire in the SSE proxy serialization OR escape in the JS rendering — both are acceptable; pick one and stick to it).
- **INV-005**: Browser disconnect mid-stream (`asyncio.CancelledError` in the SSE handler) MUST trigger an upstream cancel on the matching `(session_id, turn_id)` via `sse_client.cancel_turn`. If the turn already completed, the cancel is a best-effort no-op (existing `CancelAlreadyCompleted` exception is swallowed).
- **INV-006**: Server shutdown (Ctrl-C / SIGTERM) MUST issue upstream cancels for every entry in the turn registry within a 5-second cleanup budget. Entries that don't ack in time are abandoned with a structured log line.
- **INV-005**: Browser disconnect mid-stream (`asyncio.CancelledError` in the SSE handler) MUST trigger an upstream cancel via `sse_client.cancel_turn` on the captured `upstream_turn_id` (v0.16.0 — NOT the browser-local turn_id). If the turn already completed, the cancel is a best-effort no-op (`CancelAlreadyCompleted` swallowed). If `upstream_turn_id` is still None (upstream stream never started), the disconnect cancel is skipped — nothing to cancel.
- **INV-006**: Server shutdown (Ctrl-C / SIGTERM) MUST issue upstream cancels (on `upstream_turn_id`) for every in-flight registry entry within a 5-second cleanup budget. Handles whose `upstream_turn_id` is None are skipped. Entries that don't ack in time are abandoned with a per-entry structured log line carrying `session_id` + `upstream_turn_id`.
- **INV-007**: The turn registry MUST be in-process memory only — no persistence, no shared state across server restarts. Process exit drops the registry.
- **INV-008**: Each SSE event serialized to the browser MUST follow the contract enumerated in `tests/fixtures/presentation_contract.json` — one entry per Event type, with the exact JSON shape the browser presenter renders against.
- **INV-009**: All wire-layer modules (`sse_client`, `sessions`, `tier3`, `local_agents`) MUST be used unchanged. Any required change to those modules is out of scope for this issue and gets its own ticket.
@@ -234,9 +259,10 @@ BRIEF: Proxy POST /sessions to upstream.
PRE: [PRE-001 hard] request body has "agent_id" key -- 400 if missing
POST: [POST-001 return_value] 201 with SessionInfo on upstream success
STEPS:
1. [parse] body = await request.json(); agent_id = body["agent_id"]; end_user_id = body.get("end_user_id")
2. [proxy] async with client_factory() as client: info = await create_session(client, agent_id, end_user_id=end_user_id)
3. [return] JSONResponse(as_dict(info), status_code=201)
1. [parse] body = await request.json(); agent_id = body["agent_id"] (400 if missing)
2. [server-side] end_user_id = request.app.state.end_user_id # v0.16.0: server-configured, NOT from body
3. [proxy] async with client_factory() as client: info = await create_session(client, agent_id, end_user_id=end_user_id)
4. [return] JSONResponse(as_dict(info), status_code=201)
ERRORS:
AgentNotFound -> JSONResponse({"error_code": "agent_not_found"}, 404)
SessionApiFailed -> JSONResponse({"error_code": "session_api_failed", "status": exc.status}, exc.status)
@@ -244,6 +270,8 @@ TESTS:
happy [tracer]: respx mock 201 → endpoint returns 201 with session JSON
unknown_agent [error]: respx mock 404 → 404 with agent_not_found envelope
missing_agent_id [adversarial]: body without agent_id → 400
server_side_end_user_id [v0.16.0]: create_app(end_user_id="X") → upstream body carries end_user_id="X"
ignores_body_end_user_id [v0.16.0,security]: body end_user_id is overridden by server value
```
```contract
@@ -274,7 +302,7 @@ POST: [POST-002 state_change] app.state.turn_registry has entry for (sid, turn_i
STEPS:
1. [parse] session_id = path_params["session_id"]; body = await request.json(); content = body["content"]
2. [allocate] turn_id = next_turn_id() # process-local monotonic counter
3. [register] turn_registry[(session_id, turn_id)] = TurnHandle(content=content, status="queued", upstream_response=None)
3. [register] turn_registry[(session_id, turn_id)] = TurnHandle(content=content, status="queued", upstream_turn_id=None) # v0.16.0: was upstream_response
4. [return] JSONResponse({"turn_id": turn_id}, status_code=200)
TESTS:
happy [tracer]: POST {"content": "hi"} → 200 with turn_id; registry populated
@@ -290,17 +318,16 @@ POST: [POST-001 side_effect] each upstream event serialized to browser as SSE ev
POST: [POST-002 state_change] on completion/disconnect, registry entry removed; upstream cancel if turn still in flight
STEPS:
1. [validate] sid, tid = path/query params; handle = registry.get((sid, tid)); 404 if None
2. [open] async with client_factory() as client:
- upstream = stream_turn(client, sid, handle.content)
- handle.upstream_response = upstream
2. [open] client = client_factory() # v0.16.0: manual lifecycle, NOT `async with` — the generator outlives this frame; closed in finally
- handle.status = "streaming"
3. [forward] async for event in upstream:
3. [forward] async for event in stream_turn(client, sid, handle.content):
- IF handle.upstream_turn_id is None: handle.upstream_turn_id = event.sse_id.turn_id # v0.16.0: capture upstream turn id
- serialize per fixture: {"type": <ssetype>, "data": <json>}
- yield as `event: <type>\\ndata: <json>\\n\\n` bytes
4. [terminal] on Done/Error/Cancelled: yield final SSE, mark handle.status, break
5. [cleanup] finally:
- IF asyncio.CancelledError caught and turn in-flight: upstream cancel via cancel_turn(client, sid, tid)
- remove (sid, tid) from registry
- IF asyncio.CancelledError caught AND status=="streaming" AND upstream_turn_id is not None: cancel_turn(client, sid, handle.upstream_turn_id) # v0.16.0: upstream id, not tid
- remove (sid, tid) from registry; await client.aclose()
ERRORS:
KeyError -> 404 turn_not_found
asyncio.CancelledError -> upstream cancel, propagate
@@ -322,15 +349,18 @@ POST: [POST-001 side_effect] upstream cancel call lands; registry entry removed
POST: [POST-002 return_value] 200 with {"cancelled": true} or 200 with status reflecting upstream race
STEPS:
1. [validate] sid, tid = params; handle = registry.get((sid, tid)); 404 if None
2. [cancel] async with client_factory() as client:
- try: await cancel_turn(client, sid, tid)
2. [not-started] IF handle.upstream_turn_id is None: del registry[(sid,tid)]; return 200 {"cancelled": false, "reason": "not_started"} # v0.16.0: upstream never opened
3. [cancel] async with client_factory() as client:
- try: await cancel_turn(client, sid, handle.upstream_turn_id) # v0.16.0: upstream id, not tid
- return 200 {"cancelled": true}
3. [race] EXCEPT CancelAlreadyCompleted / CancelTurnNotFound:
4. [race] EXCEPT CancelAlreadyCompleted / CancelTurnNotFound:
- return 200 {"cancelled": false, "reason": "race_or_completed"}
4. [cleanup] del registry[(sid, tid)]
5. [cleanup] del registry[(sid, tid)]
TESTS:
happy [tracer]: registered turn → POST cancel → 200, upstream cancel called
happy [tracer]: registered turn (upstream_turn_id set) → POST cancel → 200, upstream cancel at the upstream id
unknown_turn [error]: not in registry → 404
cancel_before_started [v0.16.0]: upstream_turn_id None → 200 {cancelled:false, reason:not_started}, no upstream call
cancel_targets_upstream_turn_id [v0.16.0]: local tid != upstream id → cancel URL uses upstream id
already_completed [race]: respx cancel returns 409 → 200 with reason=race_or_completed
cancel_failed [error]: respx returns 500 → 500 with cancel_failed envelope
```
@@ -352,11 +382,11 @@ BRIEF: On Ctrl-C / SIGTERM, drain the turn registry within 5s budget per INV-006
POST: [POST-001 side_effect] every in-flight upstream turn gets a cancel attempt within budget
POST: [POST-002 side_effect] entries that don't ack in budget logged + abandoned
STEPS:
1. [collect] handles = list(app.state.turn_registry.values())
1. [collect] in_flight = [h for h in registry.values() if h.status == "streaming" and h.upstream_turn_id is not None] # v0.16.0: skip not-yet-started
2. [cancel] async with client_factory() as client:
- tasks = [cancel_turn(client, h.session_id, h.turn_id) for h in handles if h.status == "streaming"]
- done, pending = await asyncio.wait(tasks, timeout=5.0)
3. [log] for each pending: log {"kind": "shutdown", "event": "cleanup_timeout", session/turn}
- task_to_handle = {create_task(cancel_turn(client, h.session_id, h.upstream_turn_id)): h for h in in_flight} # v0.16.0: upstream id
- done, pending = await asyncio.wait(task_to_handle, timeout=5.0)
3. [log] for each pending: cancel task + log {"kind": "shutdown", "event": "cleanup_timeout", "session_id": h.session_id, "upstream_turn_id": h.upstream_turn_id}
4. [clear] registry.clear()
TESTS:
happy [tracer]: 2 in-flight turns + shutdown → both upstream cancels called, registry empty
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.15.1"
version = "0.16.0"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
+14 -6
View File
@@ -58,15 +58,17 @@ def main(argv: list[str] | None = None) -> int:
)
return 11
server_url = os.environ.get("WORLDTREE_API_URL", "http://localhost:8000")
end_user_id = os.environ.get("RATATOSKR_END_USER_ID")
# INV-001: lazy import. Users without [web] extras get a clean hint
# instead of a raw ImportError.
# instead of a raw ImportError. Scoped narrowly to the OPTIONAL
# extras (starlette / uvicorn) so a real import bug inside a
# production module (ratatoskr.web.server, ratatoskr.cli, httpx —
# all baseline deps) propagates as a true traceback rather than
# being masked as "install ratatoskr[web]".
try:
import starlette # noqa: F401 (extras-presence probe)
import uvicorn
import httpx
from ratatoskr.cli import USER_AGENT
from ratatoskr.web.server import create_app
except ImportError as exc:
sys.stderr.write(
f"[missing_extras] {exc}\n"
@@ -75,6 +77,12 @@ def main(argv: list[str] | None = None) -> int:
)
return 12
# Baseline deps + own modules — a failure here is a real bug, not a
# missing-extras condition; let it propagate.
import httpx
from ratatoskr.cli import USER_AGENT
from ratatoskr.web.server import create_app
def client_factory() -> "httpx.AsyncClient":
return httpx.AsyncClient(
base_url=server_url,
@@ -85,7 +93,7 @@ def main(argv: list[str] | None = None) -> int:
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
)
app = create_app(client_factory)
app = create_app(client_factory, end_user_id=end_user_id)
# Boot banner to stderr (so stdout stays clean for piping).
version = _pkg_version("ratatoskr")
+68 -22
View File
@@ -12,9 +12,8 @@ import asyncio
import itertools
import json
from collections.abc import AsyncIterator, Callable
from dataclasses import asdict, dataclass, field, is_dataclass
from dataclasses import asdict, dataclass, is_dataclass
from importlib.metadata import version as _pkg_version
from typing import Any
import httpx
from starlette.applications import Starlette
@@ -114,12 +113,17 @@ async def _agents_endpoint(request: Request) -> JSONResponse:
async def _create_session_endpoint(request: Request) -> JSONResponse:
"""POST /api/sessions → upstream POST /sessions. Per FN create_session_endpoint."""
"""POST /api/sessions → upstream POST /sessions. Per FN create_session_endpoint.
v0.16.0: end_user_id is SERVER-configured (app.state.end_user_id from
RATATOSKR_END_USER_ID), never read from the browser body. A client
cannot impersonate an arbitrary end-user partition.
"""
body = await request.json()
agent_id = body.get("agent_id") if isinstance(body, dict) else None
if not agent_id:
return JSONResponse({"error_code": "missing_agent_id"}, status_code=400)
end_user_id = body.get("end_user_id") if isinstance(body, dict) else None
end_user_id = request.app.state.end_user_id
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
@@ -139,13 +143,20 @@ class TurnHandle:
"""In-flight turn record stored in app.state.turn_registry.
Per FN submit_turn_endpoint + INV-005/006/007.
v0.16.0: `upstream_turn_id` captures Worldtree's server-assigned
turn_id (from the first SSE event's sse_id.turn_id) once the stream
opens. Cancel paths target THIS, not the browser-local `turn_id` —
the local counter is only a registry key. None until the first
upstream event arrives; cancel before then is a no-op (nothing to
cancel upstream yet).
"""
session_id: str
turn_id: int
content: str
status: str = "queued" # queued | streaming | done | error | cancelled
upstream_response: Any = field(default=None, repr=False)
upstream_turn_id: int | None = None
# Process-local monotonic turn_id counter. Per FN submit_turn_endpoint
@@ -229,6 +240,13 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
handle.status = "streaming"
try:
async for event in stream_turn(client, session_id, handle.content):
# v0.16.0: capture the upstream (Worldtree-assigned)
# turn_id from the first event so cancel paths target
# the real upstream turn, not our local counter.
if handle.upstream_turn_id is None:
sse_id = getattr(event, "sse_id", None)
if sse_id is not None:
handle.upstream_turn_id = sse_id.turn_id
event_type, data = _event_to_browser_payload(event)
yield _format_sse(event_type, data)
if isinstance(event, (Done, Error, Cancelled)):
@@ -242,10 +260,11 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
)
handle.status = "error"
except asyncio.CancelledError:
# Browser disconnect path (INV-005).
if handle.status == "streaming":
# Browser disconnect path (INV-005). Cancel the UPSTREAM
# turn (if it started) — never the local turn_id.
if handle.status == "streaming" and handle.upstream_turn_id is not None:
try:
await cancel_turn(client, session_id, turn_id)
await cancel_turn(client, session_id, handle.upstream_turn_id)
except Exception:
pass
raise
@@ -264,12 +283,22 @@ async def _cancel_turn_endpoint(request: Request) -> JSONResponse:
except (KeyError, ValueError):
return JSONResponse({"error_code": "missing_turn_id"}, status_code=400)
registry = request.app.state.turn_registry
if (session_id, turn_id) not in registry:
handle = registry.get((session_id, turn_id))
if handle is None:
return JSONResponse({"error_code": "turn_not_found"}, status_code=404)
# v0.16.0: cancel targets the UPSTREAM turn_id captured during
# streaming, not the browser-local turn_id. If the upstream stream
# never started (upstream_turn_id is None), there's nothing to
# cancel — clean up and report a no-op.
if handle.upstream_turn_id is None:
registry.pop((session_id, turn_id), None)
return JSONResponse(
{"cancelled": False, "reason": "not_started"}, status_code=200
)
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
await cancel_turn(client, session_id, turn_id)
await cancel_turn(client, session_id, handle.upstream_turn_id)
body = {"cancelled": True}
except (CancelAlreadyCompleted, CancelTurnNotFound):
body = {"cancelled": False, "reason": "race_or_completed"}
@@ -299,13 +328,21 @@ async def _persona_state_endpoint(request: Request) -> JSONResponse:
return JSONResponse(snap, status_code=200)
def create_app(client_factory: Callable[[], httpx.AsyncClient]) -> Starlette:
def create_app(
client_factory: Callable[[], httpx.AsyncClient],
*,
end_user_id: str | None = None,
) -> Starlette:
"""Construct the Starlette app — wire routes + state per FN create_app.
INV-002: app MUST NOT construct httpx.AsyncClient at module top;
everything HTTP-bound goes through client_factory.
INV-006: lifespan shutdown drains the turn registry within a 5s
budget — every in-flight turn gets a best-effort upstream cancel.
v0.16.0: `end_user_id` is the server-configured Worldtree end-user
partition (from RATATOSKR_END_USER_ID). Threaded into POST /sessions
server-side; never accepted from the browser.
"""
assert callable(client_factory)
@@ -314,26 +351,34 @@ def create_app(client_factory: Callable[[], httpx.AsyncClient]) -> Starlette:
@asynccontextmanager
async def lifespan(app: Starlette):
yield
# Shutdown path — drain in-flight turns per INV-006.
# Shutdown path — drain in-flight turns per INV-006. Cancel the
# UPSTREAM turn_id (v0.16.0); skip handles whose upstream stream
# never started (upstream_turn_id is None — nothing to cancel).
import sys as _sys
registry: dict[tuple[str, int], TurnHandle] = app.state.turn_registry
in_flight = [h for h in registry.values() if h.status == "streaming"]
in_flight = [
h for h in registry.values()
if h.status == "streaming" and h.upstream_turn_id is not None
]
if in_flight:
client = client_factory()
try:
tasks = [
task_to_handle = {
asyncio.create_task(
cancel_turn(client, h.session_id, h.turn_id)
)
cancel_turn(client, h.session_id, h.upstream_turn_id)
): h
for h in in_flight
]
done, pending = await asyncio.wait(tasks, timeout=5.0)
}
done, pending = await asyncio.wait(task_to_handle, timeout=5.0)
# Per-pending session/turn detail (INV-006 logging fidelity).
for task in pending:
h = task_to_handle[task]
task.cancel()
# Log timeouts (stderr; observability per scope v2)
if pending:
import sys as _sys
_sys.stderr.write(
f'{{"kind":"shutdown","event":"cleanup_timeout","count":{len(pending)}}}\n'
f'{{"kind":"shutdown","event":"cleanup_timeout",'
f'"session_id":"{h.session_id}",'
f'"upstream_turn_id":{h.upstream_turn_id}}}\n'
)
finally:
await client.aclose()
@@ -352,6 +397,7 @@ def create_app(client_factory: Callable[[], httpx.AsyncClient]) -> Starlette:
]
app = Starlette(routes=routes, lifespan=lifespan)
app.state.client_factory = client_factory
app.state.end_user_id = end_user_id
# INV-002: turn registry is in-process memory, keyed (session_id, turn_id)
app.state.turn_registry = {}
return app
+3 -1
View File
@@ -225,10 +225,12 @@ async function startSession() {
const agentId = $("agent-picker").value;
if (!agentId) return;
state.agentId = agentId;
// end_user_id is server-configured (RATATOSKR_END_USER_ID) — not sent
// from the browser. The server ignores any end_user_id in this body.
const r = await fetch("/api/sessions", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({agent_id: agentId, end_user_id: "ratatoskr-web"}),
body: JSON.stringify({agent_id: agentId}),
});
if (r.status !== 201) {
alert("create session failed: " + r.status);
+81 -5
View File
@@ -62,17 +62,93 @@ def test_entrypoint_missing_api_key_returns_11(monkeypatch) -> None:
def test_entrypoint_missing_extras_returns_12(monkeypatch) -> None:
"""missing_extras [error]: starlette unimportable → exit 12.
Simulated by shadowing the import within main()'s try-block via
sys.modules tampering — pre-set the offending module to None so the
import raises ImportError.
v0.16.0: the missing-extras probe is scoped to the OPTIONAL extras
(starlette / uvicorn) only. Simulated by shadowing `starlette` to
None in sys.modules so its import raises ImportError inside the
narrow try-block.
"""
import sys
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
monkeypatch.setenv("WORLDTREE_API_URL", "https://example.com")
monkeypatch.setitem(sys.modules, "starlette", None)
from ratatoskr.web.entrypoint import main
rc = main(["--port", "0"])
assert rc == 12
def test_entrypoint_real_import_bug_propagates(monkeypatch) -> None:
"""v0.16.0: an ImportError from a BASELINE module (not an extra) MUST
propagate as a real traceback, not be masked as exit-12 missing-extras.
Shadowing ratatoskr.web.server (a first-party module, present with or
without the [web] extras) should raise, not return 12.
"""
import sys
import pytest as _pytest
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
monkeypatch.setenv("WORLDTREE_API_URL", "https://example.com")
monkeypatch.setitem(sys.modules, "ratatoskr.web.server", None)
from ratatoskr.web.entrypoint import main
rc = main(["--port", "0"])
assert rc == 12
with _pytest.raises(ImportError):
main(["--port", "0"])
class TestEntrypointArgs:
"""FN main argparse + serve-loop traces (contract TESTS)."""
def test_default_host_is_zero(self) -> None:
"""default_host_is_zero [trace]: argv=[] → host == '0.0.0.0'."""
from ratatoskr.web.entrypoint import _build_arg_parser
args = _build_arg_parser().parse_args([])
assert args.host == "0.0.0.0"
assert args.port == 8765
assert args.open is False
def test_port_zero_supported(self) -> None:
"""port_zero_supported [trace]: argv=['--port','0'] → port == 0."""
from ratatoskr.web.entrypoint import _build_arg_parser
args = _build_arg_parser().parse_args(["--port", "0"])
assert args.port == 0
def test_happy_argv_serves_and_returns_zero(self, monkeypatch) -> None:
"""happy_argv [tracer]: env set + uvicorn.run mocked → main returns 0."""
import uvicorn
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
monkeypatch.setenv("WORLDTREE_API_URL", "https://example.com")
calls = {}
monkeypatch.setattr(uvicorn, "run", lambda app, **kw: calls.update(kw))
from ratatoskr.web.entrypoint import main
rc = main(["--port", "0"])
assert rc == 0
assert calls["host"] == "0.0.0.0"
assert calls["port"] == 0
def test_open_flag_calls_webbrowser(self, monkeypatch) -> None:
"""open_flag_calls_webbrowser [trace]: --open → webbrowser.open called."""
import uvicorn
import webbrowser
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
monkeypatch.setattr(uvicorn, "run", lambda app, **kw: None)
opened = []
monkeypatch.setattr(webbrowser, "open", lambda url: opened.append(url))
from ratatoskr.web.entrypoint import main
main(["--port", "0", "--open"])
assert len(opened) == 1
def test_no_open_default(self, monkeypatch) -> None:
"""no_open_default [trace]: argv without --open → webbrowser.open NOT called."""
import uvicorn
import webbrowser
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
monkeypatch.setattr(uvicorn, "run", lambda app, **kw: None)
opened = []
monkeypatch.setattr(webbrowser, "open", lambda url: opened.append(url))
from ratatoskr.web.entrypoint import main
main(["--port", "0"])
assert opened == []
+318 -11
View File
@@ -288,6 +288,14 @@ 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
@@ -365,17 +373,18 @@ class TestCancelTurnEndpoint:
@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)
)
"""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"]
# 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})
# 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
@@ -391,12 +400,14 @@ class TestCancelTurnEndpoint:
@respx.mock
def test_already_completed_race(self) -> None:
"""already_completed [race]: respx 409 → 200 with reason=race_or_completed."""
"""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"]
respx.post(f"https://w.example/sessions/s-1/turns/{turn_id}/cancel").mock(
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}")
@@ -404,6 +415,23 @@ class TestCancelTurnEndpoint:
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."""
@@ -438,13 +466,292 @@ class TestLifespanShutdown:
)
app = create_app(_mock_client_factory())
with TestClient(app) as client:
# Pretend two turns are in-flight (status=streaming)
# 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",
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 9 path patterns."""
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/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."""
@respx.mock
def test_full_event_vocab(self) -> None:
"""full_event_vocab [scenario]: a stream with one of each Event type
→ each serialized to its fixture-shaped browser event."""
stream = b"".join([
_sse_chunk("42:1", {"type": "worker_phase", "phase": "BuildingPrompt", "turn_id": 42}),
_sse_chunk("42:2", {"type": "thinking", "content": "hmm"}),
_sse_chunk("42:3", {"type": "text", "content": "hi"}),
_sse_chunk("42:4", {"type": "text_boundary", "kind": "sentence", "char_offset": 2, "ts": "t"}),
_sse_chunk("42:5", {"type": "tool_start", "name": "s", "arguments": {"q": "x"}}),
_sse_chunk("42:6", {"type": "tool_result", "name": "s", "result": {"n": 1}, "duration_ms": 3}),
_sse_chunk("42:7", {"type": "awaiting_llm_first_token", "turn_id": 42,
"elapsed_ms_since_building_prompt": 5000.0}),
_sse_chunk("42:8", _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)
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())
types = [e["event"] for e in _parse_browser_sse(raw)]
for expected in ("worker_phase", "thinking", "text", "text_boundary",
"tool_start", "tool_result", "awaiting_llm_first_token", "done"):
assert expected in types, f"missing browser event {expected}"
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)"
Generated
+1 -1
View File
@@ -1013,7 +1013,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.15.1"
version = "0.16.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },