Compare commits

..

11 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
vh af07a2329a feat(#2): Tier-2 — transient characters + persona-state write; audit converges
v1 coverage-audit: the last in-scope client I/O points. The audit now
CONVERGES — REST 17/40 covered with zero in-scope gaps (23 excluded-by-
design), SSE 11/11, Bifrost planes 8/8.

- sessions.py: list_character_models / create_character / get_character_state
  / delete_character (#161, character.read/write) + set_persona_state
  (POST /sessions/{id}/persona_state — freeform body, unpinned in the
  frozen surface). 200/201 -> dict (or None on 204), off-status ->
  SessionApiFailed.
- cli.py: two one-shot probes (mirror --whoami): --characters (CRUD
  lifecycle report) + --set-persona-pad "p,a,d" (requires --session).
  New ParsedArgs.characters/set_persona_pad + probe mutual-exclusion.
- Contract #2 amended (5 FNs) + validated. TDD: 7 wrapper + 5 cli tests.
  Suite 573 green; touched code ruff-clean.
- Char read side live-proven (GET /models/available-for-characters -> 200).

Coverage-map: convergence frontier CLOSED — scope-A "done" (every frozen
I/O point classified) is met; ratatoskr cuts v1 when Worldtree tags 1.0.
2026-06-30 23:57:09 -07:00
vh 5fbe353836 feat: v0.19.0 — debug-observability core complete
Milestone minor (operator-approved). Publishes the design-brief's
headline deliverable: the multi-pane debug-observability dashboard is
complete — all four observability panes are built and consuming their
real Worldtree endpoints:

- Persona      → GET /agents/{id}/persona_state
- Tools        → GET /sessions/{id}/tools
- BifrostState → GET /admin/sessions/{id}/bifrost
- AdminEvents  → GET /admin/events (SSE)

v1 client-REST coverage is 12/40; both non-REST surfaces (SSE 11/11,
Bifrost provider planes 8/8) already complete. Only Tier-2 client I/O
(transient-characters routing, persona_state-write) remains in scope;
everything else is covered or excluded-by-design in docs/coverage-map.md.

Version bump only (the feature arc landed across v0.18.5–v0.18.11).
2026-06-30 23:38:46 -07:00
vh a3c92b68dc feat(#11): AdminEvents pane — GET /admin/events SSE (session-filtered)
v1 coverage-audit: the last unbuilt design-brief §5 debug pane. #11's
blocker was already satisfied (admin key carries admin.events.read).
Completes the admin/debug-observability core.

- sse_client.py: AdminEvent dataclass + stream_admin_events — a new
  long-lived SSE consumer for the admin lifecycle stream (envelope
  {id,type,timestamp,data}), admin-scoped (bearer-override), Last-Event-ID
  resume. non-200 -> SseConnectFailed; mid-drop -> SseConnectionDropped.
- tui.py: "AdminEvents" TabPane + _format_admin_event + _admin_event_matches
  (design-brief §6 filter: active-session + non-heartbeat system.*) +
  _stream_admin_events long-lived best-effort worker (unconditional
  on_mount; self-labels not-configured / unavailable / stream-ended).
- Contract-skipped for stream_admin_events (out of #1's turn-SSE scope;
  spec § Admin Event Stream is the reference). TDD: 4 sse_client + 5 tui
  tests. Suite 561 green.
- LIVE-AUTH-PROVEN on :8081 (GET /admin/events -> HTTP 200 under admin key).

Coverage: REST 12/40. Tier 1 debug-observability core complete.
2026-06-30 23:25:34 -07:00
vh 9ce83d5fdc feat(#2): BifrostState pane — GET /admin/sessions/{id}/bifrost (admin-key)
v1 coverage-audit: the last unbuilt design-brief §5 debug widget. First
admin-key consumer in ratatoskr.

- sessions.py: get_session_bifrost(client, session_id, *, admin_key) —
  admin-scoped (admin.sessions.read); the request overrides Authorization
  with admin_key (distinct from the consumer bearer). 200 -> dict, non-200
  -> SessionApiFailed (403 scope-denied, 404 not-bound).
- cli.py: --admin-key flag + RATATOSKR_ADMIN_API_KEY env -> ParsedArgs.admin_key.
- tui.py: new "Bifrost" TabPane + _format_bifrost_state + _hydrate_bifrost_state
  best-effort worker (unconditional on_mount). Writes {endpoint, connected,
  caps, tools} + audits; self-labels "not configured" / "not bound" / graceful
  on 403+error, never crashes.
- Contract #2 amended (FN, incl. the bearer-override POST) + validated. TDD:
  4 wrapper tests + 1 format unit + 3 hydrate integration. Suite 552 green.
- LIVE-AUTH-PROVEN on :8081 (admin key reached resource-layer 404, not 401/403).

Ledger correction: #11 (AdminEvents) is NO LONGER BLOCKED — the admin key
was verified to carry admin.events.read; only the pane is unbuilt. Coverage:
REST 11/40.
2026-06-30 23:12:29 -07:00
vh e62208d8e3 feat(#2): consume GET /sessions/{id}/tools — Tools-pane inventory hydrate
v1 coverage-audit Tier-2 quick win. The owner-scoped tool-inventory
endpoint (#183) had no caller; wire it into the TUI Tools pane.

- sessions.py: get_session_tools (GET /sessions/{id}/tools) — owner-
  scoped (consumer key, no admin scope), 200 -> parsed dict verbatim,
  non-200 -> SessionApiFailed. Mirrors get_persona_state / get_me.
- tui.py: _format_tool_inventory helper + _hydrate_session_tools
  best-effort worker (mirrors _hydrate_persona), wired unconditionally
  in on_mount. Writes the merged {agent_id, builtin_tools,
  bifrost_tools} inventory the LLM saw at turn-fire into the Tools
  pane + audits; never crashes on failure.
- Covers the design-brief 5 "Tools widget" via the reachable owner
  endpoint (the admin variant stays a gap only for cross-user debug).
- Contract #2 amended (FN) + validated. TDD: 3 wrapper tests + 1
  format-helper unit + 2 hydrate integration tests. Coverage: REST
  10/40. Suite 544 green; touched code ruff-clean.
2026-06-30 22:50:12 -07:00
vh 0205b81319 memory: b1 heid-code-review panel — zero findings (cross-model-verified)
Gróa + Hulda + Regin each independently reviewed stream_turn_resilient
vs contract #1 (artifact-only) → all three zero findings. Records the
clean bill + the calibration signal (prescriptive contract + TDD =
confirmation, not discovery).
2026-06-30 22:38:40 -07:00
vh 387ac4ab2c feat(#2): consume GET /me + GET /capabilities via --whoami one-shot
v1 coverage-audit slice (capabilities+me). Both endpoints had no
caller; add them as cheap boot-time debug primitives.

- sessions.py: get_me (GET /me — identity/whoami) + get_capabilities
  (GET /capabilities — Echo ephemeral-template discovery). Mirror
  get_persona_state: 200 -> parsed dict verbatim, non-200 ->
  SessionApiFailed. Freeform dicts (frozen OpenAPI types both as
  objects).
- cli.py: new --whoami one-shot mode (mirrors --send). Fetches both,
  prints an identity + capabilities report, exits. Standalone probe:
  mutually exclusive with --send/--session/--new/--agent; opens no
  session. New ParsedArgs.whoami field + main() dispatch.
- Contract #2 amended (2 FNs) + validated. TDD: 5 wrapper tests +
  5 cli tests (validation + mode + error). Coverage map: REST 9/40.
  Suite 538 green; touched code ruff-clean.

Audit note: /capabilities is the Echo ephemeral-template discovery
endpoint, not a generic server-caps endpoint (coverage-map framing
corrected). TUI-surfacing of /me + /capabilities deferred.
2026-06-30 22:21:59 -07:00
vh 5c1b9816d4 feat(#6): startup session picker for bare TUI mode
v1 coverage-audit slice b2. The audit found list_sessions had no
caller — the startup session picker (design-brief §4) was never built;
bare TUI mode was a hard usage error. Add SessionPickerApp (mirrors
AgentPickerApp) and resolve bare mode in _resolve_then_run.

- Bare TUI mode (no --session/--new) now valid → session picker.
  Resolution: 0 sessions -> [no_sessions] exit 14 (resume-only per
  §4 "no in-app creation, --new only"); exactly 1 -> auto-resume
  (§4 "picker only when >1"); >=2 -> SessionPickerApp -> resume pick
  (Esc/Ctrl-D -> exit 0).
- cli._parse: bare TUI valid; --send still requires one flag; --agent
  forbidden in bare mode. run_tui PRE-002 xor -> mutually-exclusive.
- Contract #6 amended (SessionPickerApp + bare-mode resolution) +
  validated. TDD: 3 picker pilot tests + 5 resolution tests + 3 cli
  validation tests. Suite 528 green; touched code ruff-clean.

Design note: bare + 0 sessions errors (honors §4's no-in-app-creation
clause); the friendlier auto-fall-through-to-new is deferred pending
operator preference.
2026-06-30 22:05:14 -07:00
vh 2ba4244e9e feat(#1): route TUI + web presenters through stream_turn_resilient
Complete b1's design-brief §8b promise ("all presenters share the
consumer"): the TUI and web SSE consumers now resume transparently on
a mid-stream drop, same as cli --send (v0.18.5). The TUI is the primary
beneficiary — long-lived dev sessions across laptop suspend.

Name-for-name swap of stream_turn -> stream_turn_resilient at the two
remaining consumer loops (tui.py:1321, web/server.py:294) + their
imports. No behavioral change on the happy path (resilient == stream
when there is no drop); suite 518 green; touched lines ruff+mypy clean
(pre-existing tui/web lint debt left untouched per surgical rule).
2026-06-30 15:46:04 -07:00
18 changed files with 2535 additions and 121 deletions
+166
View File
@@ -206,3 +206,169 @@ TESTS:
limit_above_max [adversarial]: limit=300 → AssertionError; no HTTP issued limit_above_max [adversarial]: limit=300 → AssertionError; no HTTP issued
empty_cursor [adversarial]: cursor="" → AssertionError; no HTTP issued empty_cursor [adversarial]: cursor="" → AssertionError; no HTTP issued
``` ```
## Amendment 2026-06-30 — boot-time introspection reads (v1 coverage-audit: capabilities+me)
The v1 coverage-audit added two read-only server-introspection endpoints as
cheap debug primitives (surfaced via a new `ratatoskr --whoami` one-shot). Both
mirror `get_persona_state`: GET, 200 → parsed dict verbatim, any non-200 →
`SessionApiFailed`. The frozen OpenAPI types both responses as freeform objects,
so the wrappers return `dict[str, Any]` (not a typed dataclass).
```contract
FN get_me(client: httpx.AsyncClient) -> dict[str, Any]
BRIEF: GET /me — the authenticated principal's identity + key metadata (spec §GET /me). Boot-time whoami: verify the key without agent-config side effects. Returns parsed JSON verbatim; spec documents {user_id, scopes, tier, display_name?, key_id?, key_label?, ...} with optional fields OMITTED (not null). Read-only, rate-exempt, no audit emission.
PRE: [PRE-001 hard] client is not None -- assert client is not None
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
ERROR_ROUTING:
HTTP non-200 (incl. 401 bad/absent key when auth enabled):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none (caller decides: bad key → re-key; degraded tier="unknown" is still a 200)
STEPS:
1. [setup, prescriptive] assert client is not None
2. [sequential, prescriptive] resp = await client.get("/me")
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy_authenticated [happy,tracer]: 200 {user_id, scopes, tier, key_id} → dict returned verbatim
anonymous_dev_mode: 200 {user_id:"anonymous", tier:"anonymous"} → dict; no key_* fields (omitted)
401_raises [error]: 401 → SessionApiFailed(status=401)
FN get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]
BRIEF: GET /capabilities — server capability discovery (spec §Ephemeral Templates). Returns {ephemeral_templates: {echo: {allowed_models, default_model, system_prompt_max_bytes}}}. Any authenticated caller may read it (no instantiate scope). Parsed dict verbatim; any non-200 → SessionApiFailed.
PRE: [PRE-001 hard] client is not None -- assert client is not None
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
ERROR_ROUTING:
HTTP non-200:
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none
STEPS:
1. [setup, prescriptive] assert client is not None
2. [sequential, prescriptive] resp = await client.get("/capabilities")
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy [happy]: 200 {ephemeral_templates:{echo:{...}}} → dict returned verbatim
non_200_raises [error]: 500 → SessionApiFailed(status=500)
```
## Amendment 2026-07-01 — session tool introspection (v1 coverage-audit)
Owner-scoped tool-inventory read (spec #183, `GET /sessions/{id}/tools`),
surfaced in the TUI Tools pane on session-attach. Same shape as the other
introspection wrappers: GET, 200 → parsed dict verbatim, non-200 →
`SessionApiFailed`. Reachable with the consumer key (no admin scope), unlike the
admin variant `GET /admin/sessions/{id}/tools`.
```contract
FN get_session_tools(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]
BRIEF: GET /sessions/{session_id}/tools — owner-scoped merged tool inventory (spec #183) the LLM saw at turn-fire: {agent_id, builtin_tools: [...], bifrost_tools: [{name, description, parameters}, ...]}. Owner gate (ctx.user_id == session.user_id); cross-owner → 404 session_not_found (existence-hiding), revoked → 401 auth_revoked. Parsed dict verbatim; any non-200 → SessionApiFailed.
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] session_id is non-empty str -- assert session_id and isinstance(session_id, str)
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
ERROR_ROUTING:
HTTP non-200 (incl. 404 session_not_found cross-owner/unknown, 401 auth_revoked):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none
STEPS:
1. [setup, prescriptive] assert PRE-001, PRE-002
2. [sequential, prescriptive] resp = await client.get(f"/sessions/{session_id}/tools")
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy [happy,tracer]: 200 {agent_id, builtin_tools:[], bifrost_tools:[{name,...}]} → dict verbatim
cross_owner_404 [error]: 404 session_not_found → SessionApiFailed(status=404)
empty_session_id [adversarial]: "" → AssertionError; no HTTP issued
```
## Amendment 2026-07-01 — admin BifrostState read (v1 coverage-audit)
Admin-scoped Bifrost dispatch-state read (spec #176, `GET /admin/sessions/{id}/bifrost`),
surfaced in the TUI BifrostState pane on session-attach. The first admin-key
consumer in ratatoskr: requires the `admin.sessions.read` scope, so the request
OVERRIDES the Authorization header with the caller-supplied `admin_key` (distinct
from the client's default consumer key). Same result-shape convention as the
other introspection wrappers: 200 → parsed dict verbatim, non-200 → `SessionApiFailed`.
```contract
FN get_session_bifrost(client: httpx.AsyncClient, session_id: str, *, admin_key: str) -> dict[str, Any]
BRIEF: GET /admin/sessions/{session_id}/bifrost — admin-scoped live Bifrost binding (spec #176): {endpoint_url, consumer_id, connected, capabilities_granted, tools:[{name, description}]}. Requires admin.sessions.read; the request sets Authorization: Bearer <admin_key> (override), NOT the client's default consumer bearer. Parsed dict verbatim; any non-200 → SessionApiFailed — notably 403 auth_scope_denied and 404 session_not_bifrost_bound.
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] session_id is non-empty str -- assert session_id and isinstance(session_id, str)
PRE: [PRE-003 hard] admin_key is non-empty str -- assert admin_key and isinstance(admin_key, str)
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
POST: [POST-002 state_change] the outbound request Authorization header == f"Bearer {admin_key}" (override) -- assert request.headers["Authorization"] == "Bearer " + admin_key
ERROR_ROUTING:
HTTP non-200 (incl. 403 auth_scope_denied, 404 session_not_found / session_not_bifrost_bound):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none (caller decides: 403 → key lacks scope; 404 not-bound → benign unbound session)
STEPS:
1. [setup, prescriptive] assert PRE-001..PRE-003
2. [sequential, prescriptive] resp = await client.get(f"/admin/sessions/{session_id}/bifrost", headers={"Authorization": f"Bearer {admin_key}"})
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy_uses_admin_bearer [happy,tracer]: 200 {endpoint_url, connected, capabilities_granted, tools} → dict verbatim; request Authorization == "Bearer <admin_key>" (override)
scope_denied_403 [error]: 403 → SessionApiFailed(status=403)
not_bound_404 [error]: 404 session_not_bifrost_bound → SessionApiFailed(status=404)
empty_admin_key [adversarial]: admin_key="" → AssertionError; no HTTP issued
```
## Amendment 2026-07-01 — Tier-2: transient characters + persona-state write (v1 coverage-audit)
The last in-scope client I/O points. Transient-character CRUD (#161) surfaced
via a `--characters` one-shot lifecycle probe; persona-state write surfaced via
`--set-persona-pad "p,a,d"` (requires `--session`). All mirror the existing
wrappers: parsed dict verbatim (or None on 204), any off-status → SessionApiFailed.
**Note:** `set_persona_state`'s request body is FREEFORM — the frozen OpenAPI 2.2.0
declares no request schema and the prose spec documents only the GET counterpart,
so the caller supplies the snapshot shape (`--set-persona-pad` sends `{pad:[…]}`).
```contract
FN list_character_models(client) -> dict[str, Any]
BRIEF: GET /models/available-for-characters (character.read). Returns {items:[{name, description, thinking}]}. Non-200 → SessionApiFailed.
PRE: [PRE-001 hard] client is not None
POST: [POST-001 return_value] on 200 returns resp.json() unmodified
STEPS:
1. [sequential, prescriptive] resp = await client.get("/models/available-for-characters"); IF 200 RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
list_models [happy,tracer]: 200 {items:[{name:"fast"}]} → dict verbatim
FN create_character(client, character: dict, *, state: dict | None = None) -> dict[str, Any]
BRIEF: POST /characters (character.write). Body {character, state}. Returns 201 {character_id, ttl_expires_at}; non-201 → SessionApiFailed.
PRE: [PRE-001 hard] client is not None; [PRE-002 hard] character is a non-empty dict
POST: [POST-001 return_value] on 201 returns resp.json(); [POST-002 side_effect] outbound body == {"character": <arg>, "state": <state|null>}
STEPS:
1. [sequential, prescriptive] resp = await client.post("/characters", json={"character": character, "state": state}); IF 201 RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
create [happy]: 201 → {character_id}; body is {character, state:null}
create_403 [error]: 403 auth_scope_denied → SessionApiFailed(403)
FN get_character_state(client, character_id: str) -> dict[str, Any]
BRIEF: GET /characters/{id}/state (character.read). Live PAD/emotions snapshot; refreshes TTL. Non-200 → SessionApiFailed.
PRE: [PRE-001 hard] client not None; [PRE-002 hard] character_id non-empty str
POST: [POST-001 return_value] on 200 returns resp.json()
STEPS:
1. [sequential, prescriptive] resp = await client.get(f"/characters/{character_id}/state"); IF 200 RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
get_state [happy]: 200 {pad:[...]} → dict verbatim
FN delete_character(client, character_id: str) -> None
BRIEF: DELETE /characters/{id} (character.write). 200/204 → None; other → SessionApiFailed.
PRE: [PRE-001 hard] client not None; [PRE-002 hard] character_id non-empty str
POST: [POST-001 return_value] on 200/204 returns None
STEPS:
1. [sequential, prescriptive] resp = await client.delete(f"/characters/{character_id}"); IF status in (200,204) RETURN None; ELSE RAISE SessionApiFailed
TESTS:
delete [happy]: 204 → None
FN set_persona_state(client, session_id: str, snapshot: dict) -> None
BRIEF: POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection). Request body is the FREEFORM snapshot (caller-supplied; unpinned in the frozen surface). 204 → None; other → SessionApiFailed.
PRE: [PRE-001 hard] client not None; [PRE-002 hard] session_id non-empty str; [PRE-003 hard] snapshot is a dict
POST: [POST-001 return_value] on 204 returns None; [POST-002 side_effect] outbound body == snapshot verbatim
STEPS:
1. [sequential, prescriptive] resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot); IF 204 RETURN None; ELSE RAISE SessionApiFailed
TESTS:
happy [happy]: 204 → None; body == {"pad":[...]} verbatim
non_204 [error]: 422 → SessionApiFailed(422)
```
+61
View File
@@ -372,3 +372,64 @@ test layer.
- Issue #7 (mid-stream robustness, `MalformedSseData`) — landed; #6's - Issue #7 (mid-stream robustness, `MalformedSseData`) — landed; #6's
pre/in-alt-screen split is orthogonal to #7's empty-data/malformed pre/in-alt-screen split is orthogonal to #7's empty-data/malformed
distinction (different error layers entirely). distinction (different error layers entirely).
## Amendment 2026-06-30 — startup session picker (v1 coverage-audit, slice b2)
The v1 coverage-audit found `list_sessions` had **no caller** — the startup
session picker (design-brief §4: "single-session-per-launch, with a startup
picker invoked when more than one session exists ... plus flags `--session`/
`--new` to skip it") was never built. Bare TUI mode (neither `--session` nor
`--new`) was a hard usage error. This adds the picker as a pre-alt-screen
resolution step in `_resolve_then_run`, mirroring the existing `AgentPickerApp`.
**Locked design (design-brief §4):** the picker is **resume-only** (§4 negative
clause "no in-app session creation — `--new` flag only"); shown only when **>1**
session exists (exactly 1 auto-resumes; the launch intent is "resume the last
session I was poking at"). `--agent` stays a `--new` companion (forbidden in bare
mode). **bare + 0 sessions → error** `[no_sessions]` directing the operator to
`--new` (honors the "no in-app creation" clause; the friendlier
auto-fall-through-to-new alternative is deferred pending operator confirmation).
### `_parse` validation relaxation (ratatoskr.cli._parse)
- Bare TUI mode (`send is None` AND no `--session` AND no `--new`) is now VALID
→ triggers the picker. (Previously `raise UsageError("pass exactly one of
--session or --new")` unconditionally.)
- `--send` mode still requires exactly one of `--session`/`--new` (non-
interactive: no picker can open) → `UsageError("--send requires --session or
--new")`.
- `--session` + `--new` stays mutually exclusive.
- `--agent` in bare mode → `UsageError` (`--agent` belongs to `--new`).
```contract
FN SessionPickerApp.__init__(self, sessions: list[SessionInfo]) -> None
BRIEF: Textual App[str | None] startup session picker (mirrors AgentPickerApp, issue #8). Opens before RatatoskrApp when bare TUI mode resolves >1 session. `run_async()` returns the chosen session_id (str) or None on Esc/Ctrl-D/Ctrl-C dismissal. Architecturally separate from RatatoskrApp (list_sessions failures + dismissal land before any alt-screen — preserves #6 INV-001).
PRE: [PRE-001 hard] sessions is non-empty -- assert sessions (caller resolves 0-session and 1-session cases BEFORE constructing the picker)
POST: [POST-001 return_value] run_async() returns sessions[i].session_id for the highlighted row on `pick`, or None on dismiss -- assert result in {s.session_id for s in sessions} | {None}
STEPS:
1. [setup, prescriptive] Store sessions; register the Australis theme (mirror AgentPickerApp).
2. [sequential, prescriptive] compose: Header + prompt Static + ListView of one ListItem per session (id-short + agent_id + last_active/name lines) + Footer.
3. [sequential, prescriptive] BINDINGS: enter→action_pick, escape/ctrl+d/ctrl+c→action_dismiss.
4. [branch, prescriptive] action_pick: read ListView.index; if None return (nothing highlighted); else exit(sessions[index].session_id). action_dismiss: exit(None).
TESTS:
pick_returns_session_id [happy,tracer]: SessionPickerApp([s0, s1]); pilot highlights row 1 + press enter → run_async() returns s1.session_id.
dismiss_returns_none [happy]: press escape → run_async() returns None.
ctrl_d_dismisses [adversarial]: press ctrl+d → None.
FN _resolve_then_run(args) — bare-mode extension (session picker)
BRIEF: Before the existing new/resume branches, resolve bare TUI mode (not args.new AND args.session_id is None) via list_sessions + the picker. Sets a local `effective_new` and `resolved_session_id`; the existing branches then run unchanged on those locals.
STEPS (inserted at the top of the `async with client` block):
1. [setup, prescriptive] SET effective_new = args.new; resolved_session_id = args.session_id.
2. [branch, prescriptive] IF (not args.new) AND (args.session_id is None): # bare mode
a. CALL list_sessions(client) → page; ON SessionApiFailed → stderr `[session_api_failed]` + return 20; ON network error → `[network_error]` + return 21.
b. IF not page.items: stderr `[no_sessions] no sessions to resume; launch with --new --agent <id>` + return 14.
c. ELIF len(page.items) == 1: SET resolved_session_id = page.items[0].session_id. # §4: picker only when >1
d. ELSE: SET resolved_session_id = await SessionPickerApp(page.items).run_async(); IF None → return 0 (Esc/Ctrl-D clean exit).
3. [sequential, prescriptive] Replace the two `if args.new` predicates with `if effective_new`; the resume `else` branch asserts + uses `resolved_session_id`.
TESTS (in the `_resolve_then_run` block):
bare_zero_sessions_errors [error]: bare args; list_sessions → 0 items → stderr contains `[no_sessions]`; return 14; NO POST /sessions, NO picker.
bare_one_session_auto_resumes [scenario]: bare args; list_sessions → 1 item (sid="s-solo") → RatatoskrApp constructed with session_id="s-solo"; NO picker shown.
bare_multi_opens_picker [scenario,tracer]: bare args; list_sessions → 2 items; picker returns items[1].session_id → RatatoskrApp constructed with that session_id.
bare_picker_dismiss_exits_zero [scenario]: bare args; 2 items; picker returns None → return 0; RatatoskrApp NOT constructed.
bare_list_sessions_api_failure [error]: bare args; list_sessions raises SessionApiFailed(500) → stderr `[session_api_failed]`; return 20.
```
+47 -32
View File
@@ -48,7 +48,7 @@ resolved (§ Surface 1, scope-resolution table).
| Surface | Points | ✅ covered-live | ⬜ gap (in-scope) | 🚫 excluded-by-design | | Surface | Points | ✅ covered-live | ⬜ gap (in-scope) | 🚫 excluded-by-design |
|---|---|---|---|---| |---|---|---|---|---|
| REST (OpenAPI 2.2.0, path groups) | 40 | 7 | 11 | 22 | | REST (OpenAPI 2.2.0, path groups) | 40 | 17 | 0 | 23 |
| SSE events | 11 | 11 | 0 | 0 | | SSE events | 11 | 11 | 0 | 0 |
| Bifrost provider planes | 8 verbs | 8 | 0 | (10 gated verbs deferred) | | Bifrost provider planes | 8 verbs | 8 | 0 | (10 gated verbs deferred) |
@@ -75,6 +75,16 @@ sub-gap).
| `POST /agents/define` | ✅ | `tier3.py:175``_run_define` | Tier-3 create | | `POST /agents/define` | ✅ | `tier3.py:175``_run_define` | Tier-3 create |
| `PATCH /agents/{id}` | ✅ | `tier3.py:219``_run_patch` | Tier-3 mutate (system_prompt/model) | | `PATCH /agents/{id}` | ✅ | `tier3.py:219``_run_patch` | Tier-3 mutate (system_prompt/model) |
| `DELETE /agents/{id}` | ✅ | `tier3.py:242``_run_delete` | Tier-3 hard-delete | | `DELETE /agents/{id}` | ✅ | `tier3.py:242``_run_delete` | Tier-3 hard-delete |
| `GET /me` | ✅ | `sessions.py:411` `get_me``cli.py` `--whoami` | identity/whoami probe; 401→SessionApiFailed |
| `GET /capabilities` | ✅ | `sessions.py:428` `get_capabilities``cli.py` `--whoami` | Echo ephemeral-template discovery |
| `GET /sessions/{id}/tools` | ✅ | `sessions.py:411` `get_session_tools``tui.py` `_hydrate_session_tools` | owner-scoped tool inventory in the TUI Tools pane (#183) |
| `GET /admin/sessions/{id}/bifrost` | ✅ | `sessions.py:428` `get_session_bifrost``tui.py` `_hydrate_bifrost_state` | admin-scoped BifrostState pane (#176); admin key (`RATATOSKR_ADMIN_API_KEY`); live-auth-proven |
| `GET /admin/events` (SSE) | ✅ | `sse_client.py` `stream_admin_events``tui.py` `_stream_admin_events` | admin lifecycle SSE stream (#11), session-filtered AdminEvents pane; admin key; live-auth-proven |
| `GET /models/available-for-characters` | ✅ | `sessions.py` `list_character_models``cli.py` `--characters` | character-capable model profiles (#161) |
| `POST /characters` | ✅ | `sessions.py` `create_character``cli.py` `--characters` | create transient character (#161) |
| `GET /characters/{id}/state` | ✅ | `sessions.py` `get_character_state``cli.py` `--characters` | live character PAD/emotions (#161) |
| `DELETE /characters/{id}` | ✅ | `sessions.py` `delete_character``cli.py` `--characters` | remove transient character (#161) |
| `POST /sessions/{id}/persona_state` | ✅ | `sessions.py` `set_persona_state``cli.py` `--set-persona-pad` | persona-state write / affect injection (freeform body — unpinned in the frozen surface) |
**Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method **Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method
on the same path is an unwired frontier item — see frontier Tier 1): on the same path is an unwired frontier item — see frontier Tier 1):
@@ -86,26 +96,20 @@ on the same path is an unwired frontier item — see frontier Tier 1):
- `GET /agents/{id}` — consumer-agent lookup (`GET /agents/<owner>:<name>` with - `GET /agents/{id}` — consumer-agent lookup (`GET /agents/<owner>:<name>` with
the owner key) is **manual-curl-only**, not in code. the owner key) is **manual-curl-only**, not in code.
### In-scope gaps — the convergence frontier (debug-observability path) ### In-scope gaps — CONVERGED (zero remaining, 2026-07-01)
**Tier 1 — the debug-observability core (design-brief'd for v1, unbuilt):** **Every in-scope REST I/O point is now covered.** The frontier that opened this
audit (the design-brief §5 observability panes + the presenter-wiring sub-gaps +
the Tier-2 tail) is fully closed:
| Endpoint | Status | Why in-scope | - Session picker + SSE-resume — wired (`v0.18.5``.7`).
|---|---|---| - Persona · Tools · BifrostState · AdminEvents panes — all built + live (`v0.18.x``v0.19.0`).
| `GET /admin/events` | ⬜ | design-brief §5 v1 **AdminEvents pane**; = issue **#11**, **blocked** on `admin.events.read` scope (infra-ops) | - Transient-characters CRUD + persona-state write — consumed via `--characters` /
| `GET /admin/sessions/{id}/bifrost` | ⬜ | design-brief §5 v1 **BifrostState widget** — never built; admin-key-gated | `--set-persona-pad` (`v0.19.1`).
| `GET /admin/sessions/{id}/tools` | ⬜ | design-brief §5 v1 **Tools widget** — never built; admin-key-gated |
| `GET /capabilities` | ⬜ | server capability discovery — a turn flows through what's advertised |
| `GET /me` | ⬜ | whoami / key-identity — "which key am I against" is a debug primitive |
| (`GET /sessions` picker · resume) | ⬜ | sub-gaps above — presenter-wiring only, wrappers exist |
**Tier 2 — rounds out I/O coverage under A (postdates the design-brief):** The only remaining not-consumed in-scope method is `GET /agents/{id}` (consumer-
agent lookup, manual-curl-only) — a sub-method on an already-✅ path group, not a
| Endpoint | Status | Why in-scope | path-group gap. Everything else is covered or excluded-by-design below.
|---|---|---|
| `GET /sessions/{id}/tools` | ⬜ | Tier-3 owner-scoped tool introspection (#183) — reachable with the **consumer key** (no admin scope), the unblocked tool-introspection path |
| `POST /sessions/{id}/persona_state` (write) | ⬜ | affect-injection is debug-relevant; pairs with our provider affect plane |
| `POST /characters` · `DELETE /characters/{id}` · `GET /characters/{id}/state` · `GET /models/available-for-characters` | ⬜ | transient-characters (Echo) is a session-creation **routing path** a debug client should be able to drive a turn through |
### Excluded by design — the design-brief negative clauses ### Excluded by design — the design-brief negative clauses
@@ -115,6 +119,7 @@ on the same path is an unwired frontier item — see frontier Tier 1):
| `GET /sessions/{id}/messages` (history) | 🚫 | §6: single-session live transcript, no history fetch | | `GET /sessions/{id}/messages` (history) | 🚫 | §6: single-session live transcript, no history fetch |
| `GET /sessions/{id}` | 🚫 | session detail — identity is footer-visible, no detail view | | `GET /sessions/{id}` | 🚫 | session detail — identity is footer-visible, no detail view |
| `GET /sessions/{id}/tool-events` | 🚫 | §5: tool calls observed **inline from SSE** `tool_start`/`tool_result`; persisted-events endpoint is opt-in only | | `GET /sessions/{id}/tool-events` | 🚫 | §5: tool calls observed **inline from SSE** `tool_start`/`tool_result`; persisted-events endpoint is opt-in only |
| `GET /admin/sessions/{id}/tools` | 🚫 | **covered-by-alternative** — the owner-scoped `GET /sessions/{id}/tools` (✅) serves the Tools inventory; this admin variant is only for cross-user operator debug, out of the single-session focus (§6) |
| `GET/POST /admin/keys` · `DELETE/POST /admin/keys/{id}` · `POST /admin/keys/{id}/rotate` · `DELETE/POST /admin/keys/bulk` · `POST /admin/keys/bulk/rotate` | 🚫 | §6: **NOT a Worldtree-admin tool** (key mgmt) | | `GET/POST /admin/keys` · `DELETE/POST /admin/keys/{id}` · `POST /admin/keys/{id}/rotate` · `DELETE/POST /admin/keys/bulk` · `POST /admin/keys/bulk/rotate` | 🚫 | §6: **NOT a Worldtree-admin tool** (key mgmt) |
| `POST /admin/sessions/{id}/retire` | 🚫 | admin session mutation | | `POST /admin/sessions/{id}/retire` | 🚫 | admin session mutation |
| `POST /admin/persona/{archive,erase}` | 🚫 | admin persona GDPR ops (new in b2) | | `POST /admin/persona/{archive,erase}` | 🚫 | admin persona GDPR ops (new in b2) |
@@ -199,24 +204,34 @@ starts exercising them.
--- ---
## Convergence frontier (the v1 to-do) ## Convergence frontier (the v1 to-do) — CLOSED 2026-07-01
**Tier 1 — debug-observability core**, in dependency order: **Every in-scope I/O point is covered.** The frontier is empty: REST 17/40 ✅
with **zero in-scope gaps** (the other 23 REST path-groups are excluded-by-design),
SSE 11/11, Bifrost provider planes 8/8. v1 convergence (per scope A: "every
frozen I/O point classified, zero unaccounted") is **met** — ratatoskr cuts v1
when Worldtree tags 1.0. The arc, for the record:
1. **Session picker + SSE-resume** — wrappers exist (`list_sessions`, **Tier 1 — debug-observability core:**
`reconnect_turn`), need presenter wiring only. **Cheapest; unblocked.**
2. **`GET /capabilities` + `GET /me`** — cheap debug primitives. Unblocked.
3. **BifrostState + Tools widgets** (`GET /admin/sessions/{id}/{bifrost,tools}`)
— design-brief'd v1, unbuilt. Admin-key-gated reads.
4. **#11 — AdminEvents pane** — **blocked** on an `admin.events.read` scope grant
(infra-ops). The single externally-blocked item; everything else can ship
without it.
**Tier 2 — rounds out coverage** (lower priority): 1.**DONE** — Session picker (`v0.18.7`) + SSE-resume (`v0.18.5`/`.6`).
2.**DONE**`GET /capabilities` + `GET /me` (`v0.18.8`, `--whoami`).
3.**DONE** — BifrostState pane (`v0.18.10`, `GET /admin/sessions/{id}/bifrost`,
admin-key; live-auth-proven). The Tools half was already covered by the
owner-scoped `GET /sessions/{id}/tools` (item 5).
4.**DONE** — AdminEvents pane (`v0.18.11`, `GET /admin/events` SSE,
session-filtered; admin-key; live-auth-proven). #11's blocker was already
satisfied (admin key carries `admin.events.read`). **Tier 1 complete** — the
admin/debug-observability core (Persona · Tools · BifrostState · AdminEvents)
is fully built.
5. **`GET /sessions/{id}/tools`** — owner-scoped tool introspection; consumer-key **Tier 2 — rounds out coverage (all that remains):**
reachable (no admin scope), so unblocked.
6. **Transient-characters routing** (4 endpoints) + **`POST /sessions/{id}/persona_state`**. 5. **DONE**`GET /sessions/{id}/tools` (`v0.18.9`, owner-scoped tool inventory
in the TUI Tools pane).
6.**DONE** — Transient-characters CRUD (4 endpoints) + `POST /sessions/{id}/persona_state`
(`v0.19.1`, `--characters` + `--set-persona-pad` one-shot probes). The last
in-scope client I/O points.
--- ---
+57 -53
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr # Persistent memory — ratatoskr
_Last updated: 2026-06-30_ _Last updated: 2026-07-01_
This file captures durable intent and supporting evidence (goals, decisions, This file captures durable intent and supporting evidence (goals, decisions,
foot-gun warnings, in-flight state) across context resets. Read it at session 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 ## 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 **THE v1 COVERAGE-AUDIT HAS CONVERGED.** The audit that ran this session (2026-06-30 → 07-01)
`v0.18.0` (`359dbb1`): D2 (PAD read-endpoint, `v0.17.14`) renders live PAD in the web pane from our reached its scope-A done-definition: **every frozen Worldtree v1 I/O point is classified — covered
`:8390` store; D1 (composite endpoint, `v0.17.16` `7f4ceaa`) — `build_combined_provider_app` or excluded-with-rationale, zero unaccounted.** Coverage: **REST 17/40 ✅ with ZERO in-scope gaps**
(`provider/combined.py`) on `:8392` wraps bifrost's public `build_combined_app` over BOTH stores + (23 REST path-groups excluded-by-design), **SSE 11/11 ✅**, **Bifrost provider planes 8/8 ✅**
the shared affect read route; one bound session drives memory.* AND affect.* through ONE endpoint, (live-proven). The living ledger is `docs/coverage-map.md`. **v1 cuts when Worldtree tags 1.0**
op-feed deriving plane per path. Suite **503 green**. **#17 closed in the tracker 2026-06-20** (ratatoskr v1 = full Worldtree I/O coverage; the target is the coverage map, not a feature list).
(shipped `v0.17.8``.13` + the `v0.17.17` op-feed field fix). 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 **The debug-observability core is COMPLETE** (published as the `v0.19.0` milestone): all four
(2026-06-20).** infra-ops added `10.100.10.50:8392` to the personal WT's (`:8081`) observability panes built + live + consuming their real endpoints — **Persona** (`GET /agents/{id}/persona_state`),
`BIFROST_CLIENT_ALLOWED_HOSTS` (thread `01KVHWJGTT…`), unblocking the smoke. A real WT turn through **Tools** (`GET /sessions/{id}/tools`), **BifrostState** (`GET /admin/sessions/{id}/bifrost`),
`:8392` (session `b83a66b6`, agent `ratatoskr:sindra`, fresh end_user `resmoke-choco-1`) drove the **AdminEvents** (`GET /admin/events` SSE, session-filtered). **#11 is closed-by-build** (AdminEvents
FULL both-plane lifecycle on ONE endpoint, caps-routed by path: `handshake` shipped `v0.18.11`; its long-standing "blocked on `admin.events.read`" status was STALE — the admin
(`caps_granted=[memory, affect]`) → `affect.fetch` + `memory.search` (reads) → `affect.emit` key already carries the scope). The whole slice arc: SSE-resume (`v0.18.5/.6`) → session-picker
(`stored:true`, PAD row in `affect_snapshots`) → `memory.upsert_many` (`upserted:1`, chunk (`v0.18.7`) → `--whoami` me/capabilities (`v0.18.8`) → Tools (`v0.18.9`) → BifrostState (`v0.18.10`)
`2df1b79de761b948` in `memory_chunks`). Both writes verified directly in our SQLite. The → AdminEvents (`v0.18.11`) → **v0.19.0 milestone** → Tier-2 characters+persona-write (`v0.19.1`).
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.**
**bifrost repinned 0.8.0 → 0.10.0** (floor, `provider` extra). 0.10.0 made `affect.fetch` **Standing substrate pins:** Worldtree spec **v1.0.0b2** (`5810a26`) — ratatoskr now vendors the
MANDATORY (strong-or-absent: `_supports_affect_plane` requires `affect_supported`+`emit`+`fetch`, FROZEN machine-readable `conversation-api-openapi.json` (2.2.0) + `conversation-api-sse-events.schema.json`,
gating EVERY affect op incl. emit) — so the repin FORCED `affect.fetch` (`v0.17.15`, conformed pinned in `.corviduo-canonicals.toml` + drift-gated by `canonical_drift.py` (the prose markdown is a
to bifrost's reference `InMemoryAffectStore.fetch``{found, snapshot?}`) or our shipped affect `tolerate_drift` reference). **bifrost `==1.0.0` / wire v0.6 STABLE/FROZEN.** Suite **573 green**.
plane would 400. The composite's affect cap depends on it.
**OPERATOR SESSION STATE — `:8390`/`:8391`/`:8765` shells are PRE-#18 code (foot-gun).** web `:8765` **Keys (env-only, mode 600; rotate via infra-ops):** consumer/Heimdall key at
+ affect `:8390` + memory `:8391` are prior-session background shells on OLD code. The **`:8392` `~/.config/ratatoskr/provider.env`; **admin key `RATATOSKR_ADMIN_API_KEY`** (Heimdall user
composite provider is RUNNING on NEW code** (`ratatoskr-combined-provider`, pid started Jun19, `ratatoskr-readonly`, tier `readonly-admin`, **7 read scopes** incl. `admin.sessions.read` +
`RATATOSKR_OPFEED_PATH=/tmp/ratatoskr-combined-opfeed.jsonl`, shared `affect.db`/`memory.db`)now `admin.events.read` — verified 2026-07-01, **personal `:8081` only**) in `env.sh`powers the
`:8392`-allowlisted and WT-turn-proven. To see the full web stack on new code, RESTART `:8390`/`:8765` BifrostState + AdminEvents admin panes. Heimdall keys are PER-INSTANCE (a personal-minted key 401s
from current code (D2 web needs `RATATOSKR_AFFECT_READ_URL`). Consumer/owner key = `wt_live_d81b…` on demo).
(`~/.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).
**Tier-3 memory PROVEN end-to-end** (earlier this session): `ratatoskr:terse-probe` **Provider identity (the second, still-live role):** ratatoskr owns BOTH ends of the Bifrost
cold-recalled a seeded user fact (scope_any → 1 hit @ cosine 0.6994), and the verbose round-trip — the combined `:8392` provider fronts the memory (`:8391`) + affect (`:8390`) stores
`sindra-probe` too under #296 Stage 2 (v0.36.0). The #296 extraction-quality arc closed (SQLite + sqlite-vec, `memory.db`/`affect.db` at repo root); consumer/owner key `wt_live_d81b…`.
(Stage 1 v0.35.19 gate + Stage 2 v0.36.0 user-only extraction at worldtree-codex; hard- `ratatoskr:sindra` is the owner-scoped Tier-3 agent (invisible to `GET /agents`; check via
linguistic layer → Worldtree #305). `:8081` runs v0.36.0. `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` **Open / deferred (nothing blocking):** #10 (subject-migration watch on Worldtree #196). Design
(DELETE+redefined on v0.35.16; `memory:{}` block trips the promotion gate). Owner-scoped note: bare-TUI + 0-sessions → error (§4-clause-consistent; the friendlier auto-fall-to-new is
(separate `consumer_agents` table) — invisible to `GET /agents`; check via deferred, operator-preference). heid-code-review was run on the b1 resume slice only (panel: zero
`GET /agents/<owner>:<name>` with the owner key. 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** Branch: `main`**in sync with `origin/main`** at **`v0.19.1`** (`af07a23`); the whole session's arc
(`scope_all`+`scope_any`). Heimdall key env-only at `~/.config/ratatoskr/provider.env` (mode is pushed. Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
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`.
## Recent decisions ## Recent decisions
@@ -149,7 +139,21 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-06-30]` **Finding P-1 (pin drift) + pin-remediation PENDING.** We vendor the PROSE markdown (`docs/conversation-api-spec.md`), which is **byte-identical to live WT's** but frozen at v0.35.16-era content (last WT edit 2026-05-31) — it does NOT capture b2: 7 new endpoints (admin/keys/bulk, admin/persona/{archive,erase}, admin/usage, embed, judgments, me/usage), the 409/503 on messages-POST (#331), the unified error envelope (#328), or the SSE schema. **WT's authoritative v1 truth is now the FROZEN OpenAPI 2.2.0 + SSE-schema JSON** (`Worldtree/docs/v1-schema-freeze-manifest.md`). So the previously-deferred "re-vendor markdown to b2" is a **near-no-op** (markdown content identical). **Pending operator nod:** re-pin to the machine-readable artifacts (recommended — drift-checkable via `canonical_drift.py`, makes the coverage map reproducible vs a frozen diffable target) vs markdown-only. Deferred (not auto-applied) because it adds vendored artifacts + a canonical-sync pin = substrate change with CI-gating reach. **→ RESOLVED 2026-06-30 (operator: "a then b").** Vendored `conversation-api-openapi.json` (2.2.0) + `conversation-api-sse-events.schema.json` + re-copied the prose markdown; pinned all three in `.corviduo-canonicals.toml` (OpenAPI+SSE = strict drift gates, markdown = `tolerate_drift` reference); advanced `worldtree-spec-rev` f1b59f8→5810a26 + `worldtree-version` v0.29.0(STALE, never bumped from the v0.35.16 pin)→v1.0.0b2 + `pinned-on`→2026-06-30; SPEC-PIN.md history row added. `canonical_drift.py` green (10/10). `pin:`-only, no version bump (no client-facing code change; the b2 409/503 + error-envelope were already consumed in v0.18.3/.4). - `[2026-06-30]` **Finding P-1 (pin drift) + pin-remediation PENDING.** We vendor the PROSE markdown (`docs/conversation-api-spec.md`), which is **byte-identical to live WT's** but frozen at v0.35.16-era content (last WT edit 2026-05-31) — it does NOT capture b2: 7 new endpoints (admin/keys/bulk, admin/persona/{archive,erase}, admin/usage, embed, judgments, me/usage), the 409/503 on messages-POST (#331), the unified error envelope (#328), or the SSE schema. **WT's authoritative v1 truth is now the FROZEN OpenAPI 2.2.0 + SSE-schema JSON** (`Worldtree/docs/v1-schema-freeze-manifest.md`). So the previously-deferred "re-vendor markdown to b2" is a **near-no-op** (markdown content identical). **Pending operator nod:** re-pin to the machine-readable artifacts (recommended — drift-checkable via `canonical_drift.py`, makes the coverage map reproducible vs a frozen diffable target) vs markdown-only. Deferred (not auto-applied) because it adds vendored artifacts + a canonical-sync pin = substrate change with CI-gating reach. **→ RESOLVED 2026-06-30 (operator: "a then b").** Vendored `conversation-api-openapi.json` (2.2.0) + `conversation-api-sse-events.schema.json` + re-copied the prose markdown; pinned all three in `.corviduo-canonicals.toml` (OpenAPI+SSE = strict drift gates, markdown = `tolerate_drift` reference); advanced `worldtree-spec-rev` f1b59f8→5810a26 + `worldtree-version` v0.29.0(STALE, never bumped from the v0.35.16 pin)→v1.0.0b2 + `pinned-on`→2026-06-30; SPEC-PIN.md history row added. `canonical_drift.py` green (10/10). `pin:`-only, no version bump (no client-facing code change; the b2 409/503 + error-envelope were already consumed in v0.18.3/.4).
- `[2026-06-30]` **(b) Tier-1 frontier SCOPED, ready for a contract-first TDD cycle (next focused work).** The primitives already exist + are contracted + tested; the gap is PRESENTER-level wiring. Two slices: **(b1) SSE-resume** — contract #1 (`ratatoskr.sse_client`) DELIBERATELY makes resume caller-owned ("on `SseConnectionDropped`, the caller MAY invoke `reconnect_turn`"); `reconnect_turn` (sse_client.py:524) has NO caller. Gap = a SHARED resume-orchestration wrapper (catch `SseConnectionDropped` → track last-seen `sse_id``reconnect_turn` → continue), consumed by all 3 presenters per design-brief §8b "share the consumer, branch the presenter" (NOT per-presenter — that forks the consumer). New function block → **amend contract #1** (additive FN, e.g. `stream_turn_resilient`) then TDD (RED: drop-mid-stream→resume continuity; GREEN: wrapper; wire `cli --send` first as the tracer). Resume design pre-locked: in-process Last-Event-ID only, cross-process deferred to v2 (design-brief §8d). **(b2) session-picker** — `list_sessions` (sessions.py:198) has NO caller; add a Textual DataTable startup picker (>1 session) + `--session <id>`/`--new` CLI flags (design-brief §4, decisions pre-locked). Both pre-locked → heid-contract-review likely skippable as ceremony (small additive amendments to mature specs); heid-code-review still valuable. **#11 AdminEvents stays BLOCKED** on `admin.events.read` scope (infra-ops). - `[2026-06-30]` **(b) Tier-1 frontier SCOPED, ready for a contract-first TDD cycle (next focused work).** The primitives already exist + are contracted + tested; the gap is PRESENTER-level wiring. Two slices: **(b1) SSE-resume** — contract #1 (`ratatoskr.sse_client`) DELIBERATELY makes resume caller-owned ("on `SseConnectionDropped`, the caller MAY invoke `reconnect_turn`"); `reconnect_turn` (sse_client.py:524) has NO caller. Gap = a SHARED resume-orchestration wrapper (catch `SseConnectionDropped` → track last-seen `sse_id``reconnect_turn` → continue), consumed by all 3 presenters per design-brief §8b "share the consumer, branch the presenter" (NOT per-presenter — that forks the consumer). New function block → **amend contract #1** (additive FN, e.g. `stream_turn_resilient`) then TDD (RED: drop-mid-stream→resume continuity; GREEN: wrapper; wire `cli --send` first as the tracer). Resume design pre-locked: in-process Last-Event-ID only, cross-process deferred to v2 (design-brief §8d). **(b2) session-picker** — `list_sessions` (sessions.py:198) has NO caller; add a Textual DataTable startup picker (>1 session) + `--session <id>`/`--new` CLI flags (design-brief §4, decisions pre-locked). Both pre-locked → heid-contract-review likely skippable as ceremony (small additive amendments to mature specs); heid-code-review still valuable. **#11 AdminEvents stays BLOCKED** on `admin.events.read` scope (infra-ops).
- `[2026-06-30]` **(b1) SSE-resume SHIPPED (`v0.18.5`) — `stream_turn_resilient` (sse_client.py).** The shared resume-orchestration surface (design-brief §8b): wraps `stream_turn`+`reconnect_turn`, catches `SseConnectionDropped` (mid-stream drop OR clean-EOF-before-terminal) → resumes from last-seen `sse_id` via `reconnect_turn` (Last-Event-ID), up to `max_reconnects` (default 5); non-drop reconnect failures (412/410/400/TurnIdFlip/SseConnectFailed) PROPAGATE per contract #1's "surface, not recover". `last_seen` persists ACROSS attempts (a zero-event reconnect drop falls back to the prior attempt's id). Direct in-session TDD against a contract-#1 amendment (8 cases incl. two-drops, max-reconnects-exhausted, zero-budget, buffer-expired-propagates, unresumable-zero-event). Wired `cli --send` (`cli.py:396`, swapped `stream_turn``stream_turn_resilient`; **tui/web still on bare `stream_turn` — follow-up to route them through the wrapper**). Suite 518 green; ruff+mypy clean on touched code (pre-existing cli.py:400/543 mypy warts left untouched per surgical rule); contract #1 validates OK. **heid-code-review NOT run** (small additive well-TDD'd wrapper; offered to operator). **b2 (session-picker + `--session`/`--new` flags) still pending.** - `[2026-06-30]` **(b1) SSE-resume SHIPPED (`v0.18.5`) — `stream_turn_resilient` (sse_client.py).** The shared resume-orchestration surface (design-brief §8b): wraps `stream_turn`+`reconnect_turn`, catches `SseConnectionDropped` (mid-stream drop OR clean-EOF-before-terminal) → resumes from last-seen `sse_id` via `reconnect_turn` (Last-Event-ID), up to `max_reconnects` (default 5); non-drop reconnect failures (412/410/400/TurnIdFlip/SseConnectFailed) PROPAGATE per contract #1's "surface, not recover". `last_seen` persists ACROSS attempts (a zero-event reconnect drop falls back to the prior attempt's id). Direct in-session TDD against a contract-#1 amendment (8 cases incl. two-drops, max-reconnects-exhausted, zero-budget, buffer-expired-propagates, unresumable-zero-event). Wired ALL THREE presenters through it (`v0.18.6`): `cli --send` (`cli.py:396`), TUI (`tui.py:1321`), web (`web/server.py:294`) — each a name-for-name `stream_turn``stream_turn_resilient` swap (the §8b "all presenters share the consumer" promise, fully kept; the TUI is the primary resume beneficiary — long-lived sessions / laptop-suspend). Suite 518 green; ruff+mypy clean on touched code (pre-existing cli.py:400/543 mypy warts left untouched per surgical rule); contract #1 validates OK. **heid-code-review NOT run** (small additive well-TDD'd wrapper; offered to operator). **b2 (session-picker + `--session`/`--new` flags) still pending.**
- `[2026-06-30]` **(b2) session-picker SHIPPED (`v0.18.7`) — bare TUI mode → startup picker (design-brief §4).** `list_sessions` had NO caller; now bare TUI mode (no `--session`/`--new`) resolves via `list_sessions` in `_resolve_then_run`: **0 sessions → `[no_sessions]` error, exit 14** (resume-only, honors §4 "no in-app session creation — `--new` flag only"); **exactly 1 → auto-resume** (§4 "picker only when >1"); **≥2 → new `SessionPickerApp`** (Textual `App[str|None]`, mirrors `AgentPickerApp`; ListView of sessions) → resume the pick (Esc/Ctrl-D → exit 0). cli `_parse` relaxed: bare TUI now VALID (was "pass exactly one" error); `--send` still requires one flag (non-interactive, no picker); `--agent` forbidden in bare mode; `run_tui` PRE-002 XOR→"not both". Direct in-session TDD (contract #6 amendment, validated OK): 3 widget pilot tests + 5 `_resolve_then_run` resolution tests + 3 cli validation tests. Suite **528 green**; touched code ruff-clean (mypy: only the `BINDINGS` list-invariance warning every App in tui.py already carries — consistent). **DESIGN NOTE — bare+0-sessions → error (clause-consistent). The friendlier auto-fall-through-to-new alternative is DEFERRED pending operator preference (it would create a session without `--new`, against the §4 negative clause).** **Frontier now: `GET /capabilities`+`GET /me` → BifrostState/Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key) → #11 AdminEvents (BLOCKED on `admin.events.read`).** heid-code-review NOT run on b1 or b2 (offered).
- `[2026-06-30]` **capabilities+me slice SHIPPED (`v0.18.8`) — `GET /me` + `GET /capabilities` consumed via a new `--whoami` one-shot.** `get_me`/`get_capabilities` added to sessions.py (mirror `get_persona_state`: 200→dict verbatim, non-200→`SessionApiFailed`; freeform dicts per the frozen OpenAPI). New `ratatoskr --whoami` CLI mode (mirrors `--send`'s non-interactive shape) fetches both + prints an identity+capabilities report; standalone probe (mutually exclusive with `--send`/`--session`/`--new`/`--agent`, opens no session; new `ParsedArgs.whoami` field + main() dispatch). **`/capabilities` is the Echo EPHEMERAL-TEMPLATE discovery endpoint** (`{ephemeral_templates:{echo:{allowed_models,default_model,system_prompt_max_bytes}}}`), NOT a generic server-caps endpoint (audit finding — the coverage-map's earlier "server capability discovery" framing was imprecise). `/me` = whoami (`{user_id,scopes,tier,key_id?,...}`, optionals omitted-not-null). Contract-skip privilege invoked (low-effort GET wrappers) but contract #2 amended (2 FNs, validated OK) to keep the sessions spec canonical + honest test citations. TDD: 5 wrapper tests + 5 cli tests (validation + mode + error). Suite **538 green**; touched code ruff-clean (mypy: only `no-any-return` on `resp.json()`→dict, identical to the pre-existing `get_persona_state`). **Coverage: REST 9/40 ✅ (up from 7).** TUI-surfacing of /me (footer identity line) + /capabilities DEFERRED — the one-shot is the minimal tracer. **Frontier now: BifrostState + Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key-gated) → #11 AdminEvents (BLOCKED on `admin.events.read`).**
- `[2026-07-01]` **b1 (SSE-resume) heid-code-review panel: ZERO findings — cross-model-verified clean.** Gróa (Grok) + Hulda (Codex) + Regin (GLM-5.2) each independently reviewed `stream_turn_resilient` vs contract #1's amendment (artifact-only, firewall held) → all three ZERO findings; signature / PRE-001..004 / STEP 1-4 / POST-001..003 / ERROR_ROUTING / all-8-TESTS confirmed, incl. the subtle `seen = last_seen or drop.last_seen_sse_id` zero-event-drop fallback. Convergent meta-note: **TDD + the unusually-prescriptive contract (STEPS `flexibility=prescriptive` + explicit GOTO) left no room for compliant-but-different drift — confirmation, not discovery.** Calibration signal: for a thin wrapper with a tight prescriptive contract + comprehensive TDD, the panel confirms rather than discovers. **b2 (picker) + capabilities+me NOT yet reviewed** (higher-surface b2 is the better candidate if more review is wanted). Dispatch msg `01KWE2K99T…` / thread `01KWE2K99S…`; heid dispatch-log `2026-06.jsonl#01KWE2V3MMY8XS55FCJYXYV14B`.
- `[2026-07-01]` **`GET /sessions/{id}/tools` quick-win SHIPPED (`v0.18.9`) — owner-scoped tool inventory in the TUI Tools pane.** `get_session_tools` wrapper (sessions.py, mirror get_me: 200→dict, non-200→`SessionApiFailed`) + `_format_tool_inventory` helper + `_hydrate_session_tools` best-effort worker (mirror `_hydrate_persona`) wired UNCONDITIONALLY in `on_mount` → writes the merged `{agent_id, builtin_tools, bifrost_tools}` inventory (what the LLM saw at turn-fire) to the Tools pane + audits `session_tools_hydrated`, never crashes on failure. Owner-scoped (`ctx.user_id==session.user_id`) → reachable with the CONSUMER key, NO admin scope — so this **covers the design-brief §5 "Tools widget" via the reachable owner endpoint** (the admin `/admin/sessions/{id}/tools` variant stays a gap only for cross-user operator debug). Contract #2 amended (FN, validated OK) + TDD (3 wrapper respx tests + 1 format-helper unit + 2 hydrate integration tests via `_spy_writes`+pilot). Suite **544 green**; touched code ruff-clean (the tui.py ruff/mypy debt at other lines is pre-existing). **Coverage: REST 10/40 ✅.** **Frontier now: BifrostState widget (`GET /admin/sessions/{id}/bifrost`, admin-key) + #11 AdminEvents (BLOCKED on `admin.events.read`) + Tier-2 (transient-characters routing, `POST /sessions/{id}/persona_state`).**
- `[2026-07-01]` **BifrostState pane SHIPPED (`v0.18.10`) — `GET /admin/sessions/{id}/bifrost` in a new TUI "Bifrost" pane; the FIRST admin-key consumer in ratatoskr.** `get_session_bifrost(client, session_id, *, admin_key)` (sessions.py) — admin-scoped (`admin.sessions.read`); the request OVERRIDES Authorization with `admin_key` (distinct from the consumer bearer, asserted in a test); 200→dict, non-200→SessionApiFailed. Admin-key wiring: `--admin-key` flag + `RATATOSKR_ADMIN_API_KEY` env → new `ParsedArgs.admin_key`. New "Bifrost" TabPane + `_format_bifrost_state` + `_hydrate_bifrost_state` best-effort worker (mirror `_hydrate_session_tools`) UNCONDITIONALLY in on_mount → writes {endpoint, connected, caps_granted, tools} + audits; self-labels "not configured" (no admin key) / "not bound" (404) / graceful on 403 + error. Contract #2 amended (FN, validated OK) + TDD (4 wrapper respx tests incl. the admin-bearer-override assertion + 1 format unit + 3 hydrate integration). Suite **552 green**; my code ruff-clean (pre-existing tui.py ruff debt at other lines untouched, incl. a dead `RichText` import in `_hydrate_persona`). **LIVE-AUTH-PROVEN** on personal :8081: admin key authenticated (reached resource-layer 404 session_not_found, NOT 401/403) → `admin.sessions.read` works live; 200 full-state not exercised (no bound session on :8081 now — unit-covered). Patch bump (debug feature, no downstream coordination; consistent with the session's cadence — but the §5-core-completion angle is a possible minor, operator's call).
- `[2026-07-01]` **LEDGER CORRECTION: #11 (AdminEvents) is NO LONGER BLOCKED.** Verified via `GET /me` on :8081 that `RATATOSKR_ADMIN_API_KEY` (`ratatoskr-readonly`, tier readonly-admin) carries ALL 7 read scopes INCLUDING **`admin.events.read`** (+ `admin.sessions.read`, admin.keys.read, admin.skuld.read, pending.read, search.read, tool_events.read). The coverage-map + prior memory had #11 "blocked on admin.events.read" — **STALE**; the admin key was minted (post-#11-filing, env.sh) WITH the scope, so the blocker is already satisfied. **Only the AdminEvents SSE pane itself is unbuilt** — the last unbuilt §5 debug pane (a live SSE-consuming admin pane, distinct from the hydrate-at-attach panes). Coverage-map updated. **Coverage: REST 11/40 ✅.** Consider building the AdminEvents pane and/or updating #11's tracker status (its stated blocker is gone).
- `[2026-07-01]` **AdminEvents pane SHIPPED (`v0.18.11`) — `GET /admin/events` SSE in a new TUI pane; #11 closed-by-build; Tier 1 (debug-observability core) COMPLETE.** `stream_admin_events(client, *, admin_key, last_event_id=None)` (sse_client.py) — a NEW long-lived SSE consumer for the admin lifecycle broadcast (envelope `{id,type,timestamp,data}`, 17-event v0 vocab), admin-scoped (`admin.events.read`, bearer-override), Last-Event-ID resume; non-200→SseConnectFailed, mid-drop→SseConnectionDropped; new `AdminEvent` dataclass (distinct from the turn `Event` union). New "AdminEvents" TabPane + `_format_admin_event` + `_admin_event_matches` (design-brief §6 filter: active-session events + non-heartbeat `system.*`) + `_stream_admin_events` long-lived best-effort worker (unconditional on_mount, cancelled on app exit; self-labels "not configured"/"unavailable"/"stream ended"). Reuses the admin key from the BifrostState slice. **Contract-SKIPPED** for `stream_admin_events` (out of contract #1's turn-SSE scope; spec § Admin Event Stream is the reference; well-TDD'd). TDD: 4 sse_client tests (multi-event+bearer-override, Last-Event-ID header, 403, malformed-skip) + 5 tui (format, filter, worker success/no-key/403). Suite **561 green**; my code ruff-clean (pre-existing tui.py ruff debt untouched, incl. the dead `RichText` import in `_hydrate_persona`). **LIVE-AUTH-PROVEN**: `GET /admin/events` on :8081 → HTTP 200 under the admin key (connected + streamed, idle in the 4s window — no 401/403). **Coverage: REST 12/40 ✅. Tier 1 admin/debug-observability core COMPLETE** (Persona · Tools · BifrostState · AdminEvents). AdminEvents work landed as patch `v0.18.11`; then **`v0.19.0` MINOR cut (operator-approved 2026-07-01)** publishing the milestone: **the debug-observability core is complete** (Persona · Tools · BifrostState · AdminEvents all built + consuming real endpoints — the design-brief's headline deliverable). Pre-1.0 minor = release-note-worthy (no downstream althing push needed pre-1.0); lightweight tag per the SemVer mechanics (annotated reserved for major cuts). Remaining in-scope client I/O: only Tier-2 (transient-characters routing + `POST /sessions/{id}/persona_state`).
- `[2026-07-01]` **Tier-2 SHIPPED (`v0.19.1`) — transient-characters CRUD + persona-state write; the v1 coverage-audit CONVERGES (zero in-scope gaps).** 5 wrappers in sessions.py: `list_character_models`/`create_character`/`get_character_state`/`delete_character` (#161, `character.read`/`.write` scopes) + `set_persona_state` (`POST /sessions/{id}/persona_state`**FREEFORM body: unpinned in the frozen OpenAPI 2.2.0 + absent from the prose spec**, so the caller supplies the snapshot shape). Two one-shot CLI probes (mirror `--whoami`): `--characters` (models→create→get-state→delete lifecycle report) + `--set-persona-pad "p,a,d"` (requires `--session`; POSTs `{pad:[…]}`). New `ParsedArgs.characters`/`set_persona_pad` + probe-mode mutual-exclusion validation + `_probe_client` helper. Contract #2 amended (5 FNs, validated OK) + TDD (7 wrapper respx + 5 cli tests). Suite **573 green**; touched code ruff-clean. NOT live-proven (character scopes + the persona-write body shape unverified — the probes degrade gracefully on 403/422). **THE v1 COVERAGE-AUDIT HAS CONVERGED: REST 17/40 ✅ with ZERO in-scope gaps** (23 REST path-groups excluded-by-design + rationale), SSE 11/11, Bifrost provider planes 8/8. Scope-A "done" (every frozen I/O point classified, zero unaccounted) is **MET** — ratatoskr cuts v1 when Worldtree tags 1.0. Only not-consumed in-scope sub-method: `GET /agents/{id}` (consumer-agent lookup, manual-curl-only, on an already-✅ path group). Patch bump (Tier-2 tail; `v0.19.0` already published the core-complete milestone — a 2nd minor would be cadence-too-fast).
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._ _41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ratatoskr" name = "ratatoskr"
version = "0.18.5" version = "0.19.2"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+191 -3
View File
@@ -12,7 +12,7 @@ import signal
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from importlib.metadata import PackageNotFoundError, version from importlib.metadata import PackageNotFoundError, version
from typing import TextIO from typing import Any, TextIO
import httpx import httpx
@@ -22,8 +22,15 @@ from ratatoskr.sessions import (
BifrostConsumerKeyMissing, BifrostConsumerKeyMissing,
BifrostHandshakeFailed, BifrostHandshakeFailed,
SessionApiFailed, SessionApiFailed,
create_character,
create_session, create_session,
delete_character,
endpoint_for_plane, endpoint_for_plane,
get_capabilities,
get_character_state,
get_me,
list_character_models,
set_persona_state,
) )
from ratatoskr.sse_client import ( from ratatoskr.sse_client import (
AffectUpdate, AffectUpdate,
@@ -97,6 +104,18 @@ class ParsedArgs:
bifrost: BifrostBinding | None = None bifrost: BifrostBinding | None = None
bifrost_plane: str | None = None bifrost_plane: str | None = None
consumer_key: str | None = None consumer_key: str | None = None
# Standalone boot-time orientation probe: GET /me + GET /capabilities, print,
# exit. Mutually exclusive with the session/turn flags (opens no session).
whoami: bool = False
# Optional admin-tier key (RATATOSKR_ADMIN_API_KEY / --admin-key) for the
# admin-scoped inspection reads (BifrostState pane, GET /admin/sessions/…).
# None when unset — the BifrostState pane then shows "admin key not configured".
admin_key: str | None = None
# Tier-2 one-shot probes (like --whoami). --characters runs the transient-
# character CRUD lifecycle; --set-persona-pad "p,a,d" (with --session) writes
# a session's persona state (affect injection).
characters: bool = False
set_persona_pad: str | None = None
class _ArgparseError(Exception): class _ArgparseError(Exception):
@@ -121,6 +140,10 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
parser.add_argument("--api-key", dest="api_key") parser.add_argument("--api-key", dest="api_key")
parser.add_argument("--server") parser.add_argument("--server")
parser.add_argument("--raw", action="store_true") parser.add_argument("--raw", action="store_true")
parser.add_argument("--whoami", action="store_true")
parser.add_argument("--admin-key", dest="admin_key")
parser.add_argument("--characters", action="store_true")
parser.add_argument("--set-persona-pad", dest="set_persona_pad", default=None)
# Issue #5: required for per-end-user agents (lofn etc.); optional otherwise (mimir). # Issue #5: required for per-end-user agents (lofn etc.); optional otherwise (mimir).
parser.add_argument("--end-user-id", dest="end_user_id", default=None) parser.add_argument("--end-user-id", dest="end_user_id", default=None)
# Issue #17: bind the created session to our own Bifrost provider plane. # Issue #17: bind the created session to our own Bifrost provider plane.
@@ -141,10 +164,36 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
# Issue #5 INV-001: --end-user-id, if passed, MUST be non-empty (mirrors --send). # Issue #5 INV-001: --end-user-id, if passed, MUST be non-empty (mirrors --send).
if ns.end_user_id is not None and not ns.end_user_id: if ns.end_user_id is not None and not ns.end_user_id:
raise UsageError("--end-user-id must be non-empty when passed") raise UsageError("--end-user-id must be non-empty when passed")
if sum([ns.whoami, ns.characters, bool(ns.set_persona_pad)]) > 1:
raise UsageError("--whoami / --characters / --set-persona-pad are mutually exclusive")
if ns.whoami or ns.characters:
# Standalone one-shot probes: open no session.
if ns.send is not None or ns.session or ns.new or ns.agent:
raise UsageError(
"--whoami / --characters are standalone probes "
"(no --send/--session/--new/--agent)"
)
elif ns.set_persona_pad is not None:
# Session-scoped write probe: needs a target session, nothing else.
if not ns.set_persona_pad:
raise UsageError("--set-persona-pad must be non-empty (e.g. '0.4,0.1,-0.2')")
if not ns.session:
raise UsageError("--set-persona-pad requires --session <id>")
if ns.send is not None or ns.new or ns.agent:
raise UsageError("--set-persona-pad takes only --session")
else:
if ns.session and ns.new: if ns.session and ns.new:
raise UsageError("--session and --new are mutually exclusive; pass exactly one") raise UsageError("--session and --new are mutually exclusive")
if not ns.session and not ns.new: if not ns.session and not ns.new:
raise UsageError("pass exactly one of --session or --new") # Bare TUI mode → startup session picker (design-brief §4). --send is
# non-interactive (no picker can open), so it still requires one flag;
# --agent belongs with --new (bare mode resumes, it doesn't create).
if ns.send is not None:
raise UsageError("--send requires --session or --new (no interactive picker)")
if ns.agent:
raise UsageError(
"--agent belongs with --new; bare TUI mode opens the session picker"
)
if ns.session and ns.agent: if ns.session and ns.agent:
raise UsageError("--agent is required with --new and forbidden with --session") raise UsageError("--agent is required with --new and forbidden with --session")
if ns.new and not ns.agent and ns.send is not None: if ns.new and not ns.agent and ns.send is not None:
@@ -187,6 +236,9 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
bifrost = BifrostBinding(endpoint_url=endpoint_for_plane(ns.bifrost_plane, host)) bifrost = BifrostBinding(endpoint_url=endpoint_for_plane(ns.bifrost_plane, host))
bifrost_plane = ns.bifrost_plane bifrost_plane = ns.bifrost_plane
consumer_key = os.environ.get("RATATOSKR_BIFROST_CONSUMER_KEY") or None consumer_key = os.environ.get("RATATOSKR_BIFROST_CONSUMER_KEY") or None
# Admin-tier key for the admin-scoped inspection reads (BifrostState pane).
# Flag > env > None; None leaves the admin panes showing "not configured".
admin_key = ns.admin_key or os.environ.get("RATATOSKR_ADMIN_API_KEY") or None
return ParsedArgs( return ParsedArgs(
send_content=ns.send, send_content=ns.send,
@@ -200,6 +252,10 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
bifrost=bifrost, bifrost=bifrost,
bifrost_plane=bifrost_plane, bifrost_plane=bifrost_plane,
consumer_key=consumer_key, consumer_key=consumer_key,
whoami=ns.whoami,
admin_key=admin_key,
characters=ns.characters,
set_persona_pad=ns.set_persona_pad,
) )
@@ -550,6 +606,132 @@ async def _amain(args: ParsedArgs) -> int:
loop.remove_signal_handler(signal.SIGINT) loop.remove_signal_handler(signal.SIGINT)
def _format_whoami(me: dict[str, Any], caps: dict[str, Any]) -> str:
"""Render the --whoami report: identity (GET /me) + server capabilities."""
lines = ["identity:"]
lines.append(f" user_id: {me.get('user_id', '?')}")
lines.append(f" tier: {me.get('tier', '?')}")
lines.append(f" scopes: {', '.join(me.get('scopes', [])) or '(none)'}")
for k in ("display_name", "key_id", "key_label"):
if k in me:
lines.append(f" {k}: {me[k]}")
lines.append("capabilities:")
templates = caps.get("ephemeral_templates", {})
if templates:
for name, spec in templates.items():
models = ", ".join(spec.get("allowed_models", []))
lines.append(
f" ephemeral_template {name}: default={spec.get('default_model', '?')} "
f"max_bytes={spec.get('system_prompt_max_bytes', '?')} models=[{models}]"
)
else:
lines.append(" (no ephemeral templates advertised)")
return "\n".join(lines) + "\n"
async def _whoami(args: ParsedArgs) -> int:
"""--whoami one-shot: GET /me + GET /capabilities, print a compact report, exit.
A boot-time orientation probe (mirrors --send's non-interactive shape):
"who am I against this server, and what does it offer." Opens no session.
Errors land on stderr with the same [session_api_failed] / [network_error]
vocab + exit codes as the other modes.
"""
assert isinstance(args, ParsedArgs)
async with httpx.AsyncClient(
base_url=args.server_url,
headers={"Authorization": f"Bearer {args.api_key}", "User-Agent": USER_AGENT},
timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=10.0),
) as client:
try:
me = await get_me(client)
caps = await get_capabilities(client)
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
sys.stdout.write(_format_whoami(me, caps))
return 0
def _probe_client(args: ParsedArgs) -> httpx.AsyncClient:
"""AsyncClient for the one-shot probes (--whoami / --characters / --set-persona-pad)."""
return httpx.AsyncClient(
base_url=args.server_url,
headers={"Authorization": f"Bearer {args.api_key}", "User-Agent": USER_AGENT},
timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=10.0),
)
async def _characters_probe(args: ParsedArgs) -> int:
"""--characters one-shot: exercise the transient-character CRUD lifecycle
(models → create → get-state → delete), print a report, exit. A reference-
consumer smoke of the #161 character surface (needs character.read/write)."""
assert isinstance(args, ParsedArgs)
async with _probe_client(args) as client:
try:
models = await list_character_models(client)
names = ", ".join(m.get("name", "?") for m in models.get("items", []))
sys.stdout.write(f"character models: {names or '(none)'}\n")
created = await create_character(
client,
{
"schema_version": "1",
"name": "ratatoskr-probe",
"ocean": {
"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.0,
"agreeableness": 0.5, "neuroticism": 0.5,
},
"description": "ratatoskr --characters lifecycle probe",
"narrative": "A throwaway probe character.",
"voice_profile_block": "plain",
},
)
cid = created["character_id"]
sys.stdout.write(f"created: {cid} (ttl {created.get('ttl_expires_at')})\n")
state = await get_character_state(client, cid)
sys.stdout.write(f"state: pad={state.get('pad')}\n")
await delete_character(client, cid)
sys.stdout.write(f"deleted: {cid}\n")
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
return 0
async def _set_persona_probe(args: ParsedArgs) -> int:
"""--set-persona-pad one-shot: POST a PAD to /sessions/{id}/persona_state
(affect injection), print the result, exit. Requires --session."""
assert isinstance(args, ParsedArgs)
assert args.session_id is not None and args.set_persona_pad is not None
try:
pad = [float(x) for x in args.set_persona_pad.split(",")]
except ValueError:
sys.stderr.write(
"[usage_error] --set-persona-pad must be comma-separated floats "
"(e.g. '0.4,0.1,-0.2')\n"
)
return 10
async with _probe_client(args) as client:
try:
await set_persona_state(client, args.session_id, {"pad": pad})
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
sys.stdout.write(
f"persona_state set: session={args.session_id[-8:]} pad={pad} (204)\n"
)
return 0
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
"""Sync entry point. Maps UsageError/_AuthError to exit codes BEFORE the event loop.""" """Sync entry point. Maps UsageError/_AuthError to exit codes BEFORE the event loop."""
assert argv is None or all(isinstance(a, str) for a in argv) assert argv is None or all(isinstance(a, str) for a in argv)
@@ -565,6 +747,12 @@ def main(argv: list[str] | None = None) -> int:
# argparse's --help / --version short-circuit via SystemExit(0). Pass the code # argparse's --help / --version short-circuit via SystemExit(0). Pass the code
# through verbatim — argparse already printed help to stdout. # through verbatim — argparse already printed help to stdout.
return int(exc.code) if exc.code is not None else 0 return int(exc.code) if exc.code is not None else 0
if args.whoami:
return asyncio.run(_whoami(args))
if args.characters:
return asyncio.run(_characters_probe(args))
if args.set_persona_pad is not None:
return asyncio.run(_set_persona_probe(args))
if args.send_content is None: if args.send_content is None:
# TUI mode — lazy import preserves INV-001 (no textual in cli at module scope). # TUI mode — lazy import preserves INV-001 (no textual in cli at module scope).
from ratatoskr.tui import run_tui from ratatoskr.tui import run_tui
+152
View File
@@ -406,3 +406,155 @@ async def get_persona_state(
if resp.status_code == 403 and error_code == "auth_scope_denied": if resp.status_code == 403 and error_code == "auth_scope_denied":
raise AuthScopeDenied(scope="persona.read") raise AuthScopeDenied(scope="persona.read")
raise SessionApiFailed(status=resp.status_code, body=resp.content) raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_me(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /me — the authenticated principal's identity + key metadata (spec §GET /me).
Boot-time whoami: verify the key without agent-config side effects. Returns
the parsed dict verbatim (freeform per the frozen OpenAPI; the spec documents
`{user_id, scopes, tier, display_name?, key_id?, key_label?, ...}`, optional
fields omitted-not-null). 401 (bad/absent key when auth is enabled) — like
every other non-200 — surfaces as SessionApiFailed (get_persona_state
precedent). Read-only, rate-exempt, no audit emission.
"""
assert client is not None
resp = await client.get("/me")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def list_character_models(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /models/available-for-characters — character-capable model profiles (#161).
Requires `character.read`. Returns `{items: [{name, description, thinking}]}`.
Parsed dict verbatim; any non-200 → SessionApiFailed.
"""
assert client is not None
resp = await client.get("/models/available-for-characters")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def create_character(
client: httpx.AsyncClient, character: dict[str, Any], *, state: dict[str, Any] | None = None
) -> dict[str, Any]:
"""POST /characters — create a transient character (#161). Requires `character.write`.
Body is `{character, state}` (state optional — a CharacterStateSchema for
mid-conversation rehydration). Returns 201 `{character_id, ttl_expires_at}`;
any non-201 → SessionApiFailed.
"""
assert client is not None
assert isinstance(character, dict) and character
resp = await client.post("/characters", json={"character": character, "state": state})
if resp.status_code == 201:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_character_state(client: httpx.AsyncClient, character_id: str) -> dict[str, Any]:
"""GET /characters/{character_id}/state — live runtime state (#161). Requires `character.read`.
Returns `{schema_version, pad, emotions_active, mood_drift, goal_signal_history}`;
refreshes the character's TTL. Any non-200 → SessionApiFailed.
"""
assert client is not None
assert character_id and isinstance(character_id, str)
resp = await client.get(f"/characters/{character_id}/state")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def delete_character(client: httpx.AsyncClient, character_id: str) -> None:
"""DELETE /characters/{character_id} — remove a transient character (#161).
Requires `character.write`. Bound sessions detach (next turn → 410
character_not_found). 200/204 → None; any other status → SessionApiFailed.
"""
assert client is not None
assert character_id and isinstance(character_id, str)
resp = await client.delete(f"/characters/{character_id}")
if resp.status_code in (200, 204):
return None
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def set_persona_state(
client: httpx.AsyncClient, session_id: str, snapshot: dict[str, Any]
) -> None:
"""POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection).
The request body is FREEFORM: the frozen OpenAPI 2.2.0 declares no request
schema and the prose spec documents only the GET counterpart — so the caller
supplies the snapshot shape (e.g. `{pad: [p, a, d]}`, mirroring the GET
`snapshot`). 204 No Content → None; any other status → SessionApiFailed.
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert isinstance(snapshot, dict)
resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot)
if resp.status_code == 204:
return None
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_session_bifrost(
client: httpx.AsyncClient, session_id: str, *, admin_key: str
) -> dict[str, Any]:
"""GET /admin/sessions/{session_id}/bifrost — admin-scoped Bifrost dispatch state (#176).
Returns the live Bifrost binding for a session: `{endpoint_url, consumer_id,
connected, capabilities_granted, tools: [{name, description}]}`. Requires the
`admin.sessions.read` scope (admin tier), so the request OVERRIDES the
Authorization header with `admin_key` (distinct from the client's default
consumer key). Read-only (audited server-side). Parsed dict verbatim; any
non-200 → SessionApiFailed — notably 403 `auth_scope_denied` (key lacks the
scope) and 404 `session_not_bifrost_bound` (session exists, no live client).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert admin_key and isinstance(admin_key, str)
resp = await client.get(
f"/admin/sessions/{session_id}/bifrost",
headers={"Authorization": f"Bearer {admin_key}"},
)
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_session_tools(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]:
"""GET /sessions/{session_id}/tools — owner-scoped tool inventory (spec #183).
Returns the merged tool list the LLM saw at turn-fire: `{agent_id,
builtin_tools: [...], bifrost_tools: [{name, description, parameters}, ...]}`.
Owner-scoped (`ctx.user_id == session.user_id`) — reachable with the consumer
key, NO admin scope. Cross-owner access returns 404 `session_not_found`
(existence-hiding); a revoked session returns 401 `auth_revoked`. Parsed dict
verbatim; any non-200 → SessionApiFailed (mirrors get_persona_state).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
resp = await client.get(f"/sessions/{session_id}/tools")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /capabilities — server capability discovery (spec §Ephemeral Templates).
Returns `{ephemeral_templates: {echo: {allowed_models, default_model,
system_prompt_max_bytes}}}` — what the server offers before a client decides
to instantiate. Any authenticated caller may read it (no scope). Parsed dict
verbatim; any non-200 → SessionApiFailed.
"""
assert client is not None
resp = await client.get("/capabilities")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
+62
View File
@@ -175,6 +175,23 @@ Event = (
) )
@dataclass(frozen=True)
class AdminEvent:
"""One `/admin/events` envelope (INV-046) — an admin-tier lifecycle event.
Distinct from the turn-stream `Event` union: this is the process-wide admin
broadcast stream, not a per-turn stream. `id` is a plain monotonic int
(resets on restart; heartbeats have id=0). `type` is a dotted namespace
(session.* / turn.* / key.* / system.*). `data` is a type-specific dict
most carry `session_id`; per INV-049 it holds IDs + small metadata only.
"""
id: int
type: str
timestamp: str | None
data: dict[str, Any]
class MalformedSseId(Exception): class MalformedSseId(Exception):
"""Raised when an SSE event's `id:` wire field is missing or non-composite.""" """Raised when an SSE event's `id:` wire field is missing or non-composite."""
@@ -598,6 +615,51 @@ async def stream_turn_resilient(
) )
async def stream_admin_events(
client: httpx.AsyncClient,
*,
admin_key: str,
last_event_id: int | None = None,
) -> AsyncIterator[AdminEvent]:
"""GET /admin/events SSE — the admin-tier lifecycle broadcast stream (INV-046).
Yields `AdminEvent` envelopes as they arrive. Admin-scoped (admin.events.read):
the request OVERRIDES Authorization with `admin_key` (distinct from the
client's default consumer bearer). `last_event_id` sets the `Last-Event-ID`
header for resume (plain decimal int). Long-lived iterate until the caller
stops or the connection ends. Non-200 SseConnectFailed; a mid-stream drop
SseConnectionDropped (caller may reconnect from the last-seen `AdminEvent.id`).
Malformed frames are skipped (best-effort stream).
"""
assert client is not None
assert admin_key and isinstance(admin_key, str)
headers = {"Authorization": f"Bearer {admin_key}"}
if last_event_id is not None:
headers["Last-Event-ID"] = str(last_event_id)
async with httpx_sse.aconnect_sse(
client, "GET", "/admin/events", headers=headers
) as event_source:
if event_source.response.status_code != 200:
body = await event_source.response.aread()
raise SseConnectFailed(status=event_source.response.status_code, body=body)
try:
async for sse in event_source.aiter_sse():
if sse.data == "":
continue
try:
env = json.loads(sse.data)
except json.JSONDecodeError:
continue # skip a malformed admin frame (best-effort)
yield AdminEvent(
id=env.get("id", 0),
type=env["type"],
timestamp=env.get("timestamp"),
data=env.get("data", {}),
)
except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ReadTimeout) as exc:
raise SseConnectionDropped(last_seen_sse_id=None) from exc
def _parse_sse_id(raw: str) -> SseId: def _parse_sse_id(raw: str) -> SseId:
"""Parse the SSE wire `id:` as composite `{turn_id}:{seq}`. See contract FN _parse_sse_id.""" """Parse the SSE wire `id:` as composite `{turn_id}:{seq}`. See contract FN _parse_sse_id."""
assert isinstance(raw, str) assert isinstance(raw, str)
+356 -7
View File
@@ -42,11 +42,16 @@ from ratatoskr.sessions import (
BifrostHandshakeFailed, BifrostHandshakeFailed,
PersonaNotConfigured, PersonaNotConfigured,
SessionApiFailed, SessionApiFailed,
SessionInfo,
create_session, create_session,
get_persona_state, get_persona_state,
get_session_bifrost,
get_session_tools,
list_agents, list_agents,
list_sessions,
) )
from ratatoskr.sse_client import ( from ratatoskr.sse_client import (
AdminEvent,
AffectUpdate, AffectUpdate,
AwaitingLlmFirstToken, AwaitingLlmFirstToken,
CancelAlreadyCompleted, CancelAlreadyCompleted,
@@ -68,7 +73,8 @@ from ratatoskr.sse_client import (
TurnIdFlip, TurnIdFlip,
WorkerPhase, WorkerPhase,
cancel_turn, cancel_turn,
stream_turn, stream_admin_events,
stream_turn_resilient,
) )
# ---- Australis theme (https://github.com/lkraven/australis) ------------------ # ---- Australis theme (https://github.com/lkraven/australis) ------------------
@@ -194,6 +200,46 @@ def _ts() -> str:
return now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}" return now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}"
def _format_admin_event(ev: AdminEvent) -> str:
"""One-line render of an /admin/events envelope for the AdminEvents pane.
Drops `session_id` from the detail (the pane is already session-scoped) and
shows HH:MM:SS from the ISO timestamp + the remaining small metadata fields.
"""
ts = (ev.timestamp or "")[11:19]
extras = " ".join(f"{k}={v}" for k, v in ev.data.items() if k != "session_id")
return f"[{ts}] {ev.type} {extras}".rstrip()
def _format_bifrost_state(state: dict) -> list[str]:
"""Render GET /admin/sessions/{id}/bifrost (#176) into BifrostState-pane lines."""
tools = [t.get("name", "?") for t in state.get("tools", [])]
caps = state.get("capabilities_granted", [])
return [
f"bifrost binding: connected={state.get('connected')} "
f"consumer={state.get('consumer_id', '?')}",
f" endpoint: {state.get('endpoint_url', '?')}",
f" caps_granted: {', '.join(caps) or '(none)'}",
f" tools ({len(tools)}): {', '.join(tools) or '(none)'}",
]
def _format_tool_inventory(tools: dict) -> list[str]:
"""Render GET /sessions/{id}/tools (#183) into Tools-pane inventory lines.
The merged tool list the LLM saw at turn-fire distinct from the live
tool_start/tool_result events that stream into the same pane during a turn.
"""
builtin = [t.get("name", "?") for t in tools.get("builtin_tools", [])]
bifrost = [t.get("name", "?") for t in tools.get("bifrost_tools", [])]
return [
f"session tool inventory: agent={tools.get('agent_id', '?')} "
f"builtin={len(builtin)} bifrost={len(bifrost)}",
f" builtin: {', '.join(builtin) or '(none)'}",
f" bifrost: {', '.join(bifrost) or '(none)'}",
]
def _format_persona_header(snapshot: dict) -> str: def _format_persona_header(snapshot: dict) -> str:
"""One-line persona summary for the sticky header widget. """One-line persona summary for the sticky header widget.
@@ -798,6 +844,128 @@ class AgentPickerApp(App[str | None]):
self.exit(None) self.exit(None)
def _session_desc(s: SessionInfo) -> str:
"""One-line session summary for the picker's second row."""
tail = f"session {s.session_id} · last active {s.last_active}"
if s.message_count is not None:
tail += f" · {s.message_count} msgs"
return tail
class SessionPickerApp(App[str | None]):
"""Startup session picker (design-brief §4, slice b2). Opens before
RatatoskrApp when bare TUI mode resolves >1 session. `run_async()` returns
the chosen session_id (str) or None on Esc/Ctrl-D/Ctrl-C dismissal.
Resume-only (design-brief §4 negative clause "no in-app session creation —
--new flag only"): the picker chooses among EXISTING sessions; starting a
fresh one is the --new flag's job. Architecturally separate from
RatatoskrApp (mirrors AgentPickerApp): list_sessions failures + dismissal
land before any alt-screen opens (preserves #6 INV-001).
"""
DEFAULT_CSS = """
Header, HeaderIcon, HeaderTitle, HeaderClock {
background: $surface;
color: $au-bright-blue;
}
Footer {
background: $surface;
}
ListView {
scrollbar-background: $background;
scrollbar-background-hover: $background;
scrollbar-background-active: $background;
scrollbar-color: $au-dark-50;
scrollbar-color-hover: $au-dark-60;
scrollbar-color-active: $au-bright-cyan;
}
#picker-prompt {
dock: top;
height: 1;
padding: 0 1;
color: $au-bright-cyan;
background: $surface;
}
#session-list {
height: 1fr;
background: $background;
}
#session-list > ListItem {
height: auto;
padding: 1 1;
background: $background;
}
#session-list:focus ListItem.-highlight {
background: $primary;
}
#session-list:focus ListItem.-highlight .session-id-line {
color: $au-bright-white;
text-style: bold;
}
#session-list:focus ListItem.-highlight .session-desc {
color: $au-bright-80;
}
.session-id-line {
color: $au-bright-blue;
text-style: bold;
}
.session-desc {
color: $au-bright-70;
}
"""
BINDINGS: ClassVar[list[Binding]] = [
Binding("enter", "pick", "Resume", priority=True),
Binding("escape", "dismiss", "Cancel", priority=True),
Binding("ctrl+d", "dismiss", "Cancel", priority=True),
Binding("ctrl+c", "dismiss", "Cancel", priority=True),
]
def __init__(self, sessions: list[SessionInfo]) -> None:
super().__init__()
# PRE-001: caller (_resolve_then_run) resolves the 0-session and
# 1-session cases BEFORE constructing the picker.
assert sessions
self.sessions = sessions
self.register_theme(AUSTRALIS_THEME)
self.theme = "australis"
def compose(self) -> ComposeResult:
yield Header()
yield Static(
"Pick a session to resume (relaunch with --new for a fresh one):",
id="picker-prompt",
)
yield ListView(
*[
ListItem(
Static(
f"{s.name or s.session_id} · {s.agent_id}",
classes="session-id-line",
),
Static(_session_desc(s), classes="session-desc"),
)
for s in self.sessions
],
id="session-list",
)
yield Footer()
async def on_mount(self) -> None:
self.query_one("#session-list", ListView).focus()
def action_pick(self) -> None:
lv = self.query_one("#session-list", ListView)
idx = lv.index
if idx is None:
return # nothing highlighted; ignore
self.exit(self.sessions[idx].session_id)
def action_dismiss(self) -> None:
self.exit(None)
class RatatoskrApp(App[int]): class RatatoskrApp(App[int]):
"""Textual TUI shell — single chat pane.""" """Textual TUI shell — single chat pane."""
@@ -898,7 +1066,7 @@ class RatatoskrApp(App[int]):
/* v0.8.1: #current-text Static removed. Streaming text now coalesces /* v0.8.1: #current-text Static removed. Streaming text now coalesces
on `\n` and writes directly to #transcript (same pattern as v0.7.1 on `\n` and writes directly to #transcript (same pattern as v0.7.1
thinking fix). Eliminates the dock-bottom-growth-overlap bug. */ thinking fix). Eliminates the dock-bottom-growth-overlap bug. */
#tools-log, #debug-log, #thinking-log { #tools-log, #debug-log, #thinking-log, #bifrost-log, #admin-events-log {
background: $background; background: $background;
padding: 0 1; padding: 0 1;
} }
@@ -1063,6 +1231,24 @@ class RatatoskrApp(App[int]):
id="persona-log", wrap=True, markup=False, id="persona-log", wrap=True, markup=False,
highlight=False, min_width=0, highlight=False, min_width=0,
) )
with TabPane("Bifrost", id="bifrost-tab"):
# #176: admin-scoped Bifrost dispatch state (endpoint,
# connected, granted caps, tools) via
# GET /admin/sessions/{id}/bifrost. Hydrated on mount
# with the admin key; "not configured" when absent.
yield RichLog(
id="bifrost-log", wrap=True, markup=False,
highlight=False, min_width=0,
)
with TabPane("AdminEvents", id="admin-events-tab"):
# #11: live GET /admin/events SSE stream, admin-scoped,
# FILTERED to the active session (design-brief §6). A
# long-lived worker appends matching lifecycle events;
# "not configured" when no admin key is set.
yield RichLog(
id="admin-events-log", wrap=True, markup=False,
highlight=False, min_width=0,
)
# INV-002 + INV-003: visible identity + hint widgets (Footer-area). # INV-002 + INV-003: visible identity + hint widgets (Footer-area).
# pane-name widget displays current side-pane name. # pane-name widget displays current side-pane name.
yield Static("", id="identity") yield Static("", id="identity")
@@ -1116,6 +1302,17 @@ class RatatoskrApp(App[int]):
# surface (PersonaNotConfigured) get a placeholder + empty header. # surface (PersonaNotConfigured) get a placeholder + empty header.
if self.agent_id is not None: if self.agent_id is not None:
self.run_worker(self._hydrate_persona()) self.run_worker(self._hydrate_persona())
# #183: hydrate the Tools pane with the session's tool inventory via
# GET /sessions/{id}/tools (owner-scoped — consumer key, no admin scope).
# Unconditional: every session has a tool inventory to introspect.
self.run_worker(self._hydrate_session_tools())
# #176: hydrate the BifrostState pane via GET /admin/sessions/{id}/bifrost
# (admin-scoped). Self-labels "not configured" when no admin key is set,
# "not bound" for the common unbound-session 404 — always writes at mount.
self.run_worker(self._hydrate_bifrost_state())
# #11: long-lived worker streaming GET /admin/events into the AdminEvents
# pane, filtered to this session. Admin-key-gated; cancelled on app exit.
self.run_worker(self._stream_admin_events())
async def _hydrate_persona(self) -> None: async def _hydrate_persona(self) -> None:
"""Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state. """Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state.
@@ -1151,6 +1348,128 @@ class RatatoskrApp(App[int]):
f"err={type(exc).__name__}: {exc!s:.120}" f"err={type(exc).__name__}: {exc!s:.120}"
) )
async def _hydrate_session_tools(self) -> None:
"""Hydrate the Tools pane inventory via GET /sessions/{id}/tools (#183).
Best-effort observability (mirrors _hydrate_persona): on 200, writes the
merged tool inventory (builtin + bifrost) the LLM saw at turn-fire into
the Tools pane + audits; on any failure, audits and moves on never
crashes the TUI. Owner-scoped, so reachable with the consumer key.
"""
assert self.client is not None and self.session_id is not None
from rich.text import Text as RichText
try:
tools = await get_session_tools(self.client, self.session_id)
except Exception as exc: # best-effort — never crash the TUI on hydrate
self._audit(
f"session_tools_hydration_failed session={self.session_id[-8:]} "
f"err={type(exc).__name__}: {exc!s:.120}"
)
return
log = self.query_one("#tools-log", RichLog)
for line in _format_tool_inventory(tools):
log.write(RichText(line))
self._audit(
f"session_tools_hydrated session={self.session_id[-8:]} "
f"builtin={len(tools.get('builtin_tools', []))} "
f"bifrost={len(tools.get('bifrost_tools', []))}"
)
async def _hydrate_bifrost_state(self) -> None:
"""Hydrate the BifrostState pane via GET /admin/sessions/{id}/bifrost (#176).
Admin-scoped (admin.sessions.read) uses `self.args.admin_key`. Best-effort
(mirrors _hydrate_session_tools): on 200 writes the live binding (endpoint,
connected, granted caps, tools) + audits; on failure a labeled line + audit,
never crashes. No admin key "not configured". 404 session_not_bifrost_bound
is the routine unbound-session case; 403 means the key lacks the scope.
"""
assert self.client is not None and self.session_id is not None
from rich.text import Text as RichText
log = self.query_one("#bifrost-log", RichLog)
admin_key = getattr(self.args, "admin_key", None)
if not admin_key:
log.write(
RichText("(admin key not configured — set RATATOSKR_ADMIN_API_KEY)")
)
self._audit(
f"bifrost_state_skipped session={self.session_id[-8:]} reason=no_admin_key"
)
return
try:
state = await get_session_bifrost(
self.client, self.session_id, admin_key=admin_key
)
except SessionApiFailed as exc:
label = (
"(session not bound to Bifrost)"
if exc.status == 404
else f"(bifrost state unavailable: HTTP {exc.status})"
)
log.write(RichText(label))
self._audit(
f"bifrost_state_unavailable session={self.session_id[-8:]} status={exc.status}"
)
return
except Exception as exc: # best-effort — never crash the TUI on hydrate
log.write(RichText(f"(bifrost state hydration failed: {type(exc).__name__})"))
self._audit(
f"bifrost_state_hydration_failed session={self.session_id[-8:]} "
f"err={type(exc).__name__}: {exc!s:.120}"
)
return
for line in _format_bifrost_state(state):
log.write(RichText(line))
self._audit(
f"bifrost_state_hydrated session={self.session_id[-8:]} "
f"connected={state.get('connected')} tools={len(state.get('tools', []))}"
)
def _admin_event_matches(self, ev: AdminEvent) -> bool:
"""AdminEvents filter (design-brief §6): active-session events + non-heartbeat
system.* (stream-integrity signals). Heartbeats are keepalive noise."""
if ev.type == "system.heartbeat":
return False
if ev.type.startswith("system."):
return True
return ev.data.get("session_id") == self.session_id
async def _stream_admin_events(self) -> None:
"""Stream GET /admin/events (admin-scoped) into the AdminEvents pane (#11).
Long-lived + best-effort (never crashes the TUI). Filtered to the active
session (design-brief §6): appends matching lifecycle events as they
arrive. No admin key "not configured". On connect failure (e.g. 403
scope-denied) or a mid-stream drop, writes a labeled line and stops.
"""
assert self.client is not None and self.session_id is not None
from rich.text import Text as RichText
log = self.query_one("#admin-events-log", RichLog)
admin_key = getattr(self.args, "admin_key", None)
if not admin_key:
log.write(RichText("(admin key not configured — set RATATOSKR_ADMIN_API_KEY)"))
self._audit(
f"admin_events_skipped session={self.session_id[-8:]} reason=no_admin_key"
)
return
try:
async for ev in stream_admin_events(self.client, admin_key=admin_key):
if self._admin_event_matches(ev):
log.write(RichText(_format_admin_event(ev)))
except SseConnectFailed as exc:
log.write(RichText(f"(admin events unavailable: HTTP {exc.status})"))
self._audit(
f"admin_events_unavailable session={self.session_id[-8:]} status={exc.status}"
)
except Exception as exc: # drop / best-effort — never crash the TUI
log.write(RichText(f"(admin events stream ended: {type(exc).__name__})"))
self._audit(
f"admin_events_ended session={self.session_id[-8:]} err={type(exc).__name__}"
)
def _update_persona_surfaces(self, snapshot: dict) -> None: def _update_persona_surfaces(self, snapshot: dict) -> None:
"""Update sticky header + Persona pane from a fresh snapshot. """Update sticky header + Persona pane from a fresh snapshot.
@@ -1318,7 +1637,7 @@ class RatatoskrApp(App[int]):
pass pass
try: try:
async for event in stream_turn(self.client, self.session_id, content): async for event in stream_turn_resilient(self.client, self.session_id, content):
if self.active_turn_id is None: if self.active_turn_id is None:
self.active_turn_id = event.sse_id.turn_id self.active_turn_id = event.sse_id.turn_id
self._write_turn_headers(self.active_turn_id) self._write_turn_headers(self.active_turn_id)
@@ -1430,8 +1749,9 @@ def run_tui(args: ParsedArgs) -> int:
""" """
# PRE-001: TUI-mode marker (issue #4 contract) # PRE-001: TUI-mode marker (issue #4 contract)
assert isinstance(args, ParsedArgs) and args.send_content is None assert isinstance(args, ParsedArgs) and args.send_content is None
# PRE-002: Exactly one of session_id / new must be set (xor) # PRE-002 (slice b2): --session and --new are mutually exclusive, but NEITHER
assert bool(args.session_id) != bool(args.new) # is now valid — bare TUI mode opens the startup session picker (§4).
assert not (args.session_id and args.new)
return asyncio.run(_resolve_then_run(args)) return asyncio.run(_resolve_then_run(args))
@@ -1467,6 +1787,35 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
# agent_id (remote wins on conflict, since a server-listed agent # agent_id (remote wins on conflict, since a server-listed agent
# is the authoritative source). # is the authoritative source).
chosen_agent_id: str | None = args.agent_id chosen_agent_id: str | None = args.agent_id
# slice b2: bare TUI mode (no --session, no --new) → startup session
# picker (design-brief §4). Resolve into a concrete session_id BEFORE
# the new/resume branches. Resume-only: bare + 0 sessions is an error
# (creating a session is the --new flag's job).
resolved_session_id: str | None = args.session_id
if not args.new and args.session_id is None:
try:
page = await list_sessions(client)
except SessionApiFailed as exc:
sys.stderr.write(
f"[session_api_failed] status={exc.status} body={exc.body!r}\n"
)
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
if not page.items:
sys.stderr.write(
"[no_sessions] no sessions to resume; "
"launch with --new --agent <id>\n"
)
return 14
if len(page.items) == 1:
# §4: picker only when >1 — a single session auto-resumes.
resolved_session_id = page.items[0].session_id
else:
resolved_session_id = await SessionPickerApp(page.items).run_async()
if resolved_session_id is None:
return 0 # Esc / Ctrl-D — clean exit, no session opened
if args.new and args.agent_id is None: if args.new and args.agent_id is None:
try: try:
agents = await list_agents(client) agents = await list_agents(client)
@@ -1551,8 +1900,8 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
session_id = info.session_id session_id = info.session_id
agent_id: str | None = info.agent_id agent_id: str | None = info.agent_id
else: else:
assert args.session_id is not None assert resolved_session_id is not None
session_id = args.session_id session_id = resolved_session_id
agent_id = args.agent_id # may be None — INV-002 carve-out preserved agent_id = args.agent_id # may be None — INV-002 carve-out preserved
app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client) app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client)
exit_code = await app.run_async() exit_code = await app.run_async()
+5
View File
@@ -68,6 +68,10 @@ def main(argv: list[str] | None = None) -> int:
affect_read_url = os.environ.get( affect_read_url = os.environ.get(
"RATATOSKR_AFFECT_READ_URL", "http://127.0.0.1:8390" "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 # INV-001: lazy import. Users without [web] extras get a clean hint
# instead of a raw ImportError. Scoped narrowly to the OPTIONAL # 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_consumer_key=bifrost_consumer_key,
bifrost_visible_host=bifrost_visible_host, bifrost_visible_host=bifrost_visible_host,
affect_read_url=affect_read_url, affect_read_url=affect_read_url,
admin_key=admin_key,
) )
# Boot banner to stderr (so stdout stays clean for piping). # Boot banner to stderr (so stdout stays clean for piping).
+114 -3
View File
@@ -18,7 +18,12 @@ from importlib.metadata import version as _pkg_version
import httpx import httpx
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.requests import Request 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.routing import Mount, Route
from starlette.staticfiles import StaticFiles from starlette.staticfiles import StaticFiles
@@ -35,9 +40,12 @@ from ratatoskr.sessions import (
create_session, create_session,
endpoint_for_plane, endpoint_for_plane,
get_persona_state, get_persona_state,
get_session_bifrost,
get_session_tools,
list_agents, list_agents,
) )
from ratatoskr.sse_client import ( from ratatoskr.sse_client import (
AdminEvent,
CancelAlreadyCompleted, CancelAlreadyCompleted,
Cancelled, Cancelled,
CancelFailed, CancelFailed,
@@ -50,7 +58,8 @@ from ratatoskr.sse_client import (
SseConnectionDropped, SseConnectionDropped,
TurnIdFlip, TurnIdFlip,
cancel_turn, cancel_turn,
stream_turn, stream_admin_events,
stream_turn_resilient,
) )
@@ -291,7 +300,7 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
try: try:
handle.status = "streaming" handle.status = "streaming"
try: try:
async for event in stream_turn(client, session_id, handle.content): async for event in stream_turn_resilient(client, session_id, handle.content):
# v0.16.0: capture the upstream (Worldtree-assigned) # v0.16.0: capture the upstream (Worldtree-assigned)
# turn_id from the first event so cancel paths target # turn_id from the first event so cancel paths target
# the real upstream turn, not our local counter. # the real upstream turn, not our local counter.
@@ -416,6 +425,100 @@ async def _affect_state_endpoint(request: Request) -> JSONResponse:
return JSONResponse(r.json(), status_code=r.status_code) 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( def create_app(
client_factory: Callable[[], httpx.AsyncClient], client_factory: Callable[[], httpx.AsyncClient],
*, *,
@@ -423,6 +526,7 @@ def create_app(
bifrost_consumer_key: str | None = None, bifrost_consumer_key: str | None = None,
bifrost_visible_host: str | None = None, bifrost_visible_host: str | None = None,
affect_read_url: str | None = None, affect_read_url: str | None = None,
admin_key: str | None = None,
) -> Starlette: ) -> Starlette:
"""Construct the Starlette app — wire routes + state per FN create_app. """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/sessions", _create_session_endpoint, methods=["POST"]),
Route("/api/agents/{agent_id}/persona_state", _persona_state_endpoint), Route("/api/agents/{agent_id}/persona_state", _persona_state_endpoint),
Route("/api/affect/{agent_id}", _affect_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}", _submit_turn_endpoint, methods=["POST"]),
Route("/api/turns/{session_id}/stream", _stream_turn_endpoint), Route("/api/turns/{session_id}/stream", _stream_turn_endpoint),
Route("/api/turns/{session_id}/cancel", _cancel_turn_endpoint, methods=["POST"]), 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, # 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. # same dev box) — distinct from the WT-visible host used for binding.
app.state.affect_read_url = affect_read_url 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) # INV-002: turn registry is in-process memory, keyed (session_id, turn_id)
app.state.turn_registry = {} app.state.turn_registry = {}
return app return app
+173 -6
View File
@@ -231,6 +231,26 @@ body {
50% { content: "··"; } 75% { content: "···"; } 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 */ /* terminal status chips */
.chip { .chip {
display: inline-flex; align-items: center; gap: 7px; 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="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="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="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> </nav>
<div class="pane-head"> <div class="pane-head">
<span id="pane-name">tools</span> <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-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-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-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> </div>
</section> </section>
</main> </main>
@@ -542,7 +566,7 @@ body {
"use strict"; "use strict";
const $ = (id) => document.getElementById(id); 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) { function esc(s) {
const d = document.createElement("div"); const d = document.createElement("div");
@@ -603,8 +627,46 @@ function markdownSafe(raw) {
// per-turn live buffers (reset at turn open) // per-turn live buffers (reset at turn open)
const LIVE = { resp: "", think: "" }; 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 ---- // ---- 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) { function bumpBadge(pane) {
if (!(pane in PANE_BADGE)) return; if (!(pane in PANE_BADGE)) return;
PANE_BADGE[pane] += 1; PANE_BADGE[pane] += 1;
@@ -773,6 +835,7 @@ async function loadAffect(agentId) {
const snap = await r.json(); const snap = await r.json();
renderAffectPane(snap); renderAffectPane(snap);
setPersonaStrip(snap); // pad bars are the live signal setPersonaStrip(snap); // pad bars are the live signal
state.lastAffectAt = snap.emitted_at || state.lastAffectAt; // post-turn poll stop-signal
} else { } else {
let code = ""; let code = "";
try { code = (await r.json()).error_code || ""; } catch (_) {} 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 ---- // ---- session lifecycle ----
async function startSession() { async function startSession() {
const agentId = $("agent-picker").value; const agentId = $("agent-picker").value;
@@ -837,6 +987,11 @@ async function startSession() {
$("setup").style.display = "none"; $("setup").style.display = "none";
$("workspace").classList.add("live"); $("workspace").classList.add("live");
await loadPersona(agentId); 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(); $("prompt-input").focus();
} catch (e) { } catch (e) {
$("setup-err").textContent = "network error opening session"; $("setup-err").textContent = "network error opening session";
@@ -943,11 +1098,13 @@ async function submitPrompt() {
es.addEventListener("thinking", (e) => { es.addEventListener("thinking", (e) => {
const d = JSON.parse(e.data); const d = JSON.parse(e.data);
thinkingDeltas += 1; thinkingDeltas += 1;
showThinkingNote(); // ephemeral "<Agent> is pondering…" in the transcript
appendThinking(d.content); appendThinking(d.content);
}); });
es.addEventListener("text", (e) => { es.addEventListener("text", (e) => {
const d = JSON.parse(e.data); const d = JSON.parse(e.data);
textDeltas += 1; textDeltas += 1;
hideThinkingNote(); // real text begins — reasoning display is done
appendResponse(d.content); appendResponse(d.content);
}); });
es.addEventListener("text_boundary", (e) => { es.addEventListener("text_boundary", (e) => {
@@ -990,6 +1147,7 @@ async function submitPrompt() {
function terminal(label, cls, e) { function terminal(label, cls, e) {
const aw = document.querySelector("#transcript .awaiting.live"); const aw = document.querySelector("#transcript .awaiting.live");
if (aw) aw.remove(); if (aw) aw.remove();
hideThinkingNote();
finalizeResponse(); finalizeResponse();
document.querySelectorAll("#pane-thinking .think-live").forEach((b) => b.classList.remove("think-live")); document.querySelectorAll("#pane-thinking .think-live").forEach((b) => b.classList.remove("think-live"));
let meta = ""; let meta = "";
@@ -1006,9 +1164,18 @@ async function submitPrompt() {
$("composer").classList.remove("streaming"); $("composer").classList.remove("streaming");
setConn(cls === "error" ? "error" : "idle", cls === "error" ? "error" : "connected"); setConn(cls === "error" ? "error" : "idle", cls === "error" ? "error" : "connected");
if (cls === "done" && state.agentId) { if (cls === "done" && state.agentId) {
// Tier-3 affect.emit is POST-TURN ASYNC — it lands in our store a couple seconds // Tier-3 affect.emit is POST-TURN ASYNC and can land well after [done] — a single
// after [done]. Refresh the pane on a short delay to catch the new PAD (issue #18). // fixed refresh races it (issue #18 foot-gun). Poll a short window, stopping once
setTimeout(() => loadPersona(state.agentId), 2000); // 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(); $("prompt-input").focus();
} }
@@ -1053,7 +1220,7 @@ document.querySelectorAll(".tab").forEach((t) =>
// ---- keyboard ---- // ---- keyboard ----
document.addEventListener("keydown", (e) => { 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 tabs = document.querySelectorAll(".tab");
const idx = parseInt(e.key, 10) - 1; const idx = parseInt(e.key, 10) - 1;
if (tabs[idx]) { activateTab(tabs[idx]); e.preventDefault(); } if (tabs[idx]) { activateTab(tabs[idx]); e.preventDefault(); }
+159 -6
View File
@@ -175,10 +175,29 @@ class TestParseArgs:
) )
def test_usage_neither_session_nor_new(self) -> None: def test_usage_neither_session_nor_new(self) -> None:
"""usage_neither_session_nor_new: neither flag → UsageError('pass exactly one').""" """usage_neither_session_nor_new: --send with neither flag → UsageError.
with pytest.raises(UsageError, match="pass exactly one"):
--send is non-interactive (no picker can open), so a session must be
named. Bare TUI mode (no --send) is now valid session picker (§4).
"""
with pytest.raises(UsageError, match="--send requires"):
_parse_args(["--send", "hi", "--api-key", "k"]) _parse_args(["--send", "hi", "--api-key", "k"])
def test_bare_tui_mode_accepted(self) -> None:
"""bare_tui_mode (slice b2): no --send, no --session, no --new → valid;
_resolve_then_run drives the startup session picker (design-brief §4)."""
args = _parse_args(["--api-key", "k"])
assert args.send_content is None
assert args.session_id is None
assert args.new is False
assert args.agent_id is None
def test_usage_bare_tui_with_agent(self) -> None:
"""bare_tui_with_agent (slice b2): bare TUI + --agent → UsageError
(--agent belongs with --new; bare mode opens the resume picker)."""
with pytest.raises(UsageError, match="belongs with --new"):
_parse_args(["--agent", "mimir", "--api-key", "k"])
def test_usage_send_new_without_agent(self) -> None: def test_usage_send_new_without_agent(self) -> None:
"""send_new_without_agent (issue #8): --send --new without --agent → UsageError. """send_new_without_agent (issue #8): --send --new without --agent → UsageError.
@@ -1319,10 +1338,15 @@ class TestMain:
rc = main(["--send", "hi", "--new", "--agent", "m", "--api-key", "k"]) rc = main(["--send", "hi", "--new", "--agent", "m", "--api-key", "k"])
assert rc == 0 assert rc == 0
def test_usage_error_no_send( def test_empty_argv_fails_on_auth(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None: ) -> None:
"""usage_error_no_send: empty argv → exit 10; stderr [usage_error]; _amain never called.""" """empty argv → exit 11 [auth_error]; _amain never called.
Since slice b2 bare TUI mode (no --send/--session/--new) is VALID (it
opens the session picker), so empty argv is no longer a usage error
it now fails on the missing API key instead (still before _amain).
"""
amain_calls: list[int] = [] amain_calls: list[int] = []
async def fake_amain(args: ParsedArgs) -> int: async def fake_amain(args: ParsedArgs) -> int:
@@ -1331,8 +1355,8 @@ class TestMain:
monkeypatch.setattr(cli_mod, "_amain", fake_amain) monkeypatch.setattr(cli_mod, "_amain", fake_amain)
rc = main([]) rc = main([])
assert rc == 10 assert rc == 11
assert "[usage_error]" in capsys.readouterr().err assert "[auth_error]" in capsys.readouterr().err
assert amain_calls == [] assert amain_calls == []
def test_usage_error_both_session_and_new( def test_usage_error_both_session_and_new(
@@ -1540,3 +1564,132 @@ class TestBifrostBindCli:
) )
rc = await _amain(args) rc = await _amain(args)
assert rc == 22 assert rc == 22
class TestWhoami:
"""--whoami one-shot probe (slice: capabilities+me): GET /me + GET /capabilities."""
def test_whoami_standalone_accepted(self) -> None:
"""whoami_standalone_accepted: --whoami alone → valid; whoami=True, no turn flags."""
args = _parse_args(["--whoami", "--api-key", "k"])
assert args.whoami is True
assert args.send_content is None
assert args.session_id is None
assert args.new is False
def test_whoami_with_send_rejected(self) -> None:
"""whoami_with_send_rejected [adversarial]: --whoami + --send → UsageError."""
with pytest.raises(UsageError, match="standalone probe"):
_parse_args(["--whoami", "--send", "hi", "--api-key", "k"])
def test_whoami_with_new_rejected(self) -> None:
"""whoami_with_new_rejected [adversarial]: --whoami + --new → UsageError."""
with pytest.raises(UsageError, match="standalone probe"):
_parse_args(["--whoami", "--new", "--agent", "m", "--api-key", "k"])
@respx.mock
def test_whoami_mode_prints_report(self, capsys: pytest.CaptureFixture[str]) -> None:
"""whoami_mode_prints_report [happy,tracer]: /me + /capabilities → stdout report; exit 0."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(
200,
json={
"user_id": "alice",
"scopes": ["conversations.read", "conversations.write"],
"tier": "user",
"key_id": "a1b2c3d4",
},
)
)
respx.get("https://w.example/capabilities").mock(
return_value=httpx.Response(
200,
json={
"ephemeral_templates": {
"echo": {
"allowed_models": ["glm5-turbo"],
"default_model": "glm5-turbo",
"system_prompt_max_bytes": 32768,
}
}
},
)
)
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
assert rc == 0
out = capsys.readouterr().out
assert "user_id: alice" in out
assert "tier: user" in out
assert "key_id: a1b2c3d4" in out
assert "ephemeral_template echo" in out
assert "glm5-turbo" in out
@respx.mock
def test_whoami_me_auth_failure_exits_20(self, capsys: pytest.CaptureFixture[str]) -> None:
"""whoami_me_auth_failure [error]: /me 401 → exit 20 [session_api_failed]."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(401, json={"detail": "auth_invalid"})
)
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
assert rc == 20
assert "[session_api_failed]" in capsys.readouterr().err
class TestTier2Probes:
"""--characters + --set-persona-pad one-shot probes (Tier-2: #161 + persona_state-write)."""
def test_characters_standalone_accepted(self) -> None:
"""characters_standalone: --characters alone → valid."""
args = _parse_args(["--characters", "--api-key", "k"])
assert args.characters is True
assert args.session_id is None
def test_set_persona_requires_session(self) -> None:
"""set_persona_requires_session [adversarial]: --set-persona-pad needs --session."""
with pytest.raises(UsageError, match="requires --session"):
_parse_args(["--set-persona-pad", "0.4,0.1,-0.2", "--api-key", "k"])
def test_probes_mutually_exclusive(self) -> None:
"""probes_mutually_exclusive [adversarial]: --whoami + --characters → UsageError."""
with pytest.raises(UsageError, match="mutually exclusive"):
_parse_args(["--whoami", "--characters", "--api-key", "k"])
@respx.mock
def test_characters_probe_lifecycle(self, capsys: pytest.CaptureFixture[str]) -> None:
"""characters_probe [happy,tracer]: models → create → state → delete; report to stdout."""
respx.get("https://w.example/models/available-for-characters").mock(
return_value=httpx.Response(200, json={"items": [{"name": "fast"}]})
)
respx.post("https://w.example/characters").mock(
return_value=httpx.Response(201, json={"character_id": "char_z", "ttl_expires_at": "t"})
)
respx.get("https://w.example/characters/char_z/state").mock(
return_value=httpx.Response(200, json={"schema_version": "1", "pad": [0.1, 0.2, 0.3]})
)
del_route = respx.delete("https://w.example/characters/char_z").mock(
return_value=httpx.Response(204)
)
rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"])
assert rc == 0
out = capsys.readouterr().out
assert "character models: fast" in out
assert "created: char_z" in out
assert "pad=[0.1, 0.2, 0.3]" in out
assert "deleted: char_z" in out
assert del_route.call_count == 1 # lifecycle cleaned up
@respx.mock
def test_set_persona_probe(self, capsys: pytest.CaptureFixture[str]) -> None:
"""set_persona_probe [happy,tracer]: POST pad to /sessions/{id}/persona_state; 204."""
import json as _json
route = respx.post("https://w.example/sessions/s1/persona_state").mock(
return_value=httpx.Response(204)
)
rc = main(
["--set-persona-pad", "0.4,0.1,-0.2", "--session", "s1",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 0
assert "persona_state set" in capsys.readouterr().out
assert _json.loads(route.calls[0].request.content) == {"pad": [0.4, 0.1, -0.2]}
+297
View File
@@ -16,11 +16,20 @@ from ratatoskr.sessions import (
PersonaNotConfigured, PersonaNotConfigured,
SessionApiFailed, SessionApiFailed,
SessionPage, SessionPage,
create_character,
create_session, create_session,
delete_character,
endpoint_for_plane, endpoint_for_plane,
get_capabilities,
get_character_state,
get_me,
get_persona_state, get_persona_state,
get_session_bifrost,
get_session_tools,
list_agents, list_agents,
list_character_models,
list_sessions, list_sessions,
set_persona_state,
) )
@@ -896,3 +905,291 @@ class TestGetPersonaState:
with pytest.raises(PersonaNotConfigured) as exc_info: with pytest.raises(PersonaNotConfigured) as exc_info:
await get_persona_state(client, "domari") await get_persona_state(client, "domari")
assert exc_info.value.agent_id == "domari" assert exc_info.value.agent_id == "domari"
class TestGetMe:
"""docs/contracts/issues/2.contract.md FN get_me (slice: capabilities+me)."""
@respx.mock
async def test_happy_authenticated(self) -> None:
"""happy_authenticated [happy,tracer]: 200 → parsed identity dict verbatim."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(
200,
json={
"user_id": "alice",
"scopes": ["conversations.read", "conversations.write"],
"tier": "user",
"key_id": "a1b2c3d4",
"key_label": "alice phone",
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
me = await get_me(client)
assert me["user_id"] == "alice"
assert me["tier"] == "user"
assert me["key_id"] == "a1b2c3d4"
assert me["scopes"] == ["conversations.read", "conversations.write"]
@respx.mock
async def test_anonymous_dev_mode(self) -> None:
"""anonymous_dev_mode: 200 anonymous shape → dict with tier=anonymous."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(
200,
json={
"user_id": "anonymous",
"scopes": ["conversations.read"],
"tier": "anonymous",
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
me = await get_me(client)
assert me["tier"] == "anonymous"
assert "key_id" not in me # optional fields omitted, not null
@respx.mock
async def test_401_raises_session_api_failed(self) -> None:
"""401_raises [error]: bad/absent key → SessionApiFailed(status=401)."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(401, json={"detail": "auth_invalid"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_me(client)
assert exc.value.status == 401
class TestGetCapabilities:
"""docs/contracts/issues/2.contract.md FN get_capabilities (slice: capabilities+me)."""
@respx.mock
async def test_happy(self) -> None:
"""happy [happy]: 200 → ephemeral_templates dict verbatim."""
respx.get("https://w.example/capabilities").mock(
return_value=httpx.Response(
200,
json={
"ephemeral_templates": {
"echo": {
"allowed_models": ["glm5-turbo", "glm4.7"],
"default_model": "glm5-turbo",
"system_prompt_max_bytes": 32768,
}
}
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
caps = await get_capabilities(client)
echo = caps["ephemeral_templates"]["echo"]
assert echo["default_model"] == "glm5-turbo"
assert echo["system_prompt_max_bytes"] == 32768
@respx.mock
async def test_non_200_raises(self) -> None:
"""non_200_raises [error]: 500 → SessionApiFailed(status=500)."""
respx.get("https://w.example/capabilities").mock(
return_value=httpx.Response(500, content=b"boom")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_capabilities(client)
assert exc.value.status == 500
class TestGetSessionTools:
"""docs/contracts/issues/2.contract.md — get_session_tools (GET /sessions/{id}/tools, #183)."""
@respx.mock
async def test_happy(self) -> None:
"""happy [happy,tracer]: 200 → merged tool inventory dict verbatim."""
respx.get("https://w.example/sessions/s1/tools").mock(
return_value=httpx.Response(
200,
json={
"agent_id": "alice:wizard",
"builtin_tools": [],
"bifrost_tools": [
{"name": "bifrost.alice.set_field", "description": "d", "parameters": {}}
],
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
tools = await get_session_tools(client, "s1")
assert tools["agent_id"] == "alice:wizard"
assert tools["builtin_tools"] == []
assert tools["bifrost_tools"][0]["name"] == "bifrost.alice.set_field"
@respx.mock
async def test_cross_owner_404_raises(self) -> None:
"""cross_owner_404 [error]: 404 session_not_found → SessionApiFailed(404)."""
respx.get("https://w.example/sessions/s1/tools").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_session_tools(client, "s1")
assert exc.value.status == 404
@respx.mock
async def test_empty_session_id_asserts(self) -> None:
"""empty_session_id [adversarial]: '' → AssertionError; no HTTP issued."""
route = respx.get("https://w.example/sessions//tools").mock(
return_value=httpx.Response(200, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await get_session_tools(client, "")
assert route.call_count == 0
class TestGetSessionBifrost:
"""#2 contract — get_session_bifrost (GET /admin/sessions/{id}/bifrost, #176)."""
@respx.mock
async def test_happy_uses_admin_bearer(self) -> None:
"""happy [happy,tracer]: 200 → binding dict; request carries the ADMIN bearer (override)."""
route = respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
return_value=httpx.Response(
200,
json={
"endpoint_url": "https://bifrost.example/mcp",
"consumer_id": "alice",
"connected": True,
"capabilities_granted": ["tools:call", "tools:read"],
"tools": [{"name": "bifrost.alice.echo", "description": "echo"}],
},
)
)
async with httpx.AsyncClient(
base_url="https://w.example",
headers={"Authorization": "Bearer consumer-key"},
) as client:
state = await get_session_bifrost(client, "s1", admin_key="admin-xyz")
assert state["connected"] is True
assert state["tools"][0]["name"] == "bifrost.alice.echo"
# the request overrode the client's default consumer bearer with the admin key
assert route.calls[0].request.headers["Authorization"] == "Bearer admin-xyz"
@respx.mock
async def test_403_scope_denied(self) -> None:
"""403 [error]: admin key lacks admin.sessions.read → SessionApiFailed(403)."""
respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_session_bifrost(client, "s1", admin_key="k")
assert exc.value.status == 403
@respx.mock
async def test_404_not_bound(self) -> None:
"""404 [error]: session_not_bifrost_bound → SessionApiFailed(404)."""
respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_bifrost_bound"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_session_bifrost(client, "s1", admin_key="k")
assert exc.value.status == 404
@respx.mock
async def test_empty_admin_key_asserts(self) -> None:
"""empty_admin_key [adversarial]: '' → AssertionError; no HTTP issued."""
route = respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
return_value=httpx.Response(200, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await get_session_bifrost(client, "s1", admin_key="")
assert route.call_count == 0
class TestTransientCharacters:
"""docs/contracts/issues/2.contract.md — transient-character wrappers (#161)."""
@respx.mock
async def test_list_models(self) -> None:
"""list_models [happy,tracer]: 200 → {items:[...]} verbatim."""
respx.get("https://w.example/models/available-for-characters").mock(
return_value=httpx.Response(200, json={"items": [{"name": "fast", "thinking": False}]})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
models = await list_character_models(client)
assert models["items"][0]["name"] == "fast"
@respx.mock
async def test_create_body_and_response(self) -> None:
"""create [happy]: body is {character, state}; 201 → {character_id, ttl_expires_at}."""
import json as _json
route = respx.post("https://w.example/characters").mock(
return_value=httpx.Response(201, json={"character_id": "char_x", "ttl_expires_at": "t"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
out = await create_character(client, {"schema_version": "1", "name": "H"})
assert out["character_id"] == "char_x"
body = _json.loads(route.calls[0].request.content)
assert body == {"character": {"schema_version": "1", "name": "H"}, "state": None}
@respx.mock
async def test_get_state(self) -> None:
"""get_state [happy]: 200 → live PAD/emotions snapshot."""
respx.get("https://w.example/characters/char_x/state").mock(
return_value=httpx.Response(200, json={"schema_version": "1", "pad": [0.4, 0.1, -0.2]})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
state = await get_character_state(client, "char_x")
assert state["pad"] == [0.4, 0.1, -0.2]
@respx.mock
async def test_delete_204(self) -> None:
"""delete [happy]: 204 → None."""
respx.delete("https://w.example/characters/char_x").mock(
return_value=httpx.Response(204)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
assert await delete_character(client, "char_x") is None
@respx.mock
async def test_create_403_scope(self) -> None:
"""create_403 [error]: key lacks character.write → SessionApiFailed(403)."""
respx.post("https://w.example/characters").mock(
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await create_character(client, {"name": "H"})
assert exc.value.status == 403
class TestSetPersonaState:
"""#2 contract — set_persona_state (POST /sessions/{id}/persona_state)."""
@respx.mock
async def test_happy_204(self) -> None:
"""happy [happy,tracer]: freeform snapshot body; 204 → None."""
import json as _json
route = respx.post("https://w.example/sessions/s1/persona_state").mock(
return_value=httpx.Response(204)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await set_persona_state(client, "s1", {"pad": [0.4, 0.1, -0.2]})
assert result is None
assert _json.loads(route.calls[0].request.content) == {"pad": [0.4, 0.1, -0.2]}
@respx.mock
async def test_non_204_raises(self) -> None:
"""non_204 [error]: 422 (bad snapshot shape) → SessionApiFailed(422)."""
respx.post("https://w.example/sessions/s1/persona_state").mock(
return_value=httpx.Response(422, json={"error_code": "validation_failed"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await set_persona_state(client, "s1", {"pad": [1, 2, 3]})
assert exc.value.status == 422
+72
View File
@@ -5,6 +5,7 @@ import pytest
import respx import respx
from ratatoskr.sse_client import ( from ratatoskr.sse_client import (
AdminEvent,
AffectUpdate, AffectUpdate,
AgentNotAvailable, AgentNotAvailable,
AwaitingLlmFirstToken, AwaitingLlmFirstToken,
@@ -27,6 +28,7 @@ from ratatoskr.sse_client import (
_parse_sse_id, _parse_sse_id,
cancel_turn, cancel_turn,
reconnect_turn, reconnect_turn,
stream_admin_events,
stream_turn, stream_turn,
stream_turn_resilient, stream_turn_resilient,
) )
@@ -1261,3 +1263,73 @@ class TestStreamTurnResilient:
collected.append(e) collected.append(e)
assert [e.sse_id for e in collected] == [SseId(42, 1)] # type: ignore[attr-defined] assert [e.sse_id for e in collected] == [SseId(42, 1)] # type: ignore[attr-defined]
assert route.call_count == 2 assert route.call_count == 2
class TestStreamAdminEvents:
"""docs/conversation-api-spec.md § Admin Event Stream — stream_admin_events (#11)."""
@respx.mock
async def test_happy_multi_event_admin_bearer(self) -> None:
"""happy [happy,tracer]: yields AdminEvent envelopes; request uses the ADMIN bearer."""
env1 = {
"id": 41, "type": "session.created", "timestamp": "2026-05-06T10:00:00.000Z",
"data": {"session_id": "s1", "agent_id": "mimir", "user_id": None},
}
env2 = {
"id": 42, "type": "turn.started", "timestamp": "2026-05-06T10:00:01.000Z",
"data": {"session_id": "s1", "turn_id": 7, "agent_id": "mimir", "user_id": None},
}
stream = _sse_chunk("41", env1) + _sse_chunk("42", env2)
route = respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=stream
)
)
async with httpx.AsyncClient(
base_url="https://w.example", headers={"Authorization": "Bearer consumer"}
) as client:
events = [e async for e in stream_admin_events(client, admin_key="admin-xyz")]
assert [e.type for e in events] == ["session.created", "turn.started"]
assert isinstance(events[0], AdminEvent)
assert events[0].id == 41
assert events[1].data["turn_id"] == 7
assert route.calls[0].request.headers["Authorization"] == "Bearer admin-xyz"
@respx.mock
async def test_last_event_id_header(self) -> None:
"""last_event_id_header [trace]: empty stream → []; Last-Event-ID header sent."""
route = respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=b""
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_admin_events(client, admin_key="k", last_event_id=99)]
assert events == []
assert route.calls[0].request.headers["Last-Event-ID"] == "99"
@respx.mock
async def test_403_scope_denied(self) -> None:
"""403 [error]: key lacks admin.events.read → SseConnectFailed(403)."""
respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SseConnectFailed) as exc:
_ = [e async for e in stream_admin_events(client, admin_key="k")]
assert exc.value.status == 403
@respx.mock
async def test_skips_malformed_frame(self) -> None:
"""skips_malformed [adversarial]: a bad-JSON frame is skipped, not fatal."""
good = _sse_chunk("41", {"id": 41, "type": "session.created", "data": {"session_id": "s1"}})
bad = b"id: 42\ndata: not-json\n\n"
good2 = _sse_chunk("43", {"id": 43, "type": "session.deleted", "data": {"session_id": "s1"}})
respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=good + bad + good2
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_admin_events(client, admin_key="k")]
assert [e.type for e in events] == ["session.created", "session.deleted"]
+492
View File
@@ -2945,3 +2945,495 @@ class TestTuiBifrostBind:
err = capsys.readouterr().err err = capsys.readouterr().err
assert "bifrost: status=bound" in err assert "bifrost: status=bound" in err
assert "plane=memory" in err assert "plane=memory" in err
class TestSessionPickerApp:
"""docs/contracts/issues/6.contract.md FN SessionPickerApp (amendment slice b2)."""
@staticmethod
def _two():
from ratatoskr.sessions import SessionInfo
return [
SessionInfo(
session_id="s-first-0001", agent_id="mimir", created_at="t0",
last_active="t1", metadata={}, message_count=3, name=None,
archived=False, tags=[],
),
SessionInfo(
session_id="s-second-002", agent_id="echo", created_at="t0",
last_active="t2", metadata={}, message_count=None, name="probe",
archived=False, tags=[],
),
]
def test_pick_returns_session_id(self) -> None:
"""pick_returns_session_id [happy,tracer]: idx 1 + Enter → exit value == that session_id."""
from ratatoskr.tui import SessionPickerApp
app = SessionPickerApp(self._two())
async def drive() -> str | None:
async with app.run_test() as pilot:
from textual.widgets import ListView
lv = app.query_one("#session-list", ListView)
lv.index = 1
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
return app.return_value
import asyncio
assert asyncio.run(drive()) == "s-second-002"
def test_esc_returns_none(self) -> None:
"""esc_returns_none [happy]: Esc → exit value is None (dismiss, resume nothing)."""
from ratatoskr.tui import SessionPickerApp
app = SessionPickerApp(self._two())
async def drive() -> str | None:
async with app.run_test() as pilot:
await pilot.press("escape")
await pilot.pause()
return app.return_value
import asyncio
assert asyncio.run(drive()) is None
def test_ctrl_d_returns_none(self) -> None:
"""ctrl_d_returns_none [adversarial]: Ctrl-D → None."""
from ratatoskr.tui import SessionPickerApp
app = SessionPickerApp(self._two())
async def drive() -> str | None:
async with app.run_test() as pilot:
await pilot.press("ctrl+d")
await pilot.pause()
return app.return_value
import asyncio
assert asyncio.run(drive()) is None
class TestBareSessionPicker:
"""docs/contracts/issues/6.contract.md amendment (slice b2): _resolve_then_run bare mode."""
@staticmethod
def _bare_args() -> ParsedArgs:
return ParsedArgs(
send_content=None, session_id=None, new=False, agent_id=None,
api_key="k", server_url="https://w.example", raw=False,
end_user_id=None, bifrost=None, bifrost_plane=None, consumer_key=None,
)
@staticmethod
def _sess(sid: str, agent: str = "mimir"):
from ratatoskr.sessions import SessionInfo
return SessionInfo(
session_id=sid, agent_id=agent, created_at="t0", last_active="t1",
metadata={}, message_count=1, name=None, archived=False, tags=[],
)
def test_bare_zero_sessions_errors(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""bare_zero_sessions_errors [error]: 0 sessions → exit 14 [no_sessions]; App not opened."""
import ratatoskr.tui as tui_mod
from ratatoskr.sessions import SessionPage
async def fake_list(client, **kw):
return SessionPage(items=[], next_cursor=None)
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
opened: list[int] = []
async def spy(self, *a, **k):
opened.append(1)
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", spy)
rc = run_tui(self._bare_args())
assert rc == 14
assert "[no_sessions]" in capsys.readouterr().err
assert not opened
def test_bare_one_session_auto_resumes(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""bare_one_session_auto_resumes: exactly 1 → auto-resume, no picker (§4 >1 rule)."""
import ratatoskr.tui as tui_mod
from ratatoskr.sessions import SessionPage
from ratatoskr.tui import SessionPickerApp
async def fake_list(client, **kw):
return SessionPage(items=[self._sess("s-solo")], next_cursor=None)
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
picker_used: list[int] = []
async def spy_picker(self, *a, **k):
picker_used.append(1)
return None
monkeypatch.setattr(SessionPickerApp, "run_async", spy_picker)
snap: dict = {}
async def cap(self, *a, **k):
snap["sid"] = self.session_id
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", cap)
rc = run_tui(self._bare_args())
assert rc == 0
assert snap["sid"] == "s-solo"
assert not picker_used
def test_bare_multi_opens_picker(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""bare_multi_opens_picker [scenario,tracer]: >1 → picker; its choice resumes."""
import ratatoskr.tui as tui_mod
from ratatoskr.sessions import SessionPage
from ratatoskr.tui import SessionPickerApp
async def fake_list(client, **kw):
return SessionPage(items=[self._sess("s-a"), self._sess("s-b")], next_cursor=None)
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
async def pick_b(self, *a, **k):
return "s-b"
monkeypatch.setattr(SessionPickerApp, "run_async", pick_b)
snap: dict = {}
async def cap(self, *a, **k):
snap["sid"] = self.session_id
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", cap)
rc = run_tui(self._bare_args())
assert rc == 0
assert snap["sid"] == "s-b"
def test_bare_picker_dismiss_exits_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""bare_picker_dismiss_exits_zero [scenario]: picker None → exit 0; App not opened."""
import ratatoskr.tui as tui_mod
from ratatoskr.sessions import SessionPage
from ratatoskr.tui import SessionPickerApp
async def fake_list(client, **kw):
return SessionPage(items=[self._sess("s-a"), self._sess("s-b")], next_cursor=None)
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
async def pick_none(self, *a, **k):
return None
monkeypatch.setattr(SessionPickerApp, "run_async", pick_none)
opened: list[int] = []
async def spy(self, *a, **k):
opened.append(1)
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", spy)
rc = run_tui(self._bare_args())
assert rc == 0
assert not opened
def test_bare_list_sessions_api_failure(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""bare_list_sessions_api_failure [error]: list_sessions 500 → exit 20; App not opened."""
import ratatoskr.tui as tui_mod
from ratatoskr.sessions import SessionApiFailed
async def fake_list(client, **kw):
raise SessionApiFailed(status=500, body=b"boom")
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
opened: list[int] = []
async def spy(self, *a, **k):
opened.append(1)
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", spy)
rc = run_tui(self._bare_args())
assert rc == 20
assert "[session_api_failed]" in capsys.readouterr().err
assert not opened
class TestSessionToolsHydration:
"""get_session_tools + the #183 Tools-pane inventory hydrate (GET /sessions/{id}/tools)."""
def test_format_tool_inventory(self) -> None:
"""format_tool_inventory [unit]: header + builtin + bifrost lines."""
from ratatoskr.tui import _format_tool_inventory
lines = _format_tool_inventory(
{
"agent_id": "alice:wizard",
"builtin_tools": [],
"bifrost_tools": [{"name": "bifrost.x"}, {"name": "bifrost.y"}],
}
)
joined = "\n".join(lines)
assert "agent=alice:wizard" in joined
assert "builtin=0 bifrost=2" in joined
assert "builtin: (none)" in joined
assert "bifrost.x, bifrost.y" in joined
async def test_hydrate_writes_inventory_and_audits(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""hydrate_writes_inventory [scenario,tracer]: 200 → inventory in Tools pane + audit."""
import ratatoskr.tui as tui_mod
writes = _spy_writes(monkeypatch)
async def fake_tools(client, session_id):
return {
"agent_id": "alice:wizard",
"builtin_tools": [],
"bifrost_tools": [{"name": "bifrost.set_field"}],
}
monkeypatch.setattr(tui_mod, "get_session_tools", fake_tools)
app = _resolved_app(_args_existing(session_id="s-tools-01"))
async with app.run_test() as pilot:
await pilot.pause()
await app._hydrate_session_tools()
await pilot.pause()
joined = " ".join(_text_of(w) for w in writes)
assert "session tool inventory" in joined
assert "bifrost.set_field" in joined
assert "session_tools_hydrated" in joined # audit line landed
async def test_hydrate_failure_audits_no_crash(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""hydrate_failure [error]: get_session_tools raises → failure audit; no crash."""
import ratatoskr.tui as tui_mod
from ratatoskr.sessions import SessionApiFailed
writes = _spy_writes(monkeypatch)
async def boom(client, session_id):
raise SessionApiFailed(status=404, body=b"session_not_found")
monkeypatch.setattr(tui_mod, "get_session_tools", boom)
app = _resolved_app(_args_existing(session_id="s-tools-02"))
async with app.run_test() as pilot:
await pilot.pause()
await app._hydrate_session_tools()
await pilot.pause()
joined = " ".join(_text_of(w) for w in writes)
assert "session_tools_hydration_failed" in joined
class TestBifrostStateHydration:
"""get_session_bifrost + the #176 BifrostState pane (GET /admin/sessions/{id}/bifrost)."""
@staticmethod
def _mute_tools(monkeypatch: pytest.MonkeyPatch) -> None:
"""Neutralize the on_mount Tools-pane worker so it makes no real call."""
import ratatoskr.tui as tui_mod
async def noop(client, session_id):
return {"agent_id": "x", "builtin_tools": [], "bifrost_tools": []}
monkeypatch.setattr(tui_mod, "get_session_tools", noop)
def test_format_bifrost_state(self) -> None:
"""format_bifrost_state [unit]: connected / endpoint / caps / tools lines."""
from ratatoskr.tui import _format_bifrost_state
lines = _format_bifrost_state(
{
"endpoint_url": "https://b/mcp",
"consumer_id": "alice",
"connected": True,
"capabilities_granted": ["tools:call", "tools:read"],
"tools": [{"name": "bifrost.echo"}],
}
)
joined = "\n".join(lines)
assert "connected=True" in joined
assert "consumer=alice" in joined
assert "https://b/mcp" in joined
assert "tools:call, tools:read" in joined
assert "bifrost.echo" in joined
async def test_hydrate_no_admin_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""hydrate_no_admin_key [scenario]: admin_key None → 'not configured' + skip audit."""
self._mute_tools(monkeypatch)
writes = _spy_writes(monkeypatch)
app = _resolved_app(_args_existing(session_id="s-bf-1")) # admin_key defaults None
async with app.run_test() as pilot:
await pilot.pause()
await app._hydrate_bifrost_state()
await pilot.pause()
joined = " ".join(_text_of(w) for w in writes)
assert "admin key not configured" in joined
assert "bifrost_state_skipped" in joined
async def test_hydrate_success(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""hydrate_success [scenario,tracer]: 200 → binding in BifrostState pane + audit."""
import ratatoskr.tui as tui_mod
self._mute_tools(monkeypatch)
writes = _spy_writes(monkeypatch)
async def fake_bifrost(client, session_id, *, admin_key):
return {
"endpoint_url": "https://b/mcp",
"consumer_id": "alice",
"connected": True,
"capabilities_granted": ["tools:call"],
"tools": [{"name": "bifrost.echo"}],
}
monkeypatch.setattr(tui_mod, "get_session_bifrost", fake_bifrost)
app = _resolved_app(_args_existing(session_id="s-bf-2", admin_key="ak"))
async with app.run_test() as pilot:
await pilot.pause()
await app._hydrate_bifrost_state()
await pilot.pause()
joined = " ".join(_text_of(w) for w in writes)
assert "bifrost binding" in joined
assert "bifrost.echo" in joined
assert "bifrost_state_hydrated" in joined
async def test_hydrate_404_not_bound(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""hydrate_404_not_bound [error]: 404 → 'not bound to Bifrost' + audit; no crash."""
import ratatoskr.tui as tui_mod
from ratatoskr.sessions import SessionApiFailed
self._mute_tools(monkeypatch)
writes = _spy_writes(monkeypatch)
async def not_bound(client, session_id, *, admin_key):
raise SessionApiFailed(status=404, body=b"session_not_bifrost_bound")
monkeypatch.setattr(tui_mod, "get_session_bifrost", not_bound)
app = _resolved_app(_args_existing(session_id="s-bf-3", admin_key="ak"))
async with app.run_test() as pilot:
await pilot.pause()
await app._hydrate_bifrost_state()
await pilot.pause()
joined = " ".join(_text_of(w) for w in writes)
assert "not bound to Bifrost" in joined
assert "bifrost_state_unavailable" in joined
class TestAdminEventsStream:
"""stream_admin_events + the #11 AdminEvents pane (GET /admin/events, session-filtered)."""
@staticmethod
def _mute_hydrates(monkeypatch: pytest.MonkeyPatch) -> None:
"""Neutralize the other on_mount workers (tools + bifrost) — no real calls."""
import ratatoskr.tui as tui_mod
from ratatoskr.sessions import SessionApiFailed
async def noop_tools(client, session_id):
return {"agent_id": "x", "builtin_tools": [], "bifrost_tools": []}
async def noop_bifrost(client, session_id, *, admin_key):
raise SessionApiFailed(status=404, body=b"nb")
monkeypatch.setattr(tui_mod, "get_session_tools", noop_tools)
monkeypatch.setattr(tui_mod, "get_session_bifrost", noop_bifrost)
def test_format_admin_event(self) -> None:
"""format_admin_event [unit]: HH:MM:SS + type + fields; session_id dropped."""
from ratatoskr.sse_client import AdminEvent
from ratatoskr.tui import _format_admin_event
line = _format_admin_event(
AdminEvent(
42, "turn.completed", "2026-05-06T10:00:05.000Z",
{"session_id": "s1", "turn_id": 7, "duration_ms": 1200, "phase": "succeeded"},
)
)
assert "turn.completed" in line
assert "[10:00:05]" in line
assert "turn_id=7" in line
assert "session_id" not in line # dropped — pane is already session-scoped
def test_admin_event_matches_filter(self) -> None:
"""admin_event_matches [unit]: active-session + non-heartbeat system.* pass (§6)."""
from ratatoskr.sse_client import AdminEvent
E = AdminEvent
app = _resolved_app(_args_existing(session_id="s-match"))
assert app._admin_event_matches(E(1, "session.created", "t", {"session_id": "s-match"}))
assert not app._admin_event_matches(E(2, "turn.started", "t", {"session_id": "other"}))
assert not app._admin_event_matches(E(0, "system.heartbeat", "t", {}))
assert app._admin_event_matches(E(3, "system.events_dropped", "t", {"count": 5}))
async def test_stream_writes_filtered_events(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""stream_filtered [scenario,tracer]: only active-session + non-heartbeat lines land."""
import ratatoskr.tui as tui_mod
from ratatoskr.sse_client import AdminEvent
self._mute_hydrates(monkeypatch)
writes = _spy_writes(monkeypatch)
async def fake_stream(client, *, admin_key, last_event_id=None):
yield AdminEvent(41, "session.created", "t", {"session_id": "s-ae-2"})
yield AdminEvent(0, "system.heartbeat", "t", {}) # filtered (noise)
yield AdminEvent(42, "turn.started", "t", {"session_id": "other"}) # diff session
yield AdminEvent(43, "session.deleted", "t", {"session_id": "s-ae-2"})
monkeypatch.setattr(tui_mod, "stream_admin_events", fake_stream)
app = _resolved_app(_args_existing(session_id="s-ae-2", admin_key="ak"))
async with app.run_test() as pilot:
await pilot.pause()
await app._stream_admin_events()
await pilot.pause()
joined = " ".join(_text_of(w) for w in writes)
assert "session.created" in joined
assert "session.deleted" in joined
assert "system.heartbeat" not in joined
assert "turn.started" not in joined # different session → filtered
async def test_stream_no_admin_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""stream_no_admin_key [scenario]: admin_key None → 'not configured' + skip audit."""
self._mute_hydrates(monkeypatch)
writes = _spy_writes(monkeypatch)
app = _resolved_app(_args_existing(session_id="s-ae-3")) # admin_key None
async with app.run_test() as pilot:
await pilot.pause()
await app._stream_admin_events()
await pilot.pause()
joined = " ".join(_text_of(w) for w in writes)
assert "admin key not configured" in joined
assert "admin_events_skipped" in joined
async def test_stream_403_unavailable(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""stream_403 [error]: 403 scope-denied → 'unavailable' + audit; no crash."""
import ratatoskr.tui as tui_mod
from ratatoskr.sse_client import SseConnectFailed
self._mute_hydrates(monkeypatch)
writes = _spy_writes(monkeypatch)
async def denied(client, *, admin_key, last_event_id=None):
raise SseConnectFailed(status=403, body=b"auth_scope_denied")
yield # unreachable — makes this an async generator
monkeypatch.setattr(tui_mod, "stream_admin_events", denied)
app = _resolved_app(_args_existing(session_id="s-ae-4", admin_key="ak"))
async with app.run_test() as pilot:
await pilot.pause()
await app._stream_admin_events()
await pilot.pause()
joined = " ".join(_text_of(w) for w in writes)
assert "admin events unavailable: HTTP 403" in joined
assert "admin_events_unavailable" in joined
+120
View File
@@ -1062,3 +1062,123 @@ class TestAffectStateEndpoint:
resp = TestClient(app).get("/api/affect/ratatoskr:sindra") resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
assert resp.status_code == 400 assert resp.status_code == 400
assert resp.json()["error_code"] == "missing_end_user_id" 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]] [[package]]
name = "ratatoskr" name = "ratatoskr"
version = "0.18.5" version = "0.19.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },