Compare commits

...

2 Commits

Author SHA1 Message Date
vh a0a9d5f5e4 feat(web): debug-surface parity — BifrostState + AdminEvents + Tools panes, PAD-poll fix, reasoning indicator
Bring the browser surface to TUI parity as the primary debug surface:

- Tools inventory (GET /sessions/{id}/tools) folded into the tools pane —
  what the LLM has at turn-fire, above the live tool events.
- BifrostState pane (GET /admin/sessions/{id}/bifrost) — admin-scoped
  dispatch state; the admin key stays server-side (app.state.admin_key),
  never reaches the browser (INV-003 precedent).
- AdminEvents pane (GET /admin/events SSE) — admin lifecycle, session-
  filtered SERVER-side (heartbeats + other-session events dropped); one
  fixed "admin_event" browser event so every type renders (no drops).
- PAD refresh: poll a window (1.5/3.5/6.5/10.5s) instead of a single 2s
  shot that raced the post-turn-async affect.emit (issue #18 foot-gun).
- Reasoning indicator: ephemeral "<Agent> is pondering…" in the transcript
  on `thinking` deltas, cleared when text begins — clearly non-engine.

Admin key wired through entrypoint -> create_app. 9 new respx/route tests
(admin-bearer override, filter unit, SSE stream-filter); 59 web tests pass.
Live-proven against ratatoskr:sindra (bifrost connected, both caps; 253
thinking events -> indicator fires; affect emit lands -> PAD poll catches it).
2026-07-01 12:33:11 -07:00
vh fc1e1487c7 memory: snapshot — v1 coverage-audit converged (REST 17/40, zero in-scope gaps)
Refresh the decay-prone in-flight section from the stale 2026-06-20
(#17/#18) state to the converged-audit state: REST 17/40 covered with
zero in-scope gaps, SSE 11/11, Bifrost planes 8/8; debug-observability
core complete (v0.19.0); standing pins v1.0.0b2 + bifrost 1.0.0; admin
key scopes verified. Recent-decisions log unchanged.
2026-07-01 00:08:53 -07:00
7 changed files with 454 additions and 61 deletions
+42 -52
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr
_Last updated: 2026-06-30_
_Last updated: 2026-07-01_
This file captures durable intent and supporting evidence (goals, decisions,
foot-gun warnings, in-flight state) across context resets. Read it at session
@@ -39,64 +39,54 @@ upstream API key stays server-side (INV-003).
## Current state / in-flight
_As of 2026-06-20:_
_As of 2026-07-01:_
**#17 and #18 BOTH CLOSED — the composite both-plane binding is fully proven.** #18 shipped
`v0.18.0` (`359dbb1`): D2 (PAD read-endpoint, `v0.17.14`) renders live PAD in the web pane from our
`:8390` store; D1 (composite endpoint, `v0.17.16` `7f4ceaa`) — `build_combined_provider_app`
(`provider/combined.py`) on `:8392` wraps bifrost's public `build_combined_app` over BOTH stores +
the shared affect read route; one bound session drives memory.* AND affect.* through ONE endpoint,
op-feed deriving plane per path. Suite **503 green**. **#17 closed in the tracker 2026-06-20**
(shipped `v0.17.8``.13` + the `v0.17.17` op-feed field fix).
**THE v1 COVERAGE-AUDIT HAS CONVERGED.** The audit that ran this session (2026-06-30 → 07-01)
reached its scope-A done-definition: **every frozen Worldtree v1 I/O point is classified — covered
or excluded-with-rationale, zero unaccounted.** Coverage: **REST 17/40 ✅ with ZERO in-scope gaps**
(23 REST path-groups excluded-by-design), **SSE 11/11 ✅**, **Bifrost provider planes 8/8 ✅**
(live-proven). The living ledger is `docs/coverage-map.md`. **v1 cuts when Worldtree tags 1.0**
(ratatoskr v1 = full Worldtree I/O coverage; the target is the coverage map, not a feature list).
Only not-consumed in-scope *method*: `GET /agents/{id}` (consumer-agent lookup, manual-curl-only,
on an already-✅ path group).
**#18's final leg — the Worldtree-DRIVEN composite turn — RAN and is PROVEN end-to-end + persisted
(2026-06-20).** infra-ops added `10.100.10.50:8392` to the personal WT's (`:8081`)
`BIFROST_CLIENT_ALLOWED_HOSTS` (thread `01KVHWJGTT…`), unblocking the smoke. A real WT turn through
`:8392` (session `b83a66b6`, agent `ratatoskr:sindra`, fresh end_user `resmoke-choco-1`) drove the
FULL both-plane lifecycle on ONE endpoint, caps-routed by path: `handshake`
(`caps_granted=[memory, affect]`) → `affect.fetch` + `memory.search` (reads) → `affect.emit`
(`stored:true`, PAD row in `affect_snapshots`) → `memory.upsert_many` (`upserted:1`, chunk
`2df1b79de761b948` in `memory_chunks`). Both writes verified directly in our SQLite. The
model-backend outage that blocked the first attempt (both agents' models `model_unavailable`) was
operator-fixed mid-session, then the resmoke completed clean. **No open legs remain on the composite.**
**The debug-observability core is COMPLETE** (published as the `v0.19.0` milestone): all four
observability panes built + live + consuming their real endpoints — **Persona** (`GET /agents/{id}/persona_state`),
**Tools** (`GET /sessions/{id}/tools`), **BifrostState** (`GET /admin/sessions/{id}/bifrost`),
**AdminEvents** (`GET /admin/events` SSE, session-filtered). **#11 is closed-by-build** (AdminEvents
shipped `v0.18.11`; its long-standing "blocked on `admin.events.read`" status was STALE — the admin
key already carries the scope). The whole slice arc: SSE-resume (`v0.18.5/.6`) → session-picker
(`v0.18.7`) → `--whoami` me/capabilities (`v0.18.8`) → Tools (`v0.18.9`) → BifrostState (`v0.18.10`)
→ AdminEvents (`v0.18.11`) → **v0.19.0 milestone** → Tier-2 characters+persona-write (`v0.19.1`).
**bifrost repinned 0.8.0 → 0.10.0** (floor, `provider` extra). 0.10.0 made `affect.fetch`
MANDATORY (strong-or-absent: `_supports_affect_plane` requires `affect_supported`+`emit`+`fetch`,
gating EVERY affect op incl. emit) — so the repin FORCED `affect.fetch` (`v0.17.15`, conformed
to bifrost's reference `InMemoryAffectStore.fetch``{found, snapshot?}`) or our shipped affect
plane would 400. The composite's affect cap depends on it.
**Standing substrate pins:** Worldtree spec **v1.0.0b2** (`5810a26`) — ratatoskr now vendors the
FROZEN machine-readable `conversation-api-openapi.json` (2.2.0) + `conversation-api-sse-events.schema.json`,
pinned in `.corviduo-canonicals.toml` + drift-gated by `canonical_drift.py` (the prose markdown is a
`tolerate_drift` reference). **bifrost `==1.0.0` / wire v0.6 STABLE/FROZEN.** Suite **573 green**.
**OPERATOR SESSION STATE — `:8390`/`:8391`/`:8765` shells are PRE-#18 code (foot-gun).** web `:8765`
+ affect `:8390` + memory `:8391` are prior-session background shells on OLD code. The **`:8392`
composite provider is RUNNING on NEW code** (`ratatoskr-combined-provider`, pid started Jun19,
`RATATOSKR_OPFEED_PATH=/tmp/ratatoskr-combined-opfeed.jsonl`, shared `affect.db`/`memory.db`)now
`:8392`-allowlisted and WT-turn-proven. To see the full web stack on new code, RESTART `:8390`/`:8765`
from current code (D2 web needs `RATATOSKR_AFFECT_READ_URL`). Consumer/owner key = `wt_live_d81b…`
(`~/.config/ratatoskr/provider.env`, mode 600, rotate via infra-ops); providers SQLite + sqlite-vec,
`memory.db`/`affect.db` at repo root (live sindra PAD: vuong + the `resmoke-choco-1` smoke fixture).
**Keys (env-only, mode 600; rotate via infra-ops):** consumer/Heimdall key at
`~/.config/ratatoskr/provider.env`; **admin key `RATATOSKR_ADMIN_API_KEY`** (Heimdall user
`ratatoskr-readonly`, tier `readonly-admin`, **7 read scopes** incl. `admin.sessions.read` +
`admin.events.read` — verified 2026-07-01, **personal `:8081` only**) in `env.sh`powers the
BifrostState + AdminEvents admin panes. Heimdall keys are PER-INSTANCE (a personal-minted key 401s
on demo).
**Tier-3 memory PROVEN end-to-end** (earlier this session): `ratatoskr:terse-probe`
cold-recalled a seeded user fact (scope_any → 1 hit @ cosine 0.6994), and the verbose
`sindra-probe` too under #296 Stage 2 (v0.36.0). The #296 extraction-quality arc closed
(Stage 1 v0.35.19 gate + Stage 2 v0.36.0 user-only extraction at worldtree-codex; hard-
linguistic layer → Worldtree #305). `:8081` runs v0.36.0.
**Provider identity (the second, still-live role):** ratatoskr owns BOTH ends of the Bifrost
round-trip — the combined `:8392` provider fronts the memory (`:8391`) + affect (`:8390`) stores
(SQLite + sqlite-vec, `memory.db`/`affect.db` at repo root); consumer/owner key `wt_live_d81b…`.
`ratatoskr:sindra` is the owner-scoped Tier-3 agent (invisible to `GET /agents`; check via
`GET /agents/<owner>:<name>` with the owner key). This provider surface is settled/converged — no
in-flight work on it.
**Sindra:** `ratatoskr:sindra`, `thoughtful-character` role → `mistral-small-4-reasoning`
(DELETE+redefined on v0.35.16; `memory:{}` block trips the promotion gate). Owner-scoped
(separate `consumer_agents` table) — invisible to `GET /agents`; check via
`GET /agents/<owner>:<name>` with the owner key.
**Open / deferred (nothing blocking):** #10 (subject-migration watch on Worldtree #196). Design
note: bare-TUI + 0-sessions → error (§4-clause-consistent; the friendlier auto-fall-to-new is
deferred, operator-preference). heid-code-review was run on the b1 resume slice only (panel: zero
findings, cross-model-verified); b2 + the later slices were offered but not reviewed. `graphify-out/`
runs dirty (auto-regen, not chased — never stage it). Contract-skip was invoked for the low-effort
GET wrappers + `stream_admin_events`, but contract #2 / #1 / #6 were amended to stay canonical.
**Standing:** Worldtree spec pin v0.35.16 (`f1b59f8`); **bifrost 0.10.0 / wire v0.6**
(`scope_all`+`scope_any`). Heimdall key env-only at `~/.config/ratatoskr/provider.env` (mode
600); rotate via infra-ops. `graphify-out/` runs dirty (auto-regen, not chased). **Open issues:
#11** (AdminEvents pane — the next-reachable Worldtree-I/O coverage gap, blocked on an
`admin.events.read` scope request) and **#10** (subject-migration watch on Worldtree #196) — both
deferred. **#17 + #18 CLOSED.** Codex-first pilot dormant. No in-flight implementation work — repo
is at a converged checkpoint; v1 advances when Worldtree does (v1 = full Worldtree I/O coverage).
Branch: `main` (tag `v0.18.0`, `359dbb1`) — **in sync with `origin/main`** (the full #17+#18 arc is
pushed). This `/snapshot` commit will sit one ahead of origin until pushed (push is the operator's
call). Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
Branch: `main`**in sync with `origin/main`** at **`v0.19.1`** (`af07a23`); the whole session's arc
is pushed. Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
## Recent decisions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.19.1"
version = "0.19.2"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
+5
View File
@@ -68,6 +68,10 @@ def main(argv: list[str] | None = None) -> int:
affect_read_url = os.environ.get(
"RATATOSKR_AFFECT_READ_URL", "http://127.0.0.1:8390"
)
# Admin observability panes (BifrostState + AdminEvents): the readonly-admin
# key stays SERVER-SIDE — the server proxies admin-scoped reads; the browser
# never receives the key, only the session-filtered result.
admin_key = os.environ.get("RATATOSKR_ADMIN_API_KEY")
# INV-001: lazy import. Users without [web] extras get a clean hint
# instead of a raw ImportError. Scoped narrowly to the OPTIONAL
@@ -108,6 +112,7 @@ def main(argv: list[str] | None = None) -> int:
bifrost_consumer_key=bifrost_consumer_key,
bifrost_visible_host=bifrost_visible_host,
affect_read_url=affect_read_url,
admin_key=admin_key,
)
# Boot banner to stderr (so stdout stays clean for piping).
+112 -1
View File
@@ -18,7 +18,12 @@ from importlib.metadata import version as _pkg_version
import httpx
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import FileResponse, JSONResponse, StreamingResponse
from starlette.responses import (
FileResponse,
JSONResponse,
Response,
StreamingResponse,
)
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
@@ -35,9 +40,12 @@ from ratatoskr.sessions import (
create_session,
endpoint_for_plane,
get_persona_state,
get_session_bifrost,
get_session_tools,
list_agents,
)
from ratatoskr.sse_client import (
AdminEvent,
CancelAlreadyCompleted,
Cancelled,
CancelFailed,
@@ -50,6 +58,7 @@ from ratatoskr.sse_client import (
SseConnectionDropped,
TurnIdFlip,
cancel_turn,
stream_admin_events,
stream_turn_resilient,
)
@@ -416,6 +425,100 @@ async def _affect_state_endpoint(request: Request) -> JSONResponse:
return JSONResponse(r.json(), status_code=r.status_code)
async def _session_tools_endpoint(request: Request) -> JSONResponse:
"""GET /api/sessions/{session_id}/tools → owner-scoped tool inventory (spec #183).
Proxies get_session_tools with the client's CONSUMER bearer (no admin scope):
the merged {agent_id, builtin_tools, bifrost_tools} the LLM saw at turn-fire.
Any non-200 upstream → surfaced as 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:
info = await get_session_tools(client, session_id)
except SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_tools_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(info, status_code=200)
async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
"""GET /api/sessions/{session_id}/bifrost → admin-scoped Bifrost dispatch state (#176).
The admin key is SERVER-HELD (app.state.admin_key) and never reaches the
browser (INV-003 precedent — upstream credentials stay server-side); the
wrapper overrides the Authorization header with it. Fail-visible when the
admin key isn't configured (never a silent empty pane)."""
session_id = request.path_params["session_id"]
admin_key = request.app.state.admin_key
if not admin_key: # PRE-001: fail-visible, never silent
return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
bstate = await get_session_bifrost(client, session_id, admin_key=admin_key)
except SessionApiFailed as exc:
return JSONResponse(
{"error_code": "bifrost_state_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(bstate, status_code=200)
def _admin_event_matches_web(ev: AdminEvent, session_id: str | None) -> bool:
"""AdminEvents filter (design-brief §6, mirrors the TUI): forward non-heartbeat
system.* (stream-integrity signals) + events for the active session; drop the
rest so the browser sees only session-relevant lifecycle, never the full
cross-session admin firehose."""
if ev.type == "system.heartbeat":
return False
if ev.type.startswith("system."):
return True
return session_id is not None and ev.data.get("session_id") == session_id
async def _admin_events_endpoint(request: Request) -> Response:
"""GET /api/admin/events?session_id=... → SSE proxy of GET /admin/events (#11).
The admin key is SERVER-HELD; the browser only ever receives the session-filtered
stream (never the key, never the cross-session firehose). Long-lived + best-effort:
a connect failure or mid-stream drop emits a labeled `stream_error` event and ends."""
admin_key = request.app.state.admin_key
if not admin_key: # PRE-001: fail-visible, never silent
return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
session_id = request.query_params.get("session_id")
client_factory = request.app.state.client_factory
async def gen() -> AsyncIterator[bytes]:
client = client_factory()
try:
async for ev in stream_admin_events(client, admin_key=admin_key):
if not _admin_event_matches_web(ev, session_id):
continue
# Fixed SSE event name so the browser renders EVERY admin type
# with one listener (no per-type enumeration → nothing silently
# dropped); the real dotted type rides in the payload.
yield _format_sse(
"admin_event",
{"id": ev.id, "type": ev.type, "timestamp": ev.timestamp,
"data": ev.data},
)
except (SseConnectFailed, SseConnectionDropped, MalformedSseId,
MalformedSseData) as exc:
yield _format_sse(
"stream_error",
{"exception": type(exc).__name__, "message": str(exc)},
)
except asyncio.CancelledError:
raise # browser disconnect — let the generator unwind
finally:
await client.aclose()
return StreamingResponse(gen(), media_type="text/event-stream")
def create_app(
client_factory: Callable[[], httpx.AsyncClient],
*,
@@ -423,6 +526,7 @@ def create_app(
bifrost_consumer_key: str | None = None,
bifrost_visible_host: str | None = None,
affect_read_url: str | None = None,
admin_key: str | None = None,
) -> Starlette:
"""Construct the Starlette app — wire routes + state per FN create_app.
@@ -483,6 +587,9 @@ def create_app(
Route("/api/sessions", _create_session_endpoint, methods=["POST"]),
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}/bifrost", _session_bifrost_endpoint),
Route("/api/admin/events", _admin_events_endpoint),
Route("/api/turns/{session_id}", _submit_turn_endpoint, methods=["POST"]),
Route("/api/turns/{session_id}/stream", _stream_turn_endpoint),
Route("/api/turns/{session_id}/cancel", _cancel_turn_endpoint, methods=["POST"]),
@@ -498,6 +605,10 @@ def create_app(
# Issue #18 (Deliverable 2): the provider affect-read base URL (server→provider hop,
# same dev box) — distinct from the WT-visible host used for binding.
app.state.affect_read_url = affect_read_url
# Admin observability panes (BifrostState + AdminEvents): the admin key is
# SERVER-HELD (RATATOSKR_ADMIN_API_KEY) and never reaches the browser — the
# server proxies admin-scoped reads and forwards only the session-filtered result.
app.state.admin_key = admin_key
# INV-002: turn registry is in-process memory, keyed (session_id, turn_id)
app.state.turn_registry = {}
return app
+173 -6
View File
@@ -231,6 +231,26 @@ body {
50% { content: "··"; } 75% { content: "···"; }
}
/* reasoning indicator — a UI affordance in the transcript, visually distinct
from the agent's response (.response, left-bordered). Italic + a ✦ glyph so
it reads as "the app telling you inference is happening", never as engine
output. Ephemeral: appears on reasoning tokens, gone the moment real text
begins or the turn ends. */
.thinking-note {
display: inline-flex; align-items: center; gap: 8px;
color: var(--blue); font-style: italic; font-size: 12px;
margin: 6px 0; padding-left: 18px; opacity: 0.9;
animation: rise 0.3s ease both;
}
.thinking-note::before {
content: "✦"; font-style: normal; color: var(--cyan);
text-shadow: 0 0 10px var(--glow-cyan);
}
.thinking-note::after {
content: ""; width: 16px; text-align: left;
animation: dots 1.4s steps(4, end) infinite;
}
/* terminal status chips */
.chip {
display: inline-flex; align-items: center; gap: 7px;
@@ -487,6 +507,8 @@ body {
<button class="tab" data-pane="debug">debug <span class="kbd">⌃2</span><span class="badge">0</span></button>
<button class="tab" data-pane="thinking">think <span class="kbd">⌃3</span><span class="badge">0</span></button>
<button class="tab" data-pane="persona">persona <span class="kbd">⌃4</span></button>
<button class="tab" data-pane="bifrost">bifrost <span class="kbd">⌃5</span></button>
<button class="tab" data-pane="admin">admin <span class="kbd">⌃6</span><span class="badge">0</span></button>
</nav>
<div class="pane-head">
<span id="pane-name">tools</span>
@@ -497,6 +519,8 @@ body {
<div class="pane" id="pane-debug"><div class="empty">waiting for wire telemetry…</div></div>
<div class="pane" id="pane-thinking"><div class="empty">no chain-of-thought captured yet</div></div>
<div class="pane" id="pane-persona"><div class="empty">persona state loads on session open</div></div>
<div class="pane" id="pane-bifrost"><div class="empty">bifrost dispatch state loads on session open</div></div>
<div class="pane" id="pane-admin"><div class="empty">admin lifecycle events stream on session open</div></div>
</div>
</section>
</main>
@@ -542,7 +566,7 @@ body {
"use strict";
const $ = (id) => document.getElementById(id);
const state = { sessionId: null, agentId: null, turnId: null, eventSource: null };
const state = { sessionId: null, agentId: null, turnId: null, eventSource: null, lastAffectAt: null, adminES: null };
function esc(s) {
const d = document.createElement("div");
@@ -603,8 +627,46 @@ function markdownSafe(raw) {
// per-turn live buffers (reset at turn open)
const LIVE = { resp: "", think: "" };
// ---- reasoning indicator (a UI affordance — NOT engine output) ----------
// When the model streams reasoning/chain-of-thought, show an ephemeral
// "<Agent> is pondering" line in the transcript so the user knows inference
// is happening. Rotates phrasing for liveliness; removed the instant real text
// begins or the turn ends. agentDisplayName is rendered via textContent (never
// innerHTML) so an adversarial agent_id can't inject markup (INV-004).
const THINK_PHRASES = ["is thinking", "is pondering", "appears thoughtful",
"is reasoning", "is turning it over"];
let thinkRotator = null;
function agentDisplayName() {
if (!state.agentId) return "the agent";
const tail = String(state.agentId).split(":").pop() || "the agent";
return tail.charAt(0).toUpperCase() + tail.slice(1);
}
function showThinkingNote() {
// reasoning tokens ARE the first tokens — supersede the awaiting-first heartbeat
const aw = document.querySelector("#transcript .awaiting.live");
if (aw) aw.remove();
let el = document.querySelector("#transcript .thinking-note");
if (!el) {
el = document.createElement("div");
el.className = "thinking-note";
el.appendChild(document.createTextNode(""));
$("transcript").appendChild(el);
let i = 0;
const paint = () => { el.firstChild.textContent =
`${agentDisplayName()} ${THINK_PHRASES[i % THINK_PHRASES.length]}`; i++; };
paint();
thinkRotator = setInterval(paint, 2600);
}
$("transcript").scrollTop = $("transcript").scrollHeight;
}
function hideThinkingNote() {
if (thinkRotator) { clearInterval(thinkRotator); thinkRotator = null; }
const el = document.querySelector("#transcript .thinking-note");
if (el) el.remove();
}
// ---- pane helpers ----
const PANE_BADGE = { tools: 0, debug: 0, thinking: 0 };
const PANE_BADGE = { tools: 0, debug: 0, thinking: 0, admin: 0 };
function bumpBadge(pane) {
if (!(pane in PANE_BADGE)) return;
PANE_BADGE[pane] += 1;
@@ -773,6 +835,7 @@ async function loadAffect(agentId) {
const snap = await r.json();
renderAffectPane(snap);
setPersonaStrip(snap); // pad bars are the live signal
state.lastAffectAt = snap.emitted_at || state.lastAffectAt; // post-turn poll stop-signal
} else {
let code = "";
try { code = (await r.json()).error_code || ""; } catch (_) {}
@@ -794,6 +857,93 @@ async function loadAffect(agentId) {
}
}
// ---- tools inventory (#183): what the LLM HAS at turn-fire (static), rendered
// at the TOP of the tools pane; live tool_start/result events append below it. ---
function renderToolsInventory(inv) {
const row = (k, v) => `<div><span class="pk">${esc(k)}</span> <span class="pv">${esc(v)}</span></div>`;
const head = (t) => `<div class="ph">${esc(t)}</div>`;
const names = (arr) => (arr || []).map((t) => (typeof t === "string" ? t : (t && t.name) || "?"));
const builtin = inv.builtin_tools || [], bifrost = inv.bifrost_tools || [];
const html =
head("tool inventory · " + (inv.agent_id || "?")) +
row("builtin (" + builtin.length + ")", names(builtin).join(", ") || "none") +
row("bifrost (" + bifrost.length + ")", names(bifrost).join(", ") || "none") +
`<div class="rule">— live tool events —</div>`;
const pane = $("pane-tools");
const empty = pane.querySelector(".empty");
if (empty) empty.remove();
let block = pane.querySelector(".tools-inventory");
if (!block) {
block = document.createElement("div");
block.className = "tools-inventory";
pane.insertBefore(block, pane.firstChild);
}
block.innerHTML = html;
}
async function loadSessionTools(sessionId) {
try {
const r = await fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/tools");
if (r.status === 200) renderToolsInventory(await r.json());
// non-200 → best-effort hydrate; leave the live tool pane as-is (mirrors TUI)
} catch (_) {}
}
// ---- Bifrost dispatch state (#176): admin-scoped, server-proxied (admin key
// stays server-side; the browser only receives the state). ----
function renderBifrostState(b) {
const row = (k, v) => `<div><span class="pk">${esc(k)}</span> <span class="pv">${esc(v)}</span></div>`;
const head = (t) => `<div class="ph">${esc(t)}</div>`;
const tools = b.tools || [];
$("pane-bifrost").innerHTML =
head("bifrost dispatch state") +
row("endpoint", b.endpoint_url || "?") +
row("consumer", b.consumer_id || "?") +
row("connected", JSON.stringify(b.connected)) +
row("caps", (b.capabilities_granted || []).join(", ") || "none") +
`<div> </div>` + head("tools (" + tools.length + ")") +
(tools.map((t) => row("·", (t.name || "?") + (t.description ? " — " + t.description : ""))).join("")
|| `<div class="empty">none</div>`);
}
async function loadBifrostState(sessionId) {
try {
const r = await fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/bifrost");
if (r.status === 200) { renderBifrostState(await r.json()); return; }
let code = ""; try { code = (await r.json()).error_code || ""; } catch (_) {}
let msg;
if (code === "admin_key_not_configured") msg = "bifrost state needs the readonly-admin key (RATATOSKR_ADMIN_API_KEY) server-side.";
else if (r.status === 404) msg = "session is not Bifrost-bound (no live dispatch client).";
else if (r.status === 403) msg = "admin key lacks the admin.sessions.read scope.";
else msg = `bifrost state unavailable (HTTP ${esc(r.status)}${code ? " · " + esc(code) : ""}).`;
$("pane-bifrost").innerHTML = `<div class="empty">${msg}</div>`;
} catch (_) {
$("pane-bifrost").innerHTML = `<div class="empty">bifrost state fetch failed</div>`;
}
}
// ---- Admin lifecycle events (#11): admin-scoped SSE, session-filtered SERVER-side.
// One fixed "admin_event" listener renders every type; the dotted type is in data. ---
function openAdminEvents(sessionId) {
if (state.adminES) { state.adminES.close(); state.adminES = null; }
const es = new EventSource("/api/admin/events?session_id=" + encodeURIComponent(sessionId));
state.adminES = es;
es.addEventListener("admin_event", (e) => {
let d; try { d = JSON.parse(e.data); } catch (_) { return; }
appendTo("pane-admin",
`<div>[${ts()}] <span style="color:var(--blue)">${esc(d.type || "event")}</span> `
+ `${esc(JSON.stringify(d.data || {}))}</div>`);
});
es.addEventListener("stream_error", (e) => {
let d = {}; try { d = JSON.parse(e.data); } catch (_) {}
appendTo("pane-admin", `<div class="rule">— admin stream ended: ${esc(d.exception || "error")} —</div>`);
});
es.onerror = () => {
const pane = $("pane-admin");
if (pane.querySelector(".empty")) {
pane.innerHTML = `<div class="empty">admin stream unavailable — needs the readonly-admin key + admin.events.read scope.</div>`;
}
};
}
// ---- session lifecycle ----
async function startSession() {
const agentId = $("agent-picker").value;
@@ -837,6 +987,11 @@ async function startSession() {
$("setup").style.display = "none";
$("workspace").classList.add("live");
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).
loadSessionTools(state.sessionId);
loadBifrostState(state.sessionId);
openAdminEvents(state.sessionId);
$("prompt-input").focus();
} catch (e) {
$("setup-err").textContent = "network error opening session";
@@ -943,11 +1098,13 @@ async function submitPrompt() {
es.addEventListener("thinking", (e) => {
const d = JSON.parse(e.data);
thinkingDeltas += 1;
showThinkingNote(); // ephemeral "<Agent> is pondering…" in the transcript
appendThinking(d.content);
});
es.addEventListener("text", (e) => {
const d = JSON.parse(e.data);
textDeltas += 1;
hideThinkingNote(); // real text begins — reasoning display is done
appendResponse(d.content);
});
es.addEventListener("text_boundary", (e) => {
@@ -990,6 +1147,7 @@ async function submitPrompt() {
function terminal(label, cls, e) {
const aw = document.querySelector("#transcript .awaiting.live");
if (aw) aw.remove();
hideThinkingNote();
finalizeResponse();
document.querySelectorAll("#pane-thinking .think-live").forEach((b) => b.classList.remove("think-live"));
let meta = "";
@@ -1006,9 +1164,18 @@ async function submitPrompt() {
$("composer").classList.remove("streaming");
setConn(cls === "error" ? "error" : "idle", cls === "error" ? "error" : "connected");
if (cls === "done" && state.agentId) {
// Tier-3 affect.emit is POST-TURN ASYNC — it lands in our store a couple seconds
// after [done]. Refresh the pane on a short delay to catch the new PAD (issue #18).
setTimeout(() => loadPersona(state.agentId), 2000);
// Tier-3 affect.emit is POST-TURN ASYNC and can land well after [done] — a single
// fixed refresh races it (issue #18 foot-gun). Poll a short window, stopping once
// the snapshot's emitted_at advances past the pre-turn value (or a new turn starts).
const beforeAt = state.lastAffectAt;
let settled = false;
for (const delay of [1500, 3500, 6500, 10500]) {
setTimeout(async () => {
if (settled || state.turnId) return;
await loadPersona(state.agentId);
if (state.lastAffectAt && state.lastAffectAt !== beforeAt) settled = true;
}, delay);
}
}
$("prompt-input").focus();
}
@@ -1053,7 +1220,7 @@ document.querySelectorAll(".tab").forEach((t) =>
// ---- keyboard ----
document.addEventListener("keydown", (e) => {
if (e.ctrlKey && ["1", "2", "3", "4"].includes(e.key)) {
if (e.ctrlKey && ["1", "2", "3", "4", "5", "6"].includes(e.key)) {
const tabs = document.querySelectorAll(".tab");
const idx = parseInt(e.key, 10) - 1;
if (tabs[idx]) { activateTab(tabs[idx]); e.preventDefault(); }
+120
View File
@@ -1062,3 +1062,123 @@ class TestAffectStateEndpoint:
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
assert resp.status_code == 400
assert resp.json()["error_code"] == "missing_end_user_id"
class TestSessionToolsEndpoint:
"""session_tools_endpoint — proxy owner-scoped GET /sessions/{id}/tools (#183)."""
@respx.mock
def test_happy_returns_inventory(self) -> None:
"""happy [tracer]: 200 inventory → 200 verbatim."""
respx.get("https://w.example/sessions/s-1/tools").mock(
return_value=httpx.Response(200, json={
"agent_id": "ratatoskr:sindra",
"builtin_tools": ["echo"],
"bifrost_tools": [{"name": "memory.search"}],
})
)
from ratatoskr.web.server import create_app
resp = TestClient(create_app(_mock_client_factory())).get("/api/sessions/s-1/tools")
assert resp.status_code == 200
assert resp.json()["agent_id"] == "ratatoskr:sindra"
@respx.mock
def test_upstream_404_status_preserving_envelope(self) -> None:
"""error: upstream 404 → 404 session_tools_unavailable envelope."""
respx.get("https://w.example/sessions/s-1/tools").mock(
return_value=httpx.Response(404, content=b"nope")
)
from ratatoskr.web.server import create_app
resp = TestClient(create_app(_mock_client_factory())).get("/api/sessions/s-1/tools")
assert resp.status_code == 404
assert resp.json()["error_code"] == "session_tools_unavailable"
class TestSessionBifrostEndpoint:
"""session_bifrost_endpoint — proxy admin-scoped GET /admin/sessions/{id}/bifrost (#176)."""
@respx.mock
def test_happy_overrides_with_admin_bearer(self) -> None:
"""happy [tracer]: 200 state → 200; request carries the ADMIN bearer, not consumer."""
route = respx.get("https://w.example/admin/sessions/s-1/bifrost").mock(
return_value=httpx.Response(200, json={
"endpoint_url": "http://x:8392", "connected": True,
"capabilities_granted": ["memory", "affect"], "tools": [],
})
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), admin_key="adm-key")
resp = TestClient(app).get("/api/sessions/s-1/bifrost")
assert resp.status_code == 200
assert resp.json()["connected"] is True
assert route.calls.last.request.headers["Authorization"] == "Bearer adm-key"
def test_no_admin_key_fails_visible_400(self) -> None:
"""error: no admin key configured → 400 admin_key_not_configured, no upstream call."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory()) # no admin_key
resp = TestClient(app).get("/api/sessions/s-1/bifrost")
assert resp.status_code == 400
assert resp.json()["error_code"] == "admin_key_not_configured"
@respx.mock
def test_upstream_404_status_preserving_envelope(self) -> None:
"""error: upstream 404 (not bound) → 404 bifrost_state_unavailable envelope."""
respx.get("https://w.example/admin/sessions/s-1/bifrost").mock(
return_value=httpx.Response(404, content=b"nope")
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), admin_key="adm-key")
resp = TestClient(app).get("/api/sessions/s-1/bifrost")
assert resp.status_code == 404
assert resp.json()["error_code"] == "bifrost_state_unavailable"
class TestAdminEventsEndpoint:
"""admin_events_endpoint — SSE proxy of GET /admin/events, session-filtered (#11)."""
def test_filter_semantics(self) -> None:
"""unit: heartbeats drop, system.* pass, else match on session_id."""
from ratatoskr.sse_client import AdminEvent
from ratatoskr.web.server import _admin_event_matches_web
def mk(t: str, sid: "str | None" = None) -> AdminEvent:
return AdminEvent(id=1, type=t, timestamp=None,
data={"session_id": sid} if sid else {})
assert _admin_event_matches_web(mk("system.heartbeat"), "s-1") is False
assert _admin_event_matches_web(mk("system.degraded"), "s-1") is True
assert _admin_event_matches_web(mk("session.created", "s-1"), "s-1") is True
assert _admin_event_matches_web(mk("session.created", "other"), "s-1") is False
assert _admin_event_matches_web(mk("session.created", "s-1"), None) is False
def test_no_admin_key_fails_visible_400(self) -> None:
"""error: no admin key → 400 admin_key_not_configured (no stream opened)."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/admin/events?session_id=s-1")
assert resp.status_code == 400
assert resp.json()["error_code"] == "admin_key_not_configured"
@respx.mock
def test_streams_filtered_events_fixed_name(self) -> None:
"""happy: SSE → only session-matching + system.* forwarded, as `admin_event`."""
stream = (
b'event: session.created\n'
b'data: {"type":"session.created","data":{"session_id":"s-1"}}\n\n'
b'event: system.heartbeat\n'
b'data: {"type":"system.heartbeat","data":{}}\n\n'
b'event: turn.started\n'
b'data: {"type":"turn.started","data":{"session_id":"other"}}\n\n'
b'event: system.degraded\n'
b'data: {"type":"system.degraded","data":{}}\n\n'
)
respx.get("https://w.example/admin/events").mock(return_value=_sse_resp(stream))
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), admin_key="adm-key")
body = TestClient(app).get("/api/admin/events?session_id=s-1").text
assert "event: admin_event" in body # fixed browser-facing name
assert '"type": "session.created"' in body # matches active session → forwarded
assert "system.degraded" in body # system.* → forwarded
assert "system.heartbeat" not in body # heartbeat → dropped
assert "turn.started" not in body # other session → dropped
Generated
+1 -1
View File
@@ -1052,7 +1052,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.19.1"
version = "0.19.2"
source = { editable = "." }
dependencies = [
{ name = "httpx" },