Compare commits

..

15 Commits

Author SHA1 Message Date
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
vh 0c7660791f feat(#1): shared SSE resume orchestration; wire cli --send
v1 coverage-audit slice b1. The audit found reconnect_turn had no
caller — every presenter dropped the stream on disconnect instead of
resuming, leaving the "reference SSE-resume implementation" (design-
brief §3/§8d) unreachable. Add stream_turn_resilient as the single
shared resume surface (design-brief §8b "share the consumer, branch
the presenter") and route cli --send through it.

- stream_turn_resilient wraps stream_turn + reconnect_turn: on
  SseConnectionDropped (mid-stream drop or clean EOF before terminal),
  resume from the last-seen sse_id via reconnect_turn (Last-Event-ID),
  up to max_reconnects (default 5). last_seen persists across attempts.
- Non-drop reconnect failures (412/410/400/TurnIdFlip/SseConnectFailed)
  propagate unchanged, per contract #1's "surface, not recover".
- cli.py: --send consumer now drives stream_turn_resilient (transparent
  reconnect). tui/web still consume bare stream_turn (follow-up).
- Contract #1 amended (FN stream_turn_resilient) + validated; 8 TDD
  cases (happy, resume-after-1/2-drops, clean-EOF resume, unresumable
  zero-event, max-reconnects-exhausted, zero-budget, buffer-expired-
  propagates). Suite 518 green; ruff + mypy clean on touched code.
2026-06-30 15:42:33 -07:00
vh 1f289098ba memory: scope (b) Tier-1 frontier — SSE-resume + session-picker slices
Capture the contract-first plan for the two presenter-wiring gaps
(resume-orchestration wrapper per design-brief 8b; picker + CLI flags
per 4) so the next focused TDD cycle has the slice plan resident.
2026-06-30 15:32:15 -07:00
vh b798068932 pin: re-pin to Worldtree's FROZEN v1 surface (OpenAPI 2.2.0 + SSE schema)
v1 coverage-audit remediation P-1: vendor the authoritative machine-
readable artifacts and pin them for drift-checking, advancing the spec
pin from v0.35.16 (f1b59f8) to v1.0.0b2 (5810a26).

- Vendor docs/conversation-api-openapi.json (OpenAPI 2.2.0, 40 path-
  groups) + docs/conversation-api-sse-events.schema.json (11 events).
- Pin all three Conversation-API artifacts in .corviduo-canonicals.toml:
  OpenAPI + SSE schema as strict drift gates (canonical_drift.py), the
  prose markdown as tolerate_drift reference. Drift check green (10/10).
- pyproject: worldtree-spec-rev -> 5810a26, worldtree-version -> v1.0.0b2
  (was stale at v0.29.0), pinned-on -> 2026-06-30.
- SPEC-PIN.md: current-pin table + history row + vendored-artifacts list.
- coverage-map.md: P-1 marked remediated; the map now audits a frozen,
  diffable target.

The prose markdown is byte-identical to v0.35.16 (last WT edit
2026-05-31); the b2 surface lives only in the OpenAPI. No client-
facing code change (the b2 409/503 + unified error envelope were
already consumed in v0.18.3/.4) -> pin-only, no version bump.
2026-06-30 15:28:52 -07:00
vh 93e4176346 docs: author v1 coverage-map ledger; lock scope mandate A
First coverage map — every Worldtree v1-FROZEN I/O point x ratatoskr
status. Anchored on WT's frozen machine-readable artifacts (OpenAPI
2.2.0 = 40 REST path-groups + SSE schema = 11 events + bifrost wire
v0.6), not the stale vendored prose markdown.

- SSE 11/11 and Bifrost provider planes 8/8 covered + live-proven;
  client REST 7/40 live, 11 in-scope frontier, 22 excluded-by-design.
- Scope mandate A (operator): v1 done = every frozen point classified
  (covered-or-excluded-with-rationale), zero unaccounted; not a
  feature-complete client.
- Finding P-1: vendored prose markdown is byte-identical to live WT
  but frozen at v0.35.16-era content; b2's surface lives in the
  OpenAPI 2.2.0 + SSE-schema JSON we don't vendor. Pin-remediation
  pending operator nod.

No version bump (docs-only).
2026-06-30 14:41:06 -07:00
vh a358cc9150 memory: snapshot — b1→b2 409/503 adaptation + bifrost 1.0.0 + combined-bind default + admin key + regard-dead-axis finding
Worldtree on v1.0.0b2 (both demo + personal); ratatoskr v0.18.4 all pushed.
Session arc: web combined-bind default (v0.18.1), bifrost 1.0.0 repin
(v0.18.2), b1/b2 eager 409/503 status mapping (v0.18.3/.4), readonly-admin
key collected (#11 prereq cleared), and the regard-dead-axis finding
(provider-side catch -> worldtree-dev escalating to Vuong). Next substantive
effort = the v1 coverage-audit (folds in the deferred live-409 + b2 spec
re-vendor).
2026-06-30 13:57:04 -07:00
vh e4317f6a73 fix: pin the eager-503 default error_code to not_ready (Worldtree b2)
worldtree-dev finalized the #331 503 turn-launch error_code as `not_ready`
(re-pinned from internal_error; retryable, matching the /readyz 503 sense)
and re-froze the OpenAPI at 2.2.0 documenting the 409/503 statuses our
v0.18.3 mapping already handles. Tighten our fallback default from the
placeholder `turn_launch_unavailable` to the canonical `not_ready` (the
default only fires when the body omits error_code — the real code is
surfaced verbatim regardless). +1 test, suite 510 green.

NOTE: a full conversation-api-spec.md re-vendor to the b2 era is a separate,
larger pin-refresh (ratatoskr vendors the markdown spec, not the OpenAPI
JSON) — deferred, to bundle with the v1 coverage-audit / when personal is on b2.
2026-06-30 13:26:14 -07:00
vh b2e4901264 feat: map Worldtree b1 eager turn-launch statuses (409/503) in stream_turn
Worldtree v1.0.0b1 (#331) decoupled turn execution from the SSE connection,
so turn-launch failures now arrive EAGERLY as an HTTP status before any
stream: 409 agent_not_available (pre-b1 was a 200 + in-stream error event)
and 503 (retryable turn-launch / infra failure). stream_turn previously
funneled both into a generic SseConnectFailed.

Map them to typed SseConnectFailed subclasses — AgentNotAvailable (409) and
TurnLaunchUnavailable (503, retryable=True) — carrying the parsed
error_code/message from the {detail:{error_code,message}} envelope.
Subclassing keeps existing `except SseConnectFailed` handlers working with
zero changes (POST-003 preserved — no synthetic event yielded; raise mirrors
reconnect_turn's 400/410/412 pattern).

worldtree-dev confirmed 409/503 are real runtime statuses; the OpenAPI 2.1.0
gap (not enumerating them) is theirs to fix (doc-completeness, not a wire
break). The 503 error_code is being re-pinned upstream (today internal_error
-> likely not_ready); our handling keys on STATUS so it's robust to the final
code — tighten the 503 default once they confirm.

Body shape live-confirmed against demo b1's 404/401 responses. Suite 509 green.
Contract docs/contracts/issues/1.contract.md updated.
2026-06-30 13:11:33 -07:00
20 changed files with 10631 additions and 45 deletions
+34
View File
@@ -89,3 +89,37 @@ canonical_path = "scripts/contract_drift_check.py"
consumer_path = "scripts/contract_drift_check.py" consumer_path = "scripts/contract_drift_check.py"
pinned_sha256_16 = "23271287ac488da4" pinned_sha256_16 = "23271287ac488da4"
pinned_at = "2026-05-17T05:30:00+00:00" pinned_at = "2026-05-17T05:30:00+00:00"
# ---------------------------------------------------------------------------
# Worldtree Conversation-API surface (vendored from ~/development/Worldtree).
# The v1 wire is FROZEN (Worldtree #326 / v1-schema-freeze-manifest.md). The
# machine-readable OpenAPI + SSE schema are the authoritative drift GATES; the
# prose markdown is the human reference and is allowed to lag (tolerate_drift).
# These are what ratatoskr's v1 coverage map (docs/coverage-map.md) audits
# against. Pin target: Worldtree 5810a26 (v1.0.0b2).
# ---------------------------------------------------------------------------
[[pins]]
id = "worldtree-conversation-api-openapi-v2"
canonical_source = "Worldtree"
canonical_path = "docs/conversation-api-openapi.json"
consumer_path = "docs/conversation-api-openapi.json"
pinned_sha256_16 = "dbdf4e24c8b06c92"
pinned_at = "2026-06-30T22:25:56+00:00"
[[pins]]
id = "worldtree-conversation-api-sse-events-v1"
canonical_source = "Worldtree"
canonical_path = "docs/conversation-api-sse-events.schema.json"
consumer_path = "docs/conversation-api-sse-events.schema.json"
pinned_sha256_16 = "9deeebf404d72f9a"
pinned_at = "2026-06-30T22:25:56+00:00"
[[pins]]
id = "worldtree-conversation-api-spec-v1"
canonical_source = "Worldtree"
canonical_path = "docs/conversation-api-spec.md"
consumer_path = "docs/conversation-api-spec.md"
pinned_sha256_16 = "2d111a3b8322b7d1"
pinned_at = "2026-06-30T22:25:56+00:00"
tolerate_drift = true # prose reference; OpenAPI+SSE are the gates
+15 -7
View File
@@ -7,24 +7,32 @@ documents the pin, the vendored artifacts, and the bump procedure.
| Field | Value | | Field | Value |
|---|---| |---|---|
| Worldtree git SHA | `f1b59f8cd6fe41e497d0be9dad9d3110451f0d9a` | | Worldtree git SHA | `5810a26b38a5ea6630892f9a39756f57c5b7b41e` |
| Worldtree HEAD message | `Merge #299: adopt bifrost v0.6 memory scope wire (scope_any/scope_all)` | | Worldtree HEAD message | `memory: snapshot — v1.0.0b2 shipped complete (demo + personal green); consumer loop closed` |
| Pinned on | 2026-06-17 | | Pinned on | 2026-06-30 |
| Pinned by | ratatoskr-dev (bump for #297/#298 — cold recall closed end-to-end) | | Pinned by | ratatoskr-dev (v1 coverage-audit — re-pin to the FROZEN OpenAPI 2.2.0 + SSE schema) |
| Worldtree version at pin | `v0.35.16` | | Worldtree version at pin | `v1.0.0b2` |
## Pin history ## Pin history
| Date | SHA | Version | Notable deltas consumed | | Date | SHA | Version | Notable deltas consumed |
|---|---|---|---| |---|---|---|---|
| 2026-06-30 | `5810a26` | v1.0.0b2 | **Re-pin to Worldtree's FROZEN v1 surface (#326), as part of the v1 coverage-audit.** Vendored the machine-readable artifacts — `conversation-api-openapi.json` (OpenAPI **2.2.0**, 40 path-groups) + `conversation-api-sse-events.schema.json` (11 events) — now the **authoritative drift gates** (pinned in `.corviduo-canonicals.toml`, CI-checked by `canonical_drift.py`). The prose `conversation-api-spec.md` is **byte-identical** to the v0.35.16 pin (last WT markdown edit 2026-05-31), kept as the human reference (`tolerate_drift`). b2 deltas already consumed in code: 409/503 eager turn-launch statuses (#331, v0.18.3/.4) + the unified error envelope (#328). 7 endpoints documented only in the OpenAPI, not the prose, all classified in `docs/coverage-map.md`: `admin/keys/bulk`, `admin/persona/{archive,erase}`, `admin/usage`, `embed`, `judgments`, `me/usage`. No client-breaking change — `pin:`-only, no version bump. |
| 2026-06-17 | `f1b59f8` | v0.35.16 | **#297 + #298/#299 — Worldtree adopts the bifrost v0.6 scope wire (emits `scope_any`/`scope_all`) + client-side per-scope-value union recall. With our v0.17.6 provider this closes cold cross-session recall end-to-end.** Catch-up bump (v0.29.0→v0.35.16). Intervening client-facing deltas reviewed, none break our consumer: #211 agent rename (`saga``echo`, `actor``mask` — slugs only); #245 `end_user_id` persistence + memory-scope resolver; #187/#188/#219 Tier-3 define/PATCH policy (additive); `bifrost` binding field + `ephemeral_does_not_accept_bifrost` 422 now documented (the #17 surface). Error codes stable; no ratatoskr code change required. | | 2026-06-17 | `f1b59f8` | v0.35.16 | **#297 + #298/#299 — Worldtree adopts the bifrost v0.6 scope wire (emits `scope_any`/`scope_all`) + client-side per-scope-value union recall. With our v0.17.6 provider this closes cold cross-session recall end-to-end.** Catch-up bump (v0.29.0→v0.35.16). Intervening client-facing deltas reviewed, none break our consumer: #211 agent rename (`saga``echo`, `actor``mask` — slugs only); #245 `end_user_id` persistence + memory-scope resolver; #187/#188/#219 Tier-3 define/PATCH policy (additive); `bifrost` binding field + `ephemeral_does_not_accept_bifrost` 422 now documented (the #17 surface). Error codes stable; no ratatoskr code change required. |
| 2026-05-25 | `da93ca7` | v0.28.0 | #204 — new SSE event `affect_update` (current/scheduled), new endpoint `GET /agents/{id}/persona_state`, auth-model doc edits | | 2026-05-25 | `da93ca7` | v0.28.0 | #204 — new SSE event `affect_update` (current/scheduled), new endpoint `GET /agents/{id}/persona_state`, auth-model doc edits |
| 2026-05-20 | `55101e9` | v0.19.0 | initial scaffold pin | | 2026-05-20 | `55101e9` | v0.19.0 | initial scaffold pin |
## Vendored artifacts ## Vendored artifacts
- `docs/conversation-api-spec.md` — copy of `Worldtree/docs/conversation-api-spec.md` at the pinned SHA. This is the **client-facing interface contract** Ratatoskr is built against. **Authoritative (FROZEN, machine-readable — the drift gates):**
- `docs/conversation_api.contract.md` — copy of `Worldtree/docs/contracts/conversation_api.contract.md` at the pinned SHA. The **server-side contract** including INV-001..INV-052 and amendments. Useful for understanding load-bearing server invariants (e.g., INV-014 turn-id-public, INV-046 admin-events-envelope-stable, INV-049 admin-events-pii-discipline) when designing client behavior against them.
- `docs/conversation-api-openapi.json` — copy of `Worldtree/docs/conversation-api-openapi.json` (OpenAPI `info.version` **2.2.0**). The frozen v1 REST wire (40 path-groups). Pinned `worldtree-conversation-api-openapi-v2` in `.corviduo-canonicals.toml`; drift gated by `canonical_drift.py`.
- `docs/conversation-api-sse-events.schema.json` — copy of `Worldtree/docs/conversation-api-sse-events.schema.json`. The frozen SSE event schema (11 discriminated event types). Pinned `worldtree-conversation-api-sse-events-v1`.
**Reference (prose; allowed to lag — `tolerate_drift`):**
- `docs/conversation-api-spec.md` — copy of `Worldtree/docs/conversation-api-spec.md` at the pinned SHA. The **client-facing prose narrative**. Byte-frozen at v0.35.16-era content (last WT edit 2026-05-31); the OpenAPI/SSE JSON above are the source of truth where they diverge. Pinned `worldtree-conversation-api-spec-v1` (tolerate_drift).
- `docs/conversation_api.contract.md` — copy of `Worldtree/docs/contracts/conversation_api.contract.md` at the pinned SHA (byte-identical at b2 — server contract unchanged since the v0.35.16 pin). The **server-side contract** including INV-001..INV-052 and amendments. Useful for understanding load-bearing server invariants (e.g., INV-014 turn-id-public, INV-046 admin-events-envelope-stable, INV-049 admin-events-pii-discipline) when designing client behavior against them. Not in the canonical manifest (reference-only).
Both files are vendored — they reflect Worldtree at the pinned SHA, not Both files are vendored — they reflect Worldtree at the pinned SHA, not
the live `~/development/Worldtree` checkout. Update them only when the live `~/development/Worldtree` checkout. Update them only when
+54 -2
View File
@@ -106,7 +106,7 @@ POST: [POST-002 return_value] AsyncIterator yields ≥1 event ending in exactly
POST: [POST-003 state_change] every yielded Event has a populated sse_id with both fields >= 1 -- assert all(e.sse_id.turn_id >= 1 and e.sse_id.seq >= 1 for e in events) POST: [POST-003 state_change] every yielded Event has a populated sse_id with both fields >= 1 -- assert all(e.sse_id.turn_id >= 1 and e.sse_id.seq >= 1 for e in events)
ERROR_ROUTING: ERROR_ROUTING:
httpx.HTTPStatusError: httpx.HTTPStatusError:
local_handling: re-raise as SseConnectFailed(status=resp.status_code, body=resp.read()[:1024]) — server returned non-2xx before stream started (e.g., 404 session_not_found) local_handling: re-raise as SseConnectFailed(status=resp.status_code, body=resp.read()[:1024]) — server returned non-2xx before stream started (e.g., 404 session_not_found). EXCEPT the two eager turn-launch failures (Worldtree v1.0.0b1 #331), checked BEFORE raise_for_status and raised as typed SseConnectFailed SUBCLASSES carrying error_code: 409 -> AgentNotAvailable (agent unavailable; pre-b1 this was a 200 + in-stream `error` event), 503 -> TurnLaunchUnavailable (transient turn-launch failure; retryable=True). Subclassing keeps existing `except SseConnectFailed` handlers working with zero changes.
flow_control: abort flow_control: abort
state_recovery: none (no events yielded yet) state_recovery: none (no events yielded yet)
httpx.ReadError | httpx.RemoteProtocolError | httpx.ReadTimeout: httpx.ReadError | httpx.RemoteProtocolError | httpx.ReadTimeout:
@@ -129,7 +129,8 @@ ERROR_ROUTING:
STEPS: STEPS:
1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-003 1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-003
2. [sequential, flexibility=prescriptive] Open SSE connection via httpx_sse.aconnect_sse with method="POST", url=f"/sessions/{session_id}/messages", json={"content": content} 2. [sequential, flexibility=prescriptive] Open SSE connection via httpx_sse.aconnect_sse with method="POST", url=f"/sessions/{session_id}/messages", json={"content": content}
ON httpx.HTTPStatusError before stream opens: 2a. [branch, flexibility=prescriptive] IF response.status_code in (409, 503) (b1 #331 eager turn-launch failures): read the body, parse (error_code, message) from the `{"detail": {...}}` envelope OR a flat `{error_code, message}` body (status-derived default code when absent), then RAISE AgentNotAvailable (409) / TurnLaunchUnavailable (503).
ON httpx.HTTPStatusError before stream opens (any other non-2xx):
RAISE SseConnectFailed RAISE SseConnectFailed
3. [loop, flexibility=prescriptive] FOR EACH sse_event in event_source.aiter_sse(): 3. [loop, flexibility=prescriptive] FOR EACH sse_event in event_source.aiter_sse():
0. [branch, flexibility=prescriptive] IF sse_event.data == "": 0. [branch, flexibility=prescriptive] IF sse_event.data == "":
@@ -162,6 +163,9 @@ TESTS:
error_terminal [error]: mock emits one `text` then `error` with `error_code: "llm_output_invalid"` → consumer yields Text then Error; iteration ends; Error.message and Error.error_code are populated error_terminal [error]: mock emits one `text` then `error` with `error_code: "llm_output_invalid"` → consumer yields Text then Error; iteration ends; Error.message and Error.error_code are populated
cancelled_terminal [error]: mock emits `cancelled` with phase=cancelled → consumer yields Cancelled with turn_id; iteration ends cancelled_terminal [error]: mock emits `cancelled` with phase=cancelled → consumer yields Cancelled with turn_id; iteration ends
session_not_found [error]: mock returns 404 before stream opens → consumer raises SseConnectFailed(status=404) session_not_found [error]: mock returns 404 before stream opens → consumer raises SseConnectFailed(status=404)
eager_409_agent_not_available [error]: mock returns 409 {detail:{error_code:"agent_not_available", message}} before stream → consumer raises AgentNotAvailable(status=409, error_code="agent_not_available", retryable absent); isinstance SseConnectFailed
eager_503_retryable [error]: mock returns 503 before stream → consumer raises TurnLaunchUnavailable(status=503, retryable=True); isinstance SseConnectFailed
eager_409_non_json_body [adversarial]: mock returns 409 with a non-JSON body → consumer raises AgentNotAvailable with the status-derived default error_code "agent_not_available"
malformed_id_no_seq [adversarial]: mock event has `id: 42` (missing `:seq`) → consumer raises MalformedSseId; no event yielded malformed_id_no_seq [adversarial]: mock event has `id: 42` (missing `:seq`) → consumer raises MalformedSseId; no event yielded
malformed_id_alpha [adversarial]: mock event has `id: foo:bar` (non-integer parts) → consumer raises MalformedSseId malformed_id_alpha [adversarial]: mock event has `id: foo:bar` (non-integer parts) → consumer raises MalformedSseId
turn_id_flip [adversarial]: mock emits text events with ids `42:1` then `99:2` → consumer raises TurnIdFlip; only the first event was yielded turn_id_flip [adversarial]: mock emits text events with ids `42:1` then `99:2` → consumer raises TurnIdFlip; only the first event was yielded
@@ -293,3 +297,51 @@ TESTS:
trailing_whitespace [adversarial]: "42:3 " → ValueError (strict; do not strip; the server emits clean ids) trailing_whitespace [adversarial]: "42:3 " → ValueError (strict; do not strip; the server emits clean ids)
truncation [security]: input is 5000-char string with no colon → ValueError message includes only `raw[:64]` (not the full 5000) truncation [security]: input is 5000-char string with no colon → ValueError message includes only `raw[:64]` (not the full 5000)
``` ```
## Amendment 2026-06-30 — shared resume orchestration (v1 coverage-audit, slice b1)
The original contract specs resume as **caller-owned** (§Resume semantics: "the
caller MAY invoke `reconnect_turn`"). The v1 coverage-audit found `reconnect_turn`
had **no caller** — every presenter (cli/tui/web) let a mid-stream drop propagate
instead of resuming, so the "reference SSE-resume implementation" (design-brief
§3/§8d) was unreachable. Per design-brief §8b ("share the consumer, branch the
presenter") the resume loop is a **single shared orchestration surface**, not
duplicated per presenter. This adds `stream_turn_resilient` as that surface;
presenters call it instead of `stream_turn` when they want transparent reconnect.
`stream_turn` and `reconnect_turn` are unchanged (still the primitives); this is
purely additive.
```contract
FN stream_turn_resilient(client: httpx.AsyncClient, session_id: str, content: str, *, max_reconnects: int = 5) -> AsyncIterator[Event]
BRIEF: The shared resume-orchestration wrapper over stream_turn + reconnect_turn. Yields a SINGLE continuous typed Event stream; on SseConnectionDropped (mid-stream drop OR clean EOF before terminal), transparently resumes via reconnect_turn from the last-seen sse_id, up to max_reconnects times, until a terminal Done/Error/Cancelled arrives. The one surface all presenters consume for resilient streaming (design-brief §8b). Cross-process resume stays deferred to v2 (§8d): last-seen lives only in this generator's frame.
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] content is non-empty str -- assert content and isinstance(content, str)
PRE: [PRE-004 hard] max_reconnects is a non-negative int -- assert isinstance(max_reconnects, int) and max_reconnects >= 0
POST: [POST-001 return_value] yielded events are the concatenation of each attempt's events in wire order; the wrapper does NOT re-yield events it already saw (the server replays only seq>last_seen) -- assert seq is non-decreasing within a turn_id across the seam
POST: [POST-002 return_value] a fully-consumed stream terminates at exactly one Done/Error/Cancelled (INV-001 holds across reconnects) -- assert isinstance(events[-1], (Done, Error, Cancelled))
POST: [POST-003 state_change] reconnect_turn is invoked with last_event_id == f"{last_seen.turn_id}:{last_seen.seq}" of the most recently yielded event -- assert the Last-Event-ID header on attempt N+1 == the last sse_id yielded before the drop
ERROR_ROUTING:
SseConnectionDropped (from stream_turn or reconnect_turn):
local_handling: IF a last-seen sse_id exists AND reconnects < max_reconnects → increment reconnects, resume via reconnect_turn(last_event_id=f"{turn_id}:{seq}"); ELSE re-raise
flow_control: continue (resume) | abort (re-raise when no last-seen id, or budget exhausted)
state_recovery: server replays buffered events seq>last_seen then streams live (spec §Reconnect flow)
ResumeBufferExpired | ResumeTurnFinished | InvalidLastEventId | TurnIdFlip | SseConnectFailed (from reconnect_turn):
local_handling: propagate unchanged — NOT a transient drop; caller policy is abandon/restart (§Resume semantics "surface, not recover")
flow_control: abort
state_recovery: none
STEPS:
1. [setup, flexibility=prescriptive] Validate PRE-001..PRE-004; SET last_seen=None, reconnects=0, gen=stream_turn(client, session_id, content)
2. [loop, flexibility=prescriptive] async-for event in gen: SET last_seen=event.sse_id; YIELD event. On clean generator completion (terminal reached): RETURN.
3. [branch, flexibility=prescriptive] ON SseConnectionDropped d: SET seen = last_seen or d.last_seen_sse_id. IF seen is None OR reconnects >= max_reconnects: RE-RAISE. ELSE: reconnects += 1; gen = reconnect_turn(client, session_id, content, last_event_id=f"{seen.turn_id}:{seen.seq}"); GOTO step 2.
4. [error_handler, flexibility=prescriptive] Any non-drop exception from gen (ResumeBufferExpired/ResumeTurnFinished/InvalidLastEventId/TurnIdFlip/SseConnectFailed) is NOT caught — it propagates unchanged.
TESTS:
happy_no_drop [happy]: stream yields text(42:1), done(42:2) cleanly → wrapper yields exactly those 2; endpoint hit ONCE (no reconnect).
resume_after_one_drop [scenario,tracer]: attempt 1 yields text(42:1) then RemoteProtocolError; reconnect replays text(42:2)+done(42:3) → wrapper yields 42:1,42:2,42:3 as ONE stream; 2nd request carried Last-Event-ID "42:1".
resume_after_clean_eof [scenario]: attempt 1 yields text(42:1) then clean EOF (no terminal); reconnect yields done(42:2) → continuous (resumes on the INV-001 clean-eof drop too).
two_drops_then_done [scenario]: drops after 42:1 then after 42:2; third attempt yields done(42:3) → all 3 events; reconnects==2; Last-Event-ID headers "42:1" then "42:2".
unresumable_zero_event_drop [adversarial]: attempt 1 drops with ZERO events seen (last_seen None) → SseConnectionDropped propagates; only 1 request issued.
max_reconnects_exhausted [adversarial]: every attempt drops after one event; max_reconnects=2 → after initial + 2 reconnects (3 requests), SseConnectionDropped propagates.
buffer_expired_propagates [error]: attempt 1 drops after 42:1; reconnect returns 412 → ResumeBufferExpired propagates (not retried as a transient drop).
zero_budget_no_resume [adversarial]: max_reconnects=0; attempt 1 drops after 42:1 → SseConnectionDropped propagates immediately (no reconnect attempted).
```
+107
View File
@@ -206,3 +206,110 @@ 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
```
+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.
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,265 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "Server-Sent Events emitted on POST /sessions/{id}/messages. Each event is an object discriminated on `type`; all carry `turn_id`.",
"discriminator": {
"propertyName": "type"
},
"oneOf": [
{
"additionalProperties": true,
"properties": {
"phase": {
"enum": [
"BuildingPrompt",
"CallingLLM",
"ProcessingTools",
"Streaming",
"Finishing"
],
"type": "string"
},
"turn_id": {
"description": "The turn this event belongs to.",
"type": [
"integer",
"string"
]
},
"type": {
"const": "worker_phase"
}
},
"required": [
"type",
"turn_id"
],
"type": "object"
},
{
"additionalProperties": true,
"properties": {
"turn_id": {
"description": "The turn this event belongs to.",
"type": [
"integer",
"string"
]
},
"type": {
"const": "awaiting_llm_first_token"
}
},
"required": [
"type",
"turn_id"
],
"type": "object"
},
{
"additionalProperties": true,
"properties": {
"content": {
"type": "string"
},
"turn_id": {
"description": "The turn this event belongs to.",
"type": [
"integer",
"string"
]
},
"type": {
"const": "thinking"
}
},
"required": [
"type",
"turn_id"
],
"type": "object"
},
{
"additionalProperties": true,
"properties": {
"content": {
"type": "string"
},
"turn_id": {
"description": "The turn this event belongs to.",
"type": [
"integer",
"string"
]
},
"type": {
"const": "text"
}
},
"required": [
"type",
"turn_id"
],
"type": "object"
},
{
"additionalProperties": true,
"properties": {
"turn_id": {
"description": "The turn this event belongs to.",
"type": [
"integer",
"string"
]
},
"type": {
"const": "text_boundary"
}
},
"required": [
"type",
"turn_id"
],
"type": "object"
},
{
"additionalProperties": true,
"properties": {
"name": {
"type": "string"
},
"turn_id": {
"description": "The turn this event belongs to.",
"type": [
"integer",
"string"
]
},
"type": {
"const": "tool_start"
}
},
"required": [
"type",
"turn_id"
],
"type": "object"
},
{
"additionalProperties": true,
"properties": {
"name": {
"type": "string"
},
"result": {},
"turn_id": {
"description": "The turn this event belongs to.",
"type": [
"integer",
"string"
]
},
"type": {
"const": "tool_result"
}
},
"required": [
"type",
"turn_id"
],
"type": "object"
},
{
"additionalProperties": true,
"properties": {
"turn_id": {
"description": "The turn this event belongs to.",
"type": [
"integer",
"string"
]
},
"type": {
"const": "affect_update"
}
},
"required": [
"type",
"turn_id"
],
"type": "object"
},
{
"additionalProperties": true,
"properties": {
"turn_id": {
"description": "The turn this event belongs to.",
"type": [
"integer",
"string"
]
},
"type": {
"const": "done"
}
},
"required": [
"type",
"turn_id"
],
"type": "object"
},
{
"additionalProperties": true,
"properties": {
"phase": {
"type": "string"
},
"reason": {
"type": "string"
},
"turn_id": {
"description": "The turn this event belongs to.",
"type": [
"integer",
"string"
]
},
"type": {
"const": "cancelled"
}
},
"required": [
"type",
"turn_id"
],
"type": "object"
},
{
"additionalProperties": true,
"properties": {
"error_code": {
"type": "string"
},
"message": {
"type": "string"
},
"turn_id": {
"description": "The turn this event belongs to.",
"type": [
"integer",
"string"
]
},
"type": {
"const": "error"
}
},
"required": [
"type",
"turn_id"
],
"type": "object"
}
],
"title": "Worldtree Conversation API — SSE turn-stream events"
}
+250
View File
@@ -0,0 +1,250 @@
# Ratatoskr v1 coverage map
_The v1 convergence-target ledger. Ratatoskr has **no self-defined feature
roadmap**: v1 = consume all of Worldtree's I/O points, reached when Worldtree
hits 1.0 (operator, 2026-06-19; auto-memory
`project-ratatoskr-v1-derived-from-worldtree-io-coverage`). This file is that
coverage map — every Worldtree v1-frozen I/O point × ratatoskr's coverage
status, so "are we at v1?" is a ledger lookup, not a judgement call._
**First authored:** 2026-06-30 (the v1 coverage-audit kickoff).
---
## Frozen target
Worldtree is at **v1.0.0b2** — approaching 1.0, with its wire surfaces now
**FROZEN** (Worldtree `docs/v1-schema-freeze-manifest.md`, #326). The audit
anchors against the frozen machine-readable artifacts, NOT the prose markdown:
| Worldtree v1 surface | Frozen anchor | Ratatoskr role |
|---|---|---|
| Conversation REST API | OpenAPI `info.version` **2.2.0** (`Worldtree/docs/conversation-api-openapi.json`, sha `dbdf4e24…`) — **40 path×method groups** | **client** (debug TUI / web) |
| Conversation SSE events | `conversation-api-sse-events.schema.json` (sha `9deeebf4…`) — **11 discriminated event types** | **client** |
| Bifrost wire (consumer protocol) | wire **v0.6** STABLE/FROZEN (`bifrost==1.0.0`) — memory + affect planes | **provider** (Worldtree dispatches into us) |
> **Pin drift (finding P-1) — REMEDIATED 2026-06-30.** Ratatoskr formerly
> vendored only the **prose markdown** (`docs/conversation-api-spec.md`), which
> is byte-identical to live Worldtree's but frozen at v0.35.16-era content (last
> WT edit 2026-05-31) and does **not** document the b2 surface (7 endpoints
> below, the 409/503 on messages-POST #331, the unified error envelope #328, the
> SSE schema). Remediated: the **OpenAPI 2.2.0 + SSE-schema JSON are now vendored
> and pinned** (`.corviduo-canonicals.toml` → `canonical_drift.py` gate); the
> prose markdown is the `tolerate_drift` reference. Spec pin advanced to
> 5810a26 (v1.0.0b2). See § Pin remediation.
**7 endpoints new in b2 OpenAPI, absent from our vendored markdown:**
`/admin/keys/bulk`, `/admin/persona/archive`, `/admin/persona/erase`,
`/admin/usage`, `/embed`, `/judgments`, `/me/usage`.
---
## Scorecard
**Scope mandate: A (ledger-mandate), locked by operator 2026-06-30.** v1 "done"
= every frozen I/O point is **classified** (covered or excluded-with-rationale),
zero unaccounted. NOT "feature-complete client." All scope-pending rows are now
resolved (§ Surface 1, scope-resolution table).
| Surface | Points | ✅ covered-live | ⬜ gap (in-scope) | 🚫 excluded-by-design |
|---|---|---|---|---|
| REST (OpenAPI 2.2.0, path groups) | 40 | 12 | 6 | 22 |
| SSE events | 11 | 11 | 0 | 0 |
| Bifrost provider planes | 8 verbs | 8 | 0 | (10 gated verbs deferred) |
**Legend.** ✅ consumed in code AND live-proven against real Worldtree · ⬜ a
debug-observability I/O point we should cover but don't yet (the convergence
frontier) · 🚫 deliberate non-goal per the design-brief negative clauses + the A
mandate. Counts are at the **path-group** level; mixed-method groups are
footnoted (e.g. `/sessions` POST is ✅ but its `GET` picker is an unwired
sub-gap).
---
## Surface 1 — Conversation REST API (OpenAPI 2.2.0)
### Covered — client path (ratatoskr's core identity)
| Endpoint | Status | Where consumed | Note |
|---|---|---|---|
| `POST /sessions` | ✅ | `sessions.py:307``cli.py:482`,`tui.py:1508`,`web/server.py:155` | + `end_user_id`, `bifrost` binding; 404→AgentNotFound, 502→BifrostHandshakeFailed |
| `POST /sessions/{id}/messages` (turn stream, SSE) | ✅ | `sse_client.py:484` `stream_turn` → cli/tui/web | the primary surface; 409→AgentNotAvailable, 503→TurnLaunchUnavailable (b2 #331) |
| `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `sse_client.py:581` → cli/tui/web | two-stage Ctrl-C; 404/409 mapped |
| `GET /agents` | ✅ | `sessions.py:341``tui.py:1472`,`web/server.py:100` | Tier-1 roster; merged with local index |
| `GET /agents/{id}/persona_state` | ✅ | `sessions.py:384``tui.py:1132`,`web/server.py:386` | persona hydrate; 404/403 mapped |
| `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) |
| `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 |
**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):
- `GET /sessions``sessions.py:198` `list_sessions` exists, **no caller**: the
startup session-picker (design-brief §4 v1) was never wired.
- `POST /sessions/{id}/messages` + `Last-Event-ID``sse_client.py:524`
`reconnect_turn` exists, **no caller**: the reference SSE-resume impl
(design-brief §8d) was never wired.
- `GET /agents/{id}` — consumer-agent lookup (`GET /agents/<owner>:<name>` with
the owner key) is **manual-curl-only**, not in code.
### In-scope gaps — the convergence frontier (debug-observability path)
**Tier 1 — the debug-observability core (design-brief'd for v1, unbuilt):**
| Endpoint | Status | Why in-scope |
|---|---|---|
| `GET /admin/sessions/{id}/tools` | ⬜ | admin variant of the Tools inventory — **covered-by-alternative** via the owner-scoped `GET /sessions/{id}/tools` (✅); this admin variant remains a gap only for cross-user operator debug |
| (`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):**
| Endpoint | Status | Why in-scope |
|---|---|---|
| `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
| Endpoint(s) | Status | Rationale (design-brief / memory) |
|---|---|---|
| `PATCH /sessions/{id}` · `DELETE /sessions/{id}` | 🚫 | §4: rename/delete happen outside the tool (`sessions_cli.py`) |
| `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}/tool-events` | 🚫 | §5: tool calls observed **inline from SSE** `tool_start`/`tool_result`; persisted-events endpoint is opt-in only |
| `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/persona/{archive,erase}` | 🚫 | admin persona GDPR ops (new in b2) |
| `POST /admin/users/{id}/tier` | 🚫 | admin user mgmt |
| `GET /me/usage` · `GET /admin/usage` | 🚫 | usage metering — not turn-flow observability (new in b2) |
| `GET /healthz` · `GET /readyz` | 🚫 | liveness probes — low debug value (could become a connect preflight; park) |
| `GET /search` | 🚫 | §5: consumer-product feature, not turn-flow (was "defer to v2") |
| `GET/POST /uploads` · `DELETE/GET /uploads/{id}` | 🚫 | §6: no uploads — consumer-product feature |
| `GET /pending` · `GET /sessions/{id}/pending` | 🚫 | §5: poll-only, no turn-flow signal (was "optional, skip") |
| `POST /embed` | 🚫 | embedding utility — no turn flows through it |
| `POST /judgments` | 🚫 | LLM-as-judge A/B eval (`response_a`/`response_b`/`rubric`) — standalone eval utility, not turn-flow |
### Scope-resolution record (the 11 ❓ rows, resolved under A)
The 2026-05-20 design-brief deferred several surfaces; the 2026-06-19 reframe
("v1 = full I/O coverage") put them back in tension. **Resolved 2026-06-30 under
mandate A** (debug-observability identity intact; classify, don't build-all):
| Endpoint(s) | Resolution |
|---|---|
| `GET /search` · `uploads` (×2) · `pending` (×2) · `POST /embed` · `POST /judgments` | 🚫 **excluded** — consumer-product / eval / poll utilities, not turn-flow observability |
| `characters` (×4) · `POST /sessions/{id}/persona_state` | ⬜ **in-scope** (frontier Tier 2) — session-routing + affect-injection debug paths |
Nothing remains ❓. The ⬜-vs-🚫 line follows the debug-observability test: *does
a turn flow through it / is it a layer worth watching live?*
---
## Surface 2 — SSE events (11/11 ✅)
Every frozen SSE event type is parsed in `sse_client.py:_envelope_for_type`
(342-411) and rendered by all three presenters (cli/tui/web). **Full coverage.**
`text` · `worker_phase` · `thinking` · `text_boundary` · `tool_start` ·
`tool_result` · `done` · `error` · `cancelled` · `awaiting_llm_first_token` ·
`affect_update`
> Caveat (not a gap): `affect_update` is wire-verified to emit **zero** events
> for consumer (Tier-3) agents — the persona-strip SSE path never populates for
> them (memory 2026-06-18). The handler is correct; the upstream emitter is
> silent. PAD for consumer agents is surfaced via our own provider read route
> (`GET /affect/state/{id}`, #18 D2), not this event.
---
## Surface 3 — Bifrost provider planes (8/8 ✅, live-proven)
Ratatoskr **implements** the provider side; Worldtree dispatches into it.
Live-proven end-to-end through real WT turns (#17/#18 smokes; combined `:8392`
WT-driven smoke 2026-06-20).
**Memory plane** — covers the entire `bifrost.memory.MemoryDataStore` protocol
(required: `describe_store`, `get`, `get_many`, `search`, `upsert_many`) **plus**
`delete_many`:
| Verb | Where | |
|---|---|---|
| `describe_store` | `memory_store.py:140` | advertises caps (sync) |
| `search` | `memory_store.py:224` | vector recall; scope_all AND / scope_any OR |
| `get` / `get_many` | `memory_store.py:293` / `:305` | point reads |
| `upsert_many` | `memory_store.py:150` | idempotent batch write; optimistic lock |
| `delete_many` | `memory_store.py:314` | transactional delete |
**Affect plane** — covers `bifrost…InMemoryAffectStore` (`emit`, `fetch`):
| Verb | Where | |
|---|---|---|
| `emit` | `affect_store.py:47` | conduit-opaque snapshot upsert (LWW) |
| `fetch` | `affect_store.py:116` | `{found, snapshot}`; mandatory since bifrost 0.10.0 strong-or-absent gate |
Plus the non-wire PAD read route `GET /affect/state/{agent_id}`
(`affect_store.py:189`) and the combined `:8392` endpoint advertising both caps
by store-presence (`combined.py:46`).
**Deferred-gated (advertised-unsupported, correctly out-of-scope for the basic
plane):** `scan`, `get_edges_for`, `upsert_edges`, `mark_invalid`,
`mark_superseded`, `patch_many`, `commit_checkpoint`, `lease_job`,
`read_checkpoint`, `health`. These live only in the bifrost reference
*extended* store, not the `MemoryDataStore` protocol; deferred per the #294
re-scope (memory 2026-06-15). Re-evaluate only if Worldtree's Tier-3 path
starts exercising them.
---
## Convergence frontier (the v1 to-do)
**Tier 1 — debug-observability core:**
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.
**Tier 2 — rounds out coverage (all that remains):**
5.**DONE**`GET /sessions/{id}/tools` (`v0.18.9`, owner-scoped tool inventory
in the TUI Tools pane).
6. **Transient-characters routing** (4 endpoints) + **`POST /sessions/{id}/persona_state`**
— the only remaining in-scope client I/O points.
---
## Pin remediation (finding P-1) — DONE 2026-06-30
Re-pinned to the frozen machine-readable artifacts (the chosen option):
`conversation-api-openapi.json` (2.2.0) + `conversation-api-sse-events.schema.json`
are vendored under `docs/` and pinned in `.corviduo-canonicals.toml`
(`worldtree-conversation-api-openapi-v2`, `-sse-events-v1`), drift-gated by
`canonical_drift.py`. The prose markdown stays as a `tolerate_drift` reference
(`-spec-v1`). `pyproject.toml` spec pin advanced f1b59f8 → 5810a26 (v1.0.0b2);
`docs/SPEC-PIN.md` records the bump. This map now audits a frozen, diffable
target — re-running the audit is a `canonical_drift.py` check away.
---
## Decisions
1. **Scope mandate — RESOLVED 2026-06-30: A (ledger-mandate).** v1 "done" = every
frozen I/O point classified, zero unaccounted; NOT feature-complete client.
The ⬜-vs-🚫 line for the 11 formerly-pending rows is locked above (§
Scope-resolution record).
2. **Pin remediation (P-1) — RESOLVED 2026-06-30 (re-pin to machine-readable).**
Vendored `conversation-api-openapi.json` (2.2.0) + `conversation-api-sse-events.schema.json`
as the authoritative drift gates (pinned in `.corviduo-canonicals.toml`,
`canonical_drift.py` green); prose markdown kept as `tolerate_drift`
reference. Spec pin advanced f1b59f8 (v0.35.16) → 5810a26 (v1.0.0b2). This
map now audits against a frozen, diffable target.
+27 -1
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr # Persistent memory — ratatoskr
_Last updated: 2026-06-20_ _Last updated: 2026-06-30_
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
@@ -138,6 +138,30 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-06-19]` **Ratatoskr v1 is DERIVED from Worldtree I/O coverage (operator) — no self-defined feature ROADMAP.** v1 = consume all of Worldtree's I/O points, reached when Worldtree hits 1.0; the convergence target is a coverage map, not a 37 capability list. [auto-memory `project-ratatoskr-v1-derived-from-worldtree-io-coverage`] - `[2026-06-19]` **Ratatoskr v1 is DERIVED from Worldtree I/O coverage (operator) — no self-defined feature ROADMAP.** v1 = consume all of Worldtree's I/O points, reached when Worldtree hits 1.0; the convergence target is a coverage map, not a 37 capability list. [auto-memory `project-ratatoskr-v1-derived-from-worldtree-io-coverage`]
- `[2026-06-20]` **#18's final leg PROVEN — composite `:8392` WT-driven smoke ran end-to-end + persisted.** infra-ops allowlisted `10.100.10.50:8392` on the personal WT (`01KVHWJGTT…`); a real WT turn (session `b83a66b6`, `ratatoskr:sindra`, fresh end_user `resmoke-choco-1`) dispatched the full both-plane lifecycle through ONE endpoint — handshake (both caps) → `affect.fetch` + `memory.search``affect.emit` (`stored:true`) → `memory.upsert_many` (`upserted:1`) — both writes verified in our SQLite (`affect_snapshots` PAD row + `memory_chunks` chunk `2df1b79…`). First attempt blocked by a `model_unavailable` outage on the personal WT (both agents' models down), operator-fixed mid-session, then clean. The composite has no open legs. - `[2026-06-20]` **#18's final leg PROVEN — composite `:8392` WT-driven smoke ran end-to-end + persisted.** infra-ops allowlisted `10.100.10.50:8392` on the personal WT (`01KVHWJGTT…`); a real WT turn (session `b83a66b6`, `ratatoskr:sindra`, fresh end_user `resmoke-choco-1`) dispatched the full both-plane lifecycle through ONE endpoint — handshake (both caps) → `affect.fetch` + `memory.search``affect.emit` (`stored:true`) → `memory.upsert_many` (`upserted:1`) — both writes verified in our SQLite (`affect_snapshots` PAD row + `memory_chunks` chunk `2df1b79…`). First attempt blocked by a `model_unavailable` outage on the personal WT (both agents' models down), operator-fixed mid-session, then clean. The composite has no open legs.
- `[2026-06-20]` **#17 CLOSED in the tracker.** Shipped end-to-end (`v0.17.8``.13` + op-feed field fix `v0.17.17`); the 2026-06-20 composite smoke re-exercised its op-feed live. Closing comment captures the full both-plane proof. Open issues now just #11 (scope-blocked) + #10 (watch). - `[2026-06-20]` **#17 CLOSED in the tracker.** Shipped end-to-end (`v0.17.8``.13` + op-feed field fix `v0.17.17`); the 2026-06-20 composite smoke re-exercised its op-feed live. Closing comment captures the full both-plane proof. Open issues now just #11 (scope-blocked) + #10 (watch).
- `[2026-06-20]` **Sindra has real PAD but ~empty memory — the affect/memory persistence asymmetry, confirmed on real sessions.** affect EMITS every turn (persona always accumulates: vuong 8→14 interactions across the session); memory only writes on a PROMOTION trigger (salience / turn_count≥6 / idle-≥10min flush). Two real vuong sessions through the combined bind (`04d6414c`, `433541fe`) drove affect emits + memory SEARCHES but ZERO promotion upserts → `memory.db` holds only the smoke fixture, zero vuong chunks. Operator: acceptable (server-takedown = "Sindra bonked on the head"; transient memory loss WAD). Operational catch: combined-as-default web bind saves persona reliably but silently LOSES memory if a session closes before a promotion trigger fires.
- `[2026-06-29]` **Web SPA combined-bind default (`v0.18.1`, `719e4d6`) — operator-caught gap.** #18 shipped the composite `:8392` provider but never exposed it in the web bind dropdown (only memory/affect single-plane). Added `combined (:8392)` as the DEFAULT option (both planes in one session), kept single-plane for isolation diagnostics; wired `endpoint_for_plane` combined→8392 + server validation + the dropdown. Direct TDD; #17 contract updated (the governing spec for the web bind). Restarted `:8765` on current code (env.sh + provider.env + `RATATOSKR_AFFECT_READ_URL=:8392`).
- `[2026-06-29]` **bifrost repinned 1.0.0 (`v0.18.2`, `af67ad9`).** bifrost-dev shipped its first stable release; wire v0.6 now STABLE/FROZEN. Non-breaking (byte-identical to 0.10.0); switched the floor pin → exact `==1.0.0` per the stable-substrate posture. Post-1.0 breaking changes ride a bifrost MAJOR + new wire (v0.7+); a v0.6-pinned consumer is stable indefinitely. (Also this session: althing migrated to v0.15.0+ lean-bus / schema v4 — moderation retired, chamber/redis ripped; our tooling auto-updated to 0.17.4.)
- `[2026-06-30]` **Worldtree v1.0.0b1→b2 consumer adaptation: eager turn-launch statuses (`v0.18.3` `b2e4901`, `v0.18.4` `e4317f6`).** Worldtree #331 decoupled turn execution from the SSE connection → turn-launch failures now arrive EAGERLY as a status before any stream: 409 `agent_not_available` (pre-b1 a 200 + in-stream error event), 503 retryable. Mapped both in `stream_turn` to typed `SseConnectFailed` subclasses keyed on STATUS, parsing the `{detail:{error_code,message}}` envelope — POST-003 preserved (no synthetic event yielded), existing handlers still catch (the design fork vs yield-an-Error-event was decided by POST-003). **DEFERRED follow-ups** (tracked here; bundle with the v1 coverage-audit): (1) live-prove the 409/503 end-to-end on personal-b2 (now unblocked — personal on b2, my key works there); (2) full `conversation-api-spec.md` markdown re-vendor to the b2 era (ratatoskr vendors the markdown, not the OpenAPI JSON).
- `[2026-06-30]` **Verify-against-the-real-spec-before-committing caught a real upstream gap.** Holding the v0.18.3 commit to verify against demo's OpenAPI surfaced that the FROZEN OpenAPI 2.1.0 didn't document the 409/503 the heads-up described (`agent_not_available` was in the ErrorCode enum, but NO 503/turn-launch code). worldtree-dev confirmed it was THEIR gap (#331 added the statuses without extending the #328 `openapi()` override), shipped the fix in **v1.0.0b2 / OpenAPI 2.2.0** (409/503 now enumerated, 503 code finalized as `not_ready`). "The consumer-oracle earning its keep." Lesson: a provider's prose heads-up can diverge from its frozen machine-readable spec — verify the actual spec before committing a consumer adaptation.
- `[2026-06-30]` **regard is a DEAD AXIS in Worldtree's emitted affect (caught provider-side; worldtree-dev confirmed + escalated to Vuong).** Across all our affect snapshots, `valence[].regard` is EXACTLY 0.15 regardless of agent/end_user/interaction_count, while familiarity accumulates (vuong 0.18→0.69 over 14 turns). Root cause (worldtree-dev, code-grounded): 0.15 = `base_regard = agreeableness*0.3` (sindra A=0.5); regard's only human-writer `update_regard` early-returns unless an emotion is `about="other"`, but the Vili appraiser's `ViliResponse` schema has NO directedness axis (everything hardcoded `about="situation"`) — producer side lost in the #265 Vili rework; consumer machinery intact. NOT WAD; the fix (reintroduce other-directed classification) is an affect-model change touching every agent + a directedness-classification design call → worldtree-dev filing an issue to Vuong. [the consumer/provider thesis paying off again]
- `[2026-06-30]` **v1 coverage-audit kicked off; coverage ledger written (`docs/coverage-map.md`) — the first one.** Every Worldtree v1-FROZEN I/O point × ratatoskr status. Anchored on WT's frozen machine-readable artifacts (OpenAPI **2.2.0** `conversation-api-openapi.json` = 40 REST path-groups + SSE schema = 11 events + bifrost wire v0.6), NOT the stale vendored prose markdown. Result: **SSE 11/11 ✅; Bifrost provider planes 8/8 ✅ live-proven** (covers the full `bifrost.memory.MemoryDataStore` protocol = describe_store/get/get_many/search/upsert_many + delete_many, and affect emit/fetch; **`health` is extended-reference-store-only, NOT in the base protocol → correctly deferred, NOT a gap** — settles the prior "health" ambiguity); **client REST 7/40 ✅ live, 11 ⬜ in-scope, 22 🚫 excluded.**
- `[2026-06-30]` **Scope mandate A locked (operator): v1 "done" = every frozen I/O point CLASSIFIED (covered-or-excluded-with-rationale), zero unaccounted — NOT a feature-complete client.** The coverage map is a LEDGER, not a build-everything mandate. Reconciles the 2026-06-19 "consume all I/O" reframe with the 2026-05-20 design-brief's "NOT an admin tool" + deferral negative clauses (which predate both the provider identity and the reframe). Resolved the 11 design-brief-vs-reframe ❓ rows via the debug-observability test (*does a turn flow through it?*): 🚫 search / uploads / pending / embed / judgments (consumer-product + eval utilities); ⬜ transient-characters routing (4) + persona_state-write (Tier-2 frontier). **Frontier Tier 1 (all unblocked except #11):** session-picker + SSE-resume (wrappers `list_sessions`/`reconnect_turn` exist with NO caller — presenter-wiring only) → `GET /capabilities` + `GET /me` → BifrostState/Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key) → **#11 AdminEvents BLOCKED on `admin.events.read` scope**. The 3 admin-observability widgets + picker + resume were design-brief §5/§4/§8d v1 items that **were never built**.
- `[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]` **(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`).
_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._
@@ -173,5 +197,7 @@ defense against re-attempting the same cul-de-sac.
- `[2026-06-19]` **heid-code-review pulled MORE weight than its own "marginal" self-assessment.** The panel returned zero drift, but its single test-fidelity finding CASCADED into 2 real latent-bug fixes when applied (the memory `check_same_thread` bug + Regin's op-feed field-name bug). **Lesson: a contract-fidelity nudge can transitively expose bugs the test never reached — don't dismiss a "marginal" finding by its count.** - `[2026-06-19]` **heid-code-review pulled MORE weight than its own "marginal" self-assessment.** The panel returned zero drift, but its single test-fidelity finding CASCADED into 2 real latent-bug fixes when applied (the memory `check_same_thread` bug + Regin's op-feed field-name bug). **Lesson: a contract-fidelity nudge can transitively expose bugs the test never reached — don't dismiss a "marginal" finding by its count.**
- `[2026-06-20]` **The post-turn-async timing trap bit AGAIN — even a 35s post-`[done]` read missed the promotion `upsert_many` by ~2s** (it landed `19:48:58`; the read was ~`19:48:56`). A 15s-interval background poll caught it on the first tick. Same family as the affect.emit / async-promotion traps already logged — re-confirmed that "wait once then read" is fragile for post-turn writes; **poll a window, don't snapshot once.** (The affect.emit write, by contrast, DID land inside the 35s window — promotion is the slower of the two post-turn writes.) - `[2026-06-20]` **The post-turn-async timing trap bit AGAIN — even a 35s post-`[done]` read missed the promotion `upsert_many` by ~2s** (it landed `19:48:58`; the read was ~`19:48:56`). A 15s-interval background poll caught it on the first tick. Same family as the affect.emit / async-promotion traps already logged — re-confirmed that "wait once then read" is fragile for post-turn writes; **poll a window, don't snapshot once.** (The affect.emit write, by contrast, DID land inside the 35s window — promotion is the slower of the two post-turn writes.)
- `[2026-06-30]` **Heimdall keys are PER-INSTANCE — a key minted on one Worldtree 401s on another.** Our Conversation-API key works on personal `:8081` but 401s `auth_invalid` on demo `:8080` (per-instance Heimdall user store + pepper; fresh deploys start with an EMPTY key store). Same as the admin key (personal-only). **To live-drive a given instance you need a key minted FOR that instance** (request via infra-ops). Couldn't live-prove the b2 409 on demo for this reason → deferred to personal-b2 where we have access.
- `[2026-06-30]` **`tea comment <N>` hangs on Gitea** (the whole compound bash auto-backgrounded + stuck on the open `tea` call). The #11 prereq comment hung; killed it + posted via the Gitea HTTP API directly (`POST /api/v1/repos/vh/ratatoskr/issues/<N>/comments`, token from `~/.config/tea/config.yml`). **For issue comments, prefer the Gitea API over `tea comment` when `tea` is flaky** (CLAUDE.md already says use HTTP for comment-EDITS; this extends it to ADD when tea hangs). Verify-then-post (check the comment didn't already land) to avoid a double-post after a kill.
_18 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._ _18 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
+4 -4
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ratatoskr" name = "ratatoskr"
version = "0.18.2" version = "0.19.0"
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"
@@ -60,9 +60,9 @@ Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
# Ratatoskr is built against Worldtree at this commit; the vendored # Ratatoskr is built against Worldtree at this commit; the vendored
# spec snapshot in docs/ reflects that SHA. # spec snapshot in docs/ reflects that SHA.
[tool.ratatoskr.spec-pin] [tool.ratatoskr.spec-pin]
worldtree-spec-rev = "f1b59f8cd6fe41e497d0be9dad9d3110451f0d9a" worldtree-spec-rev = "5810a26b38a5ea6630892f9a39756f57c5b7b41e"
worldtree-version = "v0.29.0" worldtree-version = "v1.0.0b2"
pinned-on = "2026-05-26" pinned-on = "2026-06-30"
# Bifrost lives on the auth-gated gitea PyPI index (not public PyPI). # Bifrost lives on the auth-gated gitea PyPI index (not public PyPI).
# uv reads the credential from UV_INDEX_GITEA_USERNAME / _PASSWORD or ~/.netrc. # uv reads the credential from UV_INDEX_GITEA_USERNAME / _PASSWORD or ~/.netrc.
+88 -5
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
@@ -24,6 +24,8 @@ from ratatoskr.sessions import (
SessionApiFailed, SessionApiFailed,
create_session, create_session,
endpoint_for_plane, endpoint_for_plane,
get_capabilities,
get_me,
) )
from ratatoskr.sse_client import ( from ratatoskr.sse_client import (
AffectUpdate, AffectUpdate,
@@ -47,7 +49,7 @@ from ratatoskr.sse_client import (
TurnIdFlip, TurnIdFlip,
WorkerPhase, WorkerPhase,
cancel_turn, cancel_turn,
stream_turn, stream_turn_resilient,
) )
@@ -97,6 +99,13 @@ 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
class _ArgparseError(Exception): class _ArgparseError(Exception):
@@ -121,6 +130,8 @@ 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")
# 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 +152,25 @@ 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 ns.whoami:
# Standalone boot-time probe (GET /me + /capabilities): opens no session.
if ns.send is not None or ns.session or ns.new or ns.agent:
raise UsageError(
"--whoami is a standalone probe (no --send/--session/--new/--agent)"
)
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 +213,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 +229,8 @@ 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,
) )
@@ -393,7 +424,7 @@ async def _run_turn(
cancelling = False cancelling = False
sigint_task: asyncio.Task[bool] | None = None sigint_task: asyncio.Task[bool] | None = None
cancel_task: asyncio.Task[None] | None = None # strong ref to fire-and-forget cancel cancel_task: asyncio.Task[None] | None = None # strong ref to fire-and-forget cancel
aiter_obj = stream_turn(client, session_id, content).__aiter__() aiter_obj = stream_turn_resilient(client, session_id, content).__aiter__()
try: try:
while True: while True:
@@ -550,6 +581,56 @@ 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 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 +646,8 @@ 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.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
+75
View File
@@ -406,3 +406,78 @@ 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 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)
+177
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."""
@@ -215,6 +232,59 @@ class SseConnectFailed(Exception):
self.body = body self.body = body
class AgentNotAvailable(SseConnectFailed):
"""Eager 409 from the turn POST (Worldtree v1.0.0b1, #331): the session's
agent is unavailable, so the turn never launched. Pre-b1 this arrived as a
200 stream + an in-stream `error` event; b1 surfaces it eagerly. Subclass of
SseConnectFailed so existing `except SseConnectFailed` handlers still catch
it this type just adds the parsed `error_code` + `message`."""
def __init__(self, *, body: bytes, error_code: str, message: str) -> None:
super().__init__(status=409, body=body)
self.error_code = error_code
self.message = message
class TurnLaunchUnavailable(SseConnectFailed):
"""Eager 503 from the turn POST (Worldtree v1.0.0b1, #331): a transient
turn-launch failure (loop shutdown / resource exhaustion). RETRYABLE.
Subclass of SseConnectFailed; adds `error_code`, `message`, `retryable`."""
retryable = True
def __init__(self, *, body: bytes, error_code: str, message: str) -> None:
super().__init__(status=503, body=body)
self.error_code = error_code
self.message = message
# Canonical error_codes (Worldtree #331 / v1.0.0b2): 409 -> agent_not_available,
# 503 -> not_ready (retryable; re-pinned from internal_error). Used only as a
# fallback default when the body omits error_code — the real code is surfaced
# verbatim from the {detail:{error_code,message}} envelope.
_EAGER_TURN_FAILURE_CODE = {409: "agent_not_available", 503: "not_ready"}
def _eager_failure_fields(body: bytes, status: int) -> tuple[str, str]:
"""Extract (error_code, message) from an eager turn-launch failure body
(#331). Accepts the Worldtree `{"detail": {...}}` envelope OR a flat
`{error_code, message}`; falls back to a status-derived default code and a
generic message when the body is absent / non-JSON / malformed."""
try:
parsed: Any = json.loads(body)
except (json.JSONDecodeError, ValueError):
parsed = None
src: dict[str, Any] = {}
if isinstance(parsed, dict):
detail = parsed.get("detail")
src = detail if isinstance(detail, dict) else parsed
code = src.get("error_code") or _EAGER_TURN_FAILURE_CODE[status]
message = src.get("message")
if not isinstance(message, str):
message = f"turn launch failed (HTTP {status})"
return str(code), message
class SseConnectionDropped(Exception): class SseConnectionDropped(Exception):
"""Raised when the HTTP/SSE connection dropped mid-stream.""" """Raised when the HTTP/SSE connection dropped mid-stream."""
@@ -434,6 +504,19 @@ async def stream_turn(
f"/sessions/{session_id}/messages", f"/sessions/{session_id}/messages",
json={"content": content}, json={"content": content},
) as event_source: ) as event_source:
# Worldtree v1.0.0b1 (#331): turn-launch failures arrive EAGERLY as a
# status before any stream — 409 agent_not_available (pre-b1 this was a
# 200 + in-stream `error` event), 503 a transient retryable launch
# failure. Surface them as typed SseConnectFailed subclasses carrying
# error_code; request-level non-2xx (404 session_not_found, etc.) stay
# generic SseConnectFailed.
status = event_source.response.status_code
if status in (409, 503):
body = await event_source.response.aread()
code, message = _eager_failure_fields(body, status)
if status == 409:
raise AgentNotAvailable(body=body, error_code=code, message=message)
raise TurnLaunchUnavailable(body=body, error_code=code, message=message)
try: try:
event_source.response.raise_for_status() event_source.response.raise_for_status()
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
@@ -483,6 +566,100 @@ async def reconnect_turn(
yield event yield event
async def stream_turn_resilient(
client: httpx.AsyncClient,
session_id: str,
content: str,
*,
max_reconnects: int = 5,
) -> AsyncIterator[Event]:
"""Resume-orchestration wrapper over stream_turn + reconnect_turn.
Yields ONE continuous Event stream; on `SseConnectionDropped` (mid-stream
drop or clean EOF before a terminal), resumes from the last-seen `sse_id`
via `reconnect_turn`, up to `max_reconnects` times, until a terminal
Done/Error/Cancelled arrives. The single shared surface presenters consume
for resilient streaming (design-brief §8b: "share the consumer, branch the
presenter"). Cross-process resume stays deferred to v2 (§8d): `last_seen`
lives only in this generator's frame. See contract FN stream_turn_resilient
(amendment 2026-06-30).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert content and isinstance(content, str)
assert isinstance(max_reconnects, int) and max_reconnects >= 0
last_seen: SseId | None = None
reconnects = 0
gen = stream_turn(client, session_id, content)
while True:
try:
async for event in gen:
last_seen = event.sse_id
yield event
return # generator completed cleanly → terminal event reached (INV-001)
except SseConnectionDropped as drop:
# Prefer the id we tracked from a yielded event; fall back to the one
# the drop carries (covers a drop on the very first frame). Non-drop
# reconnect failures (412/410/400/flip) are NOT caught here — they
# propagate per the contract's "surface, not recover" policy.
seen = last_seen or drop.last_seen_sse_id
if seen is None or reconnects >= max_reconnects:
raise
reconnects += 1
gen = reconnect_turn(
client,
session_id,
content,
last_event_id=f"{seen.turn_id}:{seen.seq}",
)
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()
+2 -2
View File
@@ -50,7 +50,7 @@ from ratatoskr.sse_client import (
SseConnectionDropped, SseConnectionDropped,
TurnIdFlip, TurnIdFlip,
cancel_turn, cancel_turn,
stream_turn, stream_turn_resilient,
) )
@@ -291,7 +291,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.
+99 -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,72 @@ 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
+206
View File
@@ -18,7 +18,11 @@ from ratatoskr.sessions import (
SessionPage, SessionPage,
create_session, create_session,
endpoint_for_plane, endpoint_for_plane,
get_capabilities,
get_me,
get_persona_state, get_persona_state,
get_session_bifrost,
get_session_tools,
list_agents, list_agents,
list_sessions, list_sessions,
) )
@@ -896,3 +900,205 @@ 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
+294 -1
View File
@@ -5,7 +5,9 @@ import pytest
import respx import respx
from ratatoskr.sse_client import ( from ratatoskr.sse_client import (
AdminEvent,
AffectUpdate, AffectUpdate,
AgentNotAvailable,
AwaitingLlmFirstToken, AwaitingLlmFirstToken,
CancelAlreadyCompleted, CancelAlreadyCompleted,
Cancelled, Cancelled,
@@ -18,13 +20,17 @@ from ratatoskr.sse_client import (
ResumeBufferExpired, ResumeBufferExpired,
ResumeTurnFinished, ResumeTurnFinished,
SseConnectFailed, SseConnectFailed,
SseConnectionDropped,
SseId, SseId,
Text, Text,
TurnIdFlip, TurnIdFlip,
TurnLaunchUnavailable,
_parse_sse_id, _parse_sse_id,
cancel_turn, cancel_turn,
reconnect_turn, reconnect_turn,
stream_admin_events,
stream_turn, stream_turn,
stream_turn_resilient,
) )
_DONE_42_6 = { _DONE_42_6 = {
@@ -49,6 +55,36 @@ def _sse_chunk(sse_id: str, body: dict[str, object]) -> bytes:
return f"id: {sse_id}\ndata: {json.dumps(body)}\n\n".encode() return f"id: {sse_id}\ndata: {json.dumps(body)}\n\n".encode()
_EVENT_STREAM = {"content-type": "text/event-stream"}
class _DropStream(httpx.AsyncByteStream):
"""Yield the given chunks, then raise a mid-stream drop (RemoteProtocolError).
Mirrors the inline `_DropAfter` used by TestStreamTurn.test_connection_drop;
hoisted to module scope because the resilient-wrapper tests reuse it.
"""
def __init__(self, chunks: list[bytes]) -> None:
self._chunks = chunks
async def __aiter__(self): # type: ignore[no-untyped-def]
for c in self._chunks:
yield c
raise httpx.RemoteProtocolError("simulated mid-stream drop")
async def aclose(self) -> None:
return None
def _drop_response(chunks: list[bytes]) -> httpx.Response:
return httpx.Response(200, headers=_EVENT_STREAM, stream=_DropStream(chunks))
def _stream_response(content: bytes) -> httpx.Response:
return httpx.Response(200, headers=_EVENT_STREAM, content=content)
class TestParseSseId: class TestParseSseId:
def test_happy_simple(self) -> None: def test_happy_simple(self) -> None:
"""happy_simple [happy,tracer]: '42:3' -> SseId(turn_id=42, seq=3).""" """happy_simple [happy,tracer]: '42:3' -> SseId(turn_id=42, seq=3)."""
@@ -415,8 +451,9 @@ class TestStreamTurn:
async def test_connect_failed_body_truncated(self) -> None: async def test_connect_failed_body_truncated(self) -> None:
"""ERROR_ROUTING: SseConnectFailed.body is truncated to <= 1024 bytes.""" """ERROR_ROUTING: SseConnectFailed.body is truncated to <= 1024 bytes."""
big_body = b"x" * 5000 big_body = b"x" * 5000
# 500 (not 409/503 — those are now eager turn-launch carve-outs, #331).
respx.post("https://w.example/sessions/s1/messages").mock( respx.post("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(503, content=big_body) return_value=httpx.Response(500, content=big_body)
) )
async with httpx.AsyncClient(base_url="https://w.example") as client: async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SseConnectFailed) as exc_info: with pytest.raises(SseConnectFailed) as exc_info:
@@ -424,6 +461,71 @@ class TestStreamTurn:
assert len(exc_info.value.body) <= 1024 assert len(exc_info.value.body) <= 1024
assert exc_info.value.body == big_body[:1024] assert exc_info.value.body == big_body[:1024]
@respx.mock
async def test_eager_409_agent_not_available(self) -> None:
"""b1 #331: eager 409 -> AgentNotAvailable (SseConnectFailed subclass) with
typed error_code; the turn never streams."""
respx.post("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(
409,
json={
"detail": {
"error_code": "agent_not_available",
"message": "agent ratatoskr:sindra is unavailable",
}
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AgentNotAvailable) as exc:
_ = [e async for e in stream_turn(client, "s1", "hi")]
assert exc.value.status == 409
assert exc.value.error_code == "agent_not_available"
assert "unavailable" in exc.value.message
assert isinstance(exc.value, SseConnectFailed) # existing handlers still catch
@respx.mock
async def test_eager_503_turn_launch_unavailable_retryable(self) -> None:
"""b1 #331: eager 503 -> TurnLaunchUnavailable (retryable, SseConnectFailed subclass)."""
respx.post("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(
503,
json={"error_code": "turn_launch_failed", "message": "resource exhausted"},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(TurnLaunchUnavailable) as exc:
_ = [e async for e in stream_turn(client, "s1", "hi")]
assert exc.value.status == 503
assert exc.value.retryable is True
assert exc.value.error_code == "turn_launch_failed"
assert isinstance(exc.value, SseConnectFailed)
@respx.mock
async def test_eager_409_non_json_body_defaults(self) -> None:
"""b1 #331: eager 409 with a non-JSON body -> AgentNotAvailable with the
status-derived default error_code."""
respx.post("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(409, content=b"<html>nope</html>")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AgentNotAvailable) as exc:
_ = [e async for e in stream_turn(client, "s1", "hi")]
assert exc.value.error_code == "agent_not_available"
@respx.mock
async def test_eager_503_non_json_body_defaults_not_ready(self) -> None:
"""b2: eager 503 with a non-JSON body -> TurnLaunchUnavailable with the
canonical default error_code `not_ready`."""
respx.post("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(503, content=b"<html>nope</html>")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(TurnLaunchUnavailable) as exc:
_ = [e async for e in stream_turn(client, "s1", "hi")]
assert exc.value.error_code == "not_ready"
assert exc.value.retryable is True
@respx.mock @respx.mock
async def test_no_text_aggregation(self) -> None: async def test_no_text_aggregation(self) -> None:
"""no_text_aggregation: consumer yields each text event separately; no concat.""" """no_text_aggregation: consumer yields each text event separately; no concat."""
@@ -1040,3 +1142,194 @@ class TestAwaitingLlmFirstToken:
elapsed = [b.elapsed_ms_since_building_prompt for b in beats] elapsed = [b.elapsed_ms_since_building_prompt for b in beats]
assert elapsed == sorted(elapsed) # monotonically increasing assert elapsed == sorted(elapsed) # monotonically increasing
assert all(b.turn_id == 42 for b in beats) assert all(b.turn_id == 42 for b in beats)
_URL = "https://w.example/sessions/s1/messages"
class TestStreamTurnResilient:
"""docs/contracts/issues/1.contract.md FN stream_turn_resilient (amendment 2026-06-30)."""
@respx.mock
async def test_happy_no_drop(self) -> None:
"""happy_no_drop [happy]: clean stream passes through; no reconnect issued."""
stream = _sse_chunk("42:1", {"type": "text", "content": "a"}) + _sse_chunk(
"42:2", _DONE_42_6
)
route = respx.post(_URL).mock(return_value=_stream_response(stream))
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_turn_resilient(client, "s1", "hi")]
assert [e.sse_id for e in events] == [SseId(42, 1), SseId(42, 2)]
assert isinstance(events[-1], Done)
assert route.call_count == 1 # POST-001: no reconnect on a clean stream
@respx.mock
async def test_resume_after_one_drop(self) -> None:
"""resume_after_one_drop [tracer]: a mid-stream drop resumes via reconnect; one stream."""
first = _drop_response([_sse_chunk("42:1", {"type": "text", "content": "a"})])
resume = _stream_response(
_sse_chunk("42:2", {"type": "text", "content": "b"})
+ _sse_chunk("42:3", _DONE_42_6)
)
route = respx.post(_URL).mock(side_effect=[first, resume])
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_turn_resilient(client, "s1", "hi")]
assert [e.sse_id for e in events] == [SseId(42, 1), SseId(42, 2), SseId(42, 3)]
assert isinstance(events[-1], Done)
assert route.call_count == 2
# POST-003: reconnect carries the last yielded pre-drop event's id.
assert route.calls[1].request.headers.get("Last-Event-ID") == "42:1"
# PRE/wire: first attempt does NOT carry a Last-Event-ID.
assert route.calls[0].request.headers.get("Last-Event-ID") is None
@respx.mock
async def test_resume_after_clean_eof(self) -> None:
"""resume_after_clean_eof: a clean EOF before terminal also triggers resume (INV-001)."""
first = _stream_response(_sse_chunk("42:1", {"type": "text", "content": "a"}))
resume = _stream_response(_sse_chunk("42:2", _DONE_42_6))
route = respx.post(_URL).mock(side_effect=[first, resume])
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_turn_resilient(client, "s1", "hi")]
assert [e.sse_id for e in events] == [SseId(42, 1), SseId(42, 2)]
assert isinstance(events[-1], Done)
assert route.calls[1].request.headers.get("Last-Event-ID") == "42:1"
@respx.mock
async def test_two_drops_then_done(self) -> None:
"""two_drops_then_done: two transient drops, third attempt completes; ids thread through."""
a1 = _drop_response([_sse_chunk("42:1", {"type": "text", "content": "a"})])
a2 = _drop_response([_sse_chunk("42:2", {"type": "text", "content": "b"})])
a3 = _stream_response(_sse_chunk("42:3", _DONE_42_6))
route = respx.post(_URL).mock(side_effect=[a1, a2, a3])
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_turn_resilient(client, "s1", "hi")]
assert [e.sse_id for e in events] == [SseId(42, 1), SseId(42, 2), SseId(42, 3)]
assert route.call_count == 3
assert route.calls[1].request.headers.get("Last-Event-ID") == "42:1"
assert route.calls[2].request.headers.get("Last-Event-ID") == "42:2"
@respx.mock
async def test_unresumable_zero_event_drop(self) -> None:
"""unresumable_zero_event_drop [adversarial]: drop before any event → propagate."""
route = respx.post(_URL).mock(side_effect=[_drop_response([])])
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SseConnectionDropped):
_ = [e async for e in stream_turn_resilient(client, "s1", "hi")]
assert route.call_count == 1 # no id to resume from → no reconnect
@respx.mock
async def test_max_reconnects_exhausted(self) -> None:
"""max_reconnects_exhausted [adversarial]: every attempt drops; budget caps reconnects."""
side = [
_drop_response([_sse_chunk(f"42:{n}", {"type": "text", "content": "x"})])
for n in (1, 2, 3)
]
route = respx.post(_URL).mock(side_effect=side)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SseConnectionDropped):
_ = [
e
async for e in stream_turn_resilient(
client, "s1", "hi", max_reconnects=2
)
]
assert route.call_count == 3 # initial + 2 reconnects, then give up
@respx.mock
async def test_zero_budget_no_resume(self) -> None:
"""zero_budget_no_resume [adversarial]: max_reconnects=0 → first drop propagates."""
first = _drop_response([_sse_chunk("42:1", {"type": "text", "content": "a"})])
route = respx.post(_URL).mock(side_effect=[first])
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SseConnectionDropped):
_ = [
e
async for e in stream_turn_resilient(
client, "s1", "hi", max_reconnects=0
)
]
assert route.call_count == 1
@respx.mock
async def test_buffer_expired_propagates(self) -> None:
"""buffer_expired_propagates [error]: a 412 on reconnect surfaces, not retried."""
first = _drop_response([_sse_chunk("42:1", {"type": "text", "content": "a"})])
resume = httpx.Response(412, json={"turn_id": 42, "buffered_from_seq": 7})
route = respx.post(_URL).mock(side_effect=[first, resume])
async with httpx.AsyncClient(base_url="https://w.example") as client:
collected: list[object] = []
with pytest.raises(ResumeBufferExpired):
async for e in stream_turn_resilient(client, "s1", "hi"):
collected.append(e)
assert [e.sse_id for e in collected] == [SseId(42, 1)] # type: ignore[attr-defined]
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
Generated
+1 -1
View File
@@ -1052,7 +1052,7 @@ wheels = [
[[package]] [[package]]
name = "ratatoskr" name = "ratatoskr"
version = "0.18.2" version = "0.19.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },