diff --git a/docs/contracts/web_debug_surface.contract.md b/docs/contracts/web_debug_surface.contract.md index b0a4761..0f17836 100644 --- a/docs/contracts/web_debug_surface.contract.md +++ b/docs/contracts/web_debug_surface.contract.md @@ -35,6 +35,17 @@ functions: steps: "Open client_factory() client; await get_session_tools(client, session_id); return 200. Except SessionApiFailed -> status-preserving envelope." flexibility: "prescriptive" + - name: "_session_messages_endpoint" + signature: "async _session_messages_endpoint(request: Request) -> JSONResponse" + description: "GET /api/sessions/{session_id}/messages — proxy the session's message history so the SPA renders existing turns on open (notably a #347 authored first-message seeded at create-time; without it a seeded session's transcript is blank until the user speaks)." + preconditions: + - "session_id in path_params." + postconditions: + - "POST-001: 200 with the upstream {session_id, items, next_cursor} dict verbatim on success." + - "POST-002: on SessionApiFailed(status) -> JSONResponse({error_code:'session_messages_unavailable', status}, status_code=status) — status-preserving." + steps: "Open client_factory() client; await get_session_messages(client, session_id); return 200. Except SessionApiFailed -> status-preserving envelope." + flexibility: "prescriptive" + - name: "_session_bifrost_endpoint" signature: "async _session_bifrost_endpoint(request: Request) -> JSONResponse" description: "GET /api/sessions/{session_id}/bifrost — proxy admin-scoped Bifrost dispatch state." @@ -95,6 +106,14 @@ functions: - "POST-002: a scheduled poll no-ops if a NEW turn has started (state.turnId truthy) or already settled — no refresh of a stale agent, no unbounded polling." flexibility: "open" + - name: "loadTranscript (index.html)" + signature: "async loadTranscript(sessionId) -> void" + description: "On session open, GET /api/sessions/{id}/messages and render each EXISTING turn into #transcript — notably a #347 authored first-message seeded at create-time (which lives in the ledger, not the live turn stream, so without this the transcript is blank until the user speaks)." + postconditions: + - "POST-001: assistant items render as a .response .md-body bubble via markdownSafe(content) (escape-first whitelist, same path as appendResponse); user items render as a .prompt-echo via textContent — no upstream content reaches innerHTML unescaped (INV-004)." + - "POST-002: any non-200, fetch error, or parse error is swallowed (best-effort) — a blank transcript is acceptable; opening the workspace is never blocked." + flexibility: "prescriptive" + - name: "web pane renderers (index.html: renderToolsInventory / renderBifrostState / openAdminEvents)" signature: "renderToolsInventory(inv) ; renderBifrostState(b) ; openAdminEvents(sessionId)" description: "Render the three new surfaces; all content escaped (INV-004)." diff --git a/persistent-memory.md b/persistent-memory.md index 72e6a64..b091bdb 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -163,6 +163,8 @@ decision. Captures rationale that won't be obvious from code alone. - `[2026-07-06]` **Sindra rewritten onto a #347 authored first-message + first-message-preset AUTO-SEED SHIPPED (`v0.19.8`).** Operator "rewrite Sindra" now that #347 first-messages work. Her card had a `**Startup:**` block (a pre-#347 workaround: "introduce yourself + ask for Intensity/Mood/Willingness" with a verbatim scripted greeting) — precisely what #347 replaces. Rewrite, all NON-destructive: **(1)** lifted her scripted opening into a #347 first-message (punctuation-fixed); **(2) PATCHed her live definition** — `PATCH /agents/ratatoskr:sindra` (body `ConsumerAgentPatchRequest` = system_prompt+role, extra=forbid; keeps OCEAN/persona/memory) removing the Startup block -> a 1-line `**Opening:**` fallback + reworded the axes-persist line (25686->25449 chars, verified Startup gone); **(3) codified auto-seed:** NEW module `src/ratatoskr/first_message.py` (`FIRST_MESSAGE_PRESETS` dict {agent_id->text} + `seed_preset_first_message` best-effort helper) wired into ALL 3 session-create paths — cli `_amain` (`--send --new`), tui `_resolve_then_run` (bare `--new`), web `_create_session_endpoint` (POST /api/sessions) — so every new Sindra session opens with her greeting. **Best-effort (INV-001: swallows AuthoredHistoryUnavailable/SessionApiFailed/httpx.HTTPError -> NEVER blocks create)**; per-content idempotency key (`ratatoskr-preset-`+sha256[:12]). Contract `docs/contracts/first_message.contract.md` (module-scoped: `module:`+`purpose:`+`touches:` required, NOT `target_module:`) + TDD (9 unit + 1 web wire-in; **the 3 existing sindra bind tests needed a history-endpoint mock** since creating a preset agent now auto-seeds). Suite **612 green**, ruff+mypy clean. **LIVE-PROVEN generation-free**: create sindra session -> auto-seed -> read-back seq-0 assistant greeting (409 chars). Sindra's greeting now lives canonically in the preset registry (repo); her server card no longer carries it. Patch bump (single-commit feature, no downstream coordination). **FOOT-GUN: sindra requires `end_user_id` on session-create (422 `end_user_id_required`) — all real paths pass it from env (RATATOSKR_END_USER_ID) / web server config.** **Then the full quality gate (operator-directed, folded into v0.19.8): heid-code-review (unanimous ZERO implementation drift; 2 test-only fixups — INV-004 verification-claim made explicit re the global rglob test + an exactly-one-POST assertion) + heid-bug-hunt (3/3 convergence caught what the conformance lens structurally COULDN'T — the code matched the contract's NARROW 3-type ERROR_ROUTING, but INV-001's "NEVER raises" is BROADER). HARDENED: broad `except Exception` → None (re-raise `asyncio.CancelledError`, itself a BaseException), soft-guard PREs (return None, NOT assert — a wiring bug can't crash the create path it's wired into), and `asyncio.wait_for(_SEED_TIMEOUT_S=10s)` bounding the seed write (the CLI/TUI clients run read=None for SSE → a stalled /history would otherwise block create forever). Suite 615 green. LESSON: code-matches-ERROR_ROUTING ≠ honors-broad-INV-001 — heid-code-review confirms contract-conformance, heid-bug-hunt catches robustness gaps the contract's own narrow clauses miss; run both.** +- `[2026-07-06]` **Web UI now RENDERS the seeded first-message (`v0.19.9`) — operator-reported "i don't see Sindra's greeting on the web ui".** Diagnosis: the auto-seed WORKED (greeting was in the ledger at seq-0), but the web SPA never fetched a session's EXISTING history — NO `/api/sessions/{id}/messages` route (GET /messages was originally deferred out-of-scope; sessions used to start empty so it never mattered) and `startSession()` went straight from create → persona/tools/admin hydration, so the transcript only filled from the live turn stream + user echoes. Fix: (1) NEW web proxy route `GET /api/sessions/{id}/messages` → `get_session_messages` (mirrors the tools/bifrost proxies; status-preserving `session_messages_unavailable` envelope); (2) SPA `loadTranscript(sessionId)` — fetches the route on open, renders assistant items as `.response .md-body` (markdownSafe, same escape-first path as appendResponse) + user items as `.prompt-echo` (textContent), called in `startSession` after the workspace opens; best-effort (swallows failures). Contract `web_debug_surface.contract.md` amended (server endpoint + loadTranscript entries). TDD (2 web route tests, suite 617 green) + **Playwright DOM check PROVED the render** (drove the real UI: pick sindra → open → her greeting bubble appears — the JS-render lens unit tests can't reach; [[feedback_debug_surface_uses_canonical_surface_only]] cousin lesson). Web restarted on the fix. **FOOT-GUN (self-inflicted): `pkill -f "ratatoskr-web --host"` SELF-MATCHES the bash command running it → exit 144, killed its own restart mid-flight — kill the web by PID, never `pkill -f` on a pattern your own command contains.** **FOOT-GUN: uvicorn hangs on SIGTERM with an open admin-events SSE → needed SIGKILL.** **Playwright: python module absent from the venv; use node + `executablePath=/opt/ms-playwright/chromium-1223/chrome-linux64/chrome` — the shared browser is build 1223, npm-latest playwright wants 1228 (version-mismatch), so pin executablePath instead of letting playwright resolve.** + _41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._ _For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._ diff --git a/pyproject.toml b/pyproject.toml index ede7966..c4a23c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.19.8" +version = "0.19.9" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/web/server.py b/src/ratatoskr/web/server.py index 5530bbe..c3d747f 100644 --- a/src/ratatoskr/web/server.py +++ b/src/ratatoskr/web/server.py @@ -42,6 +42,7 @@ from ratatoskr.sessions import ( endpoint_for_plane, get_persona_state, get_session_bifrost, + get_session_messages, get_session_tools, list_agents, ) @@ -448,6 +449,26 @@ async def _session_tools_endpoint(request: Request) -> JSONResponse: return JSONResponse(info, status_code=200) +async def _session_messages_endpoint(request: Request) -> JSONResponse: + """GET /api/sessions/{session_id}/messages → the session's message history. + + Proxies get_session_messages so the SPA can render a session's EXISTING turns + on open — notably a #347 authored first-message seeded at create-time (which + lives in the ledger, not the live turn stream). Any non-200 upstream → a + status-preserving error envelope.""" + session_id = request.path_params["session_id"] + client_factory = request.app.state.client_factory + try: + async with client_factory() as client: + data = await get_session_messages(client, session_id) + except SessionApiFailed as exc: + return JSONResponse( + {"error_code": "session_messages_unavailable", "status": exc.status}, + status_code=exc.status, + ) + return JSONResponse(data, status_code=200) + + async def _session_bifrost_endpoint(request: Request) -> JSONResponse: """GET /api/sessions/{session_id}/bifrost → admin-scoped Bifrost dispatch state (#176). @@ -592,6 +613,7 @@ def create_app( Route("/api/agents/{agent_id}/persona_state", _persona_state_endpoint), Route("/api/affect/{agent_id}", _affect_state_endpoint), Route("/api/sessions/{session_id}/tools", _session_tools_endpoint), + Route("/api/sessions/{session_id}/messages", _session_messages_endpoint), Route("/api/sessions/{session_id}/bifrost", _session_bifrost_endpoint), Route("/api/admin/events", _admin_events_endpoint), Route("/api/turns/{session_id}", _submit_turn_endpoint, methods=["POST"]), diff --git a/src/ratatoskr/web/static/index.html b/src/ratatoskr/web/static/index.html index b4e261d..fec80a9 100644 --- a/src/ratatoskr/web/static/index.html +++ b/src/ratatoskr/web/static/index.html @@ -1166,6 +1166,7 @@ async function startSession() { setConn("idle", "connected"); $("setup").style.display = "none"; $("workspace").classList.add("live"); + await loadTranscript(state.sessionId); await loadPersona(agentId); // Admin/debug surfaces — best-effort hydrate + live stream (all self-render on // failure; the admin key is server-held, never sent from here). @@ -1197,6 +1198,32 @@ function finalizeResponse() { const live = document.querySelector("#transcript .response.live"); if (live) live.classList.remove("live"); } +// Render a session's EXISTING ledger on open — notably a #347 authored +// first-message seeded at create-time (it lives in history, not the live turn +// stream, so without this the transcript is blank until the user speaks). +// Best-effort: a failed/empty fetch just leaves the transcript empty. A seed +// renders byte-identical to a lived assistant turn (model-invisible provenance). +async function loadTranscript(sessionId) { + try { + const r = await fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/messages"); + if (r.status !== 200) return; + const data = await r.json(); + for (const m of (data && data.items) || []) { + if (m.role === "assistant") { + const b = document.createElement("div"); + b.className = "response md-body"; + b.innerHTML = markdownSafe(m.content || ""); + $("transcript").appendChild(b); + } else if (m.role === "user") { + const e = document.createElement("div"); + e.className = "prompt-echo"; + e.textContent = m.content || ""; + $("transcript").appendChild(e); + } + } + $("transcript").scrollTop = $("transcript").scrollHeight; + } catch (_) { /* best-effort — a blank transcript is acceptable */ } +} // Thinking: same live-Markdown treatment into the current turn's block. function appendThinking(text) { LIVE.think += text; diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 7e5ce47..c160192 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -199,6 +199,39 @@ class TestCreateSessionEndpoint: assert hist.call_count == 1 +class TestSessionMessagesEndpoint: + """GET /api/sessions/{id}/messages — proxy session history (renders the #347 seed).""" + + @respx.mock + def test_happy_returns_history(self) -> None: + """happy [tracer]: proxies GET /sessions/{id}/messages → 200 with the items verbatim.""" + payload = { + "session_id": "s-1", + "items": [{"seq": 0, "role": "assistant", "content": "Hey there."}], + "next_cursor": None, + } + respx.get("https://w.example/sessions/s-1/messages").mock( + return_value=httpx.Response(200, json=payload) + ) + from ratatoskr.web.server import create_app + app = create_app(_mock_client_factory()) + resp = TestClient(app).get("/api/sessions/s-1/messages") + assert resp.status_code == 200 + assert resp.json()["items"][0]["content"] == "Hey there." + + @respx.mock + def test_non_200_status_preserved(self) -> None: + """error: upstream 404 → status-preserving session_messages_unavailable envelope.""" + respx.get("https://w.example/sessions/ghost/messages").mock( + return_value=httpx.Response(404, json={"error_code": "session_not_found"}) + ) + from ratatoskr.web.server import create_app + app = create_app(_mock_client_factory()) + resp = TestClient(app).get("/api/sessions/ghost/messages") + assert resp.status_code == 404 + assert resp.json()["error_code"] == "session_messages_unavailable" + + _SNAPSHOT = { "agent_id": "mimir", "pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5}, diff --git a/uv.lock b/uv.lock index 9776388..4997c8f 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,7 +1052,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.19.8" +version = "0.19.9" source = { editable = "." } dependencies = [ { name = "httpx" },