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)
163 lines
4.4 KiB
Python
163 lines
4.4 KiB
Python
"""Drift-detection between TUI presentation discipline and web JS
|
|
presenter per issue #16 INV-008.
|
|
|
|
The JSON fixture at `tests/fixtures/presentation_contract.json`
|
|
enumerates the expected browser-facing event payload for each Event
|
|
type. Server-side serialization (`_event_to_browser_payload`) is
|
|
unit-tested against the fixture. JS-side rendering in
|
|
`src/ratatoskr/web/static/index.html` consumes the same shape — if
|
|
this fixture changes, both sides update in lockstep.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from ratatoskr.sse_client import (
|
|
AffectUpdate,
|
|
AwaitingLlmFirstToken,
|
|
Cancelled,
|
|
Done,
|
|
Error,
|
|
SseId,
|
|
Text,
|
|
TextBoundary,
|
|
Thinking,
|
|
ToolResult,
|
|
ToolStart,
|
|
WorkerPhase,
|
|
)
|
|
from ratatoskr.web.server import _event_to_browser_payload
|
|
|
|
|
|
def _load_fixture() -> dict:
|
|
path = Path(__file__).parent / "fixtures" / "presentation_contract.json"
|
|
return json.loads(path.read_text())
|
|
|
|
|
|
def _check(name: str, event: object) -> None:
|
|
"""Assert (event_type, data) for `event` matches the fixture entry."""
|
|
fixture = _load_fixture()
|
|
assert name in fixture, f"fixture missing entry for {name!r}"
|
|
expected = fixture[name]
|
|
event_type, data = _event_to_browser_payload(event)
|
|
assert event_type == expected["event_type"], (
|
|
f"{name}: event_type {event_type!r} != fixture {expected['event_type']!r}"
|
|
)
|
|
assert data == expected["data"], (
|
|
f"{name}: data mismatch\n got: {data}\n fixture: {expected['data']}"
|
|
)
|
|
|
|
|
|
def test_worker_phase_matches_fixture() -> None:
|
|
_check(
|
|
"worker_phase",
|
|
WorkerPhase(sse_id=SseId(42, 3), phase="BuildingPrompt", turn_id=42),
|
|
)
|
|
|
|
|
|
def test_thinking_matches_fixture() -> None:
|
|
_check(
|
|
"thinking",
|
|
Thinking(sse_id=SseId(42, 5), content="Let me think..."),
|
|
)
|
|
|
|
|
|
def test_text_matches_fixture() -> None:
|
|
_check(
|
|
"text",
|
|
Text(sse_id=SseId(42, 7), content="Hello there"),
|
|
)
|
|
|
|
|
|
def test_text_boundary_matches_fixture() -> None:
|
|
_check(
|
|
"text_boundary",
|
|
TextBoundary(
|
|
sse_id=SseId(42, 8), kind="sentence",
|
|
char_offset=11, ts="2026-05-28T00:00:00Z",
|
|
),
|
|
)
|
|
|
|
|
|
def test_tool_start_matches_fixture() -> None:
|
|
_check(
|
|
"tool_start",
|
|
ToolStart(sse_id=SseId(42, 9), name="search", arguments={"q": "ratatoskr"}),
|
|
)
|
|
|
|
|
|
def test_tool_result_matches_fixture() -> None:
|
|
_check(
|
|
"tool_result",
|
|
ToolResult(
|
|
sse_id=SseId(42, 10), name="search",
|
|
result={"n": 1}, duration_ms=12,
|
|
),
|
|
)
|
|
|
|
|
|
def test_done_matches_fixture() -> None:
|
|
_check(
|
|
"done",
|
|
Done(
|
|
sse_id=SseId(42, 11), phase="succeeded", response="Hello there",
|
|
model="qwen3.6-35-a3b", duration_ms=1234,
|
|
usage={
|
|
"prompt_tokens": 100, "completion_tokens": 50,
|
|
"total_tokens": 150, "cached_input_tokens": 0,
|
|
},
|
|
),
|
|
)
|
|
|
|
|
|
def test_error_matches_fixture() -> None:
|
|
_check(
|
|
"error",
|
|
Error(
|
|
sse_id=SseId(42, 11), phase="failed",
|
|
message="llm output invalid", error_code="llm_output_invalid",
|
|
),
|
|
)
|
|
|
|
|
|
def test_cancelled_matches_fixture() -> None:
|
|
_check(
|
|
"cancelled",
|
|
Cancelled(
|
|
sse_id=SseId(42, 11), phase="cancelled", turn_id=42,
|
|
reason="user_cancel", partial_message_id=None,
|
|
),
|
|
)
|
|
|
|
|
|
def test_affect_update_matches_fixture() -> None:
|
|
_check(
|
|
"affect_update",
|
|
AffectUpdate(
|
|
sse_id=SseId(42, 1), status="current", turn_id=42,
|
|
snapshot={
|
|
"agent_id": "mimir",
|
|
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
|
|
"dominant_emotion": "curiosity",
|
|
"emotions_active": [
|
|
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
|
|
],
|
|
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
|
|
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
|
|
"last_updated_at": "2026-05-28T00:00:00+00:00",
|
|
},
|
|
),
|
|
)
|
|
|
|
|
|
def test_awaiting_llm_first_token_matches_fixture() -> None:
|
|
_check(
|
|
"awaiting_llm_first_token",
|
|
AwaitingLlmFirstToken(
|
|
sse_id=SseId(42, 2), turn_id=42,
|
|
elapsed_ms_since_building_prompt=5012.3,
|
|
),
|
|
)
|