Slice 1 of issue #17 (Bifrost-binding the chat client) — the client-side
BIND primitive, TDD'd against docs/contracts/issues/17.contract.md.
- BifrostBinding{endpoint_url, scope=None} frozen dataclass (#160 shape)
- create_session(..., bifrost=, consumer_key=): carries the bifrost body
field and OVERRIDES the bearer to the consumer key per-request (INV-001 —
never falls back to the canary key)
- BifrostConsumerKeyMissing: raised BEFORE any HTTP when a binding lacks a
non-empty key (PRE-001)
- BifrostHandshakeFailed: 502 on a BOUND create -> carries detail.bifrost_error
(both-shape unwrap per the persona_state wire lesson); gated on bifrost!=None
so an unbound 502 stays SessionApiFailed (INV-002)
- endpoint_for_plane: memory->:8391 / affect->:8390, invalid->ValueError
7 new tests; full suite 442 green; ruff clean.
Live smoke against personal:8081 during the v0.15.0 web-companion
verification surfaced that real Worldtree returns persona_state
errors in the FastAPI default envelope shape:
{"detail": {"error_code": "auth_scope_denied", "message": "..."}}
The v0.12.0 `get_persona_state` parser only inspected the top-level
`error_code` key. When the field was nested under `detail`, the
typed exception (AuthScopeDenied / PersonaNotConfigured /
AgentNotAvailable) wasn't raised; the call fell through to
SessionApiFailed, which then surfaced through the web companion as
an opaque HTTP 500 on /api/agents/{id}/persona_state.
The original test_sessions.py mocks used the flat-shape envelope, so
the bug was invisible in unit tests until the real-wire smoke.
Fix: extract error_code from either `err.get("error_code")` (flat)
OR `err.get("detail", {}).get("error_code")` (FastAPI default).
Patch per SemVer discipline — bug fix to v0.12.0 surface, no public
signature change, no new behavior. Callers that were getting the
wrong exception now get the right one; callers that were already
getting the right exception (flat-shape paths) are unchanged.
Tests: 2 new regression cases in TestGetPersonaState — one each for
the detail-envelope shape of 403 auth_scope_denied and 404
persona_not_configured. Suite: 358 passing.
Adds the read-side half of Worldtree #204's persona-state observability
surface. Pairs with v0.11.0's AffectUpdate SSE event — together they
let a consumer hydrate a persona pane on session-open (this GET) and
keep it live as turns fire (the SSE event).
Public surface:
- `get_persona_state(client, agent_id) -> dict[str, Any]` — GET
/agents/{agent_id}/persona_state, returns the same `snapshot` dict
shape as AffectUpdate.snapshot
- New exception types mapped from the spec's documented 4xx error_codes:
- `PersonaNotConfigured` (404 persona_not_configured) — agent has
no persona surface (domari, muninn, all Tier 3 in Phase 2.0)
- `AgentNotAvailable` (404 agent_not_available) — unknown agent_id
- `AuthScopeDenied` (403 auth_scope_denied) — key lacks the
requested scope (persona.read here; reusable for future scoped
endpoints)
- Other non-2xx falls through to the existing SessionApiFailed
precedent so novel failure modes aren't silently absorbed
Tests: 6 new cases covering happy snapshot return, each typed 4xx
sub-code, unknown 404 fall-through, and 5xx SessionApiFailed parity.
Not yet consumed: TUI persona surface (Persona TabPane / sticky
header line). UX shape pending operator direction — step 3.
Adds GET /agents fetch + ListView picker for bare `--new` (TUI mode
without --agent). Three in-place amendments:
- ratatoskr.sessions: new `list_agents()` + `AgentInfo` frozen
dataclass with omit-when-null/empty defaults mirroring SessionInfo's
INV-001/INV-002 origin-conditional pattern. Non-200 responses raise
the existing SessionApiFailed (no new exception).
- ratatoskr.cli: `_parse_args` softens `--agent` from absolute to
mode-conditional — required for `--send --new`, optional for bare
`--new`, forbidden with `--session` (unchanged INV-004).
- ratatoskr.tui: new `AgentPickerApp(App[str | None])` — separate
Textual App (not Screen-within-RatatoskrApp) so list_agents errors
land on real stderr before any alt-screen opens (preserves issue
#6's INV-001). `_resolve_then_run` gains a pre-create branch:
fetch agents → empty list → exit 13; non-200 → exit 20; network
error → exit 21; picker dismissed → exit 0; otherwise thread chosen
agent_id into create_session.
Contract: docs/contracts/issues/8.contract.md (drift-check clean).
Tests: +18 (227 total, was 209). Live smoke against personal Worldtree
(:8081) returned 12 agents; programmatic picker drive auto-picked lofn
and created a real session with `end_user_id="ratatoskr-tui"`.
Issue #6 (TUI startup error visibility): restructure run_tui lifecycle so
pre-App.run() failures land on real stderr instead of getting eaten by
the alt-screen teardown. New _resolve_then_run async helper opens the
AsyncClient via async-with, does pre-flight session resolution, routes
AgentNotFound / SessionApiFailed / network errors to sys.stderr (verbatim
same labels + exit codes as cli._amain), then constructs RatatoskrApp
with pre-resolved state and awaits app.run_async(). RatatoskrApp.__init__
signature widens to (args, *, session_id, agent_id, client) — all three
required. on_mount narrows to identity-widget population; on_unmount
becomes a no-op (client lifetime owned by run_tui's async-with).
Issue #5 (--end-user-id for per-end-user agents): sessions.create_session
gains keyword-only end_user_id kwarg with PRE-003 non-empty assertion;
ParsedArgs.end_user_id field added (default None); --end-user-id flag
with non-empty validation; _amain + _resolve_then_run thread it to their
create_session calls. RATATOSKR_END_USER_ID env-var fallback
(flag > env > None) per the post-2026-05-23 amendment; env.sh (gitignored)
ships "ratatoskr-tui" as project-stable partition default.
Worldtree-dev consumer-API follow-up (althing 01KSBARG2B8M): User-Agent
header added (ratatoskr/<version> (vh@phasefinal.com), version pulled via
importlib.metadata) to both AsyncClient constructions so server logs can
distinguish ratatoskr traffic from other consumers.
Volva code-review (2 rounds on #6) found 8 test-precision gaps + 1 PRE
assertion drift, all Category 1 fixed: missing PRE-001 at
_resolve_then_run entry; Rule separator assertions on markdown render;
RichLog-write spy on empty submit; input-cleared + no-new-worker on
cancelling busy; worker.cancel observation on three force-exit paths;
on_unmount-no-close focused test (the prior client-lifetime test patched
run_async so on_unmount was never exercised); happy --new resolve test
verifying POST count + identity propagation.
Issues #2/#3/#4/#5 contracts amended in-place to reflect:
- create_session widened (PRE-003, body construction step, body shape POST)
- ParsedArgs description + _parse_args STEPS + _amain create_session call
+ new TESTS for end_user_id + env-var fallback
- _resolve_then_run STEPS + new TEST entries; on_mount narrowed;
INV-007 amended for new client ownership
- Post-#6 adjustment note on issue #5 (_resolve_then_run replaces
on_mount as the threading site since #6 moved session resolution out
of the alt-screen)
188 tests GREEN; ruff clean. Bumps to v0.1.0 — first minor release, the
load-bearing reason is RatatoskrApp.__init__'s breaking signature change
(additive end_user_id alone wouldn't have triggered a minor pre-v1.x).
Files Gitea issues #9 (spec-pin refresh v0.19.0 → v0.22.1), #10 (track
Worldtree #196 subject:{type,id} migration), #11 (AdminEvents pane auth
prerequisite admin.events.read). Infra-ops pinged via althing for
agents.call:lofn scope add (broker pattern; they forwarded to
worldtree-dev because personal Worldtree exposes no public
scope-mutation endpoint).
Volva's code-spec review (thread 01KS4EKVKKGF) surfaced three findings
on the TDD-passing sessions module. All three addressed; one carries
a collateral contract amendment to keep INV-002 truthful.
1) drift: archived=item.get("archived", False) returned None for an
explicit "archived": null in the response. dict.get(k, default) only
fires the default when the key is absent — it does NOT default for
explicit-null values. The dataclass type is `bool` (not `bool | None`)
and INV-002 says explicit-null → False; the .get() form silently
violated both. Fixed: archived=item.get("archived") or False
(handles absent, null, false, and true cleanly).
INV-002 wording was the source of the bug — I introduced the
mis-spelled form during the Volva amendment round. Updated to spell
out the .get(default) foot-gun explicitly so future readers (and
future paraphrase rounds) don't fall back to the broken pattern.
2) test-gap: no test exercised explicit-null archived/tags. The
_list_item() helper had its own defaulting layer (tags=None →
["work"]) so a happy path test couldn't catch the underlying drift.
Added test_explicit_null_list_defaults using a raw dict to bypass
the helper. Catches the drift directly.
3) precision: message_count=body.get("message_count") could silently
default to None while POST-003 required it non-None. INV-001 prose
literally said "body['message_count']" (bracket access) so the
STEP 5 .get() was the contract's own internal inconsistency.
Aligned the code to bracket access (matches sibling required
fields like session_id) and amended STEP 5 + INV-001 to spell out
the strict semantics explicitly.
Volva's meta-note: "modest weight" — TDD caught the main surface;
this round caught a narrow Python .get() semantics edge that no
human reading would have spotted without explicit-null priors.
Still pulls real weight: that's the kind of bug that ships and
shows up months later when a server starts emitting null where
it used to omit a field.
63 tests GREEN (42 sse_client + 20 sessions + 1 boundary).
Ruff clean. Drift check still GREEN against the pinned issue body.
Implements docs/contracts/issues/2.contract.md. Two functions
(create_session, list_sessions), two frozen dataclasses (SessionInfo,
SessionPage), three exception types (AgentNotFound, InvalidCursor,
SessionApiFailed). 19 contract-listed tests cover every TESTS:
entry verbatim per the tracer-bullet vertical-slice ordering.
SessionInfo uses one shape across both endpoints with origin-
conditional defaults per INV-001 (create) and INV-002 (list). create-
origin always sets list-only fields to (name=None, archived=False,
tags=[]); list-origin reads them from the response item with
absent/null treated as those same defaults — keeps the dataclass
uniform without forcing callers to handle two types.
Spotted an internal-inconsistency in the contract at TDD start —
POST-003 and happy_create's test description still said "archived
is None, tags is None" while the freshly-applied Volva amendment
had moved INV-001 to (archived=False, tags=[]). Fixed in-place
before writing any tests so the spec stayed coherent.
SessionApiFailed.body truncates to <= 1024 bytes at construction,
matching the SseConnectFailed / CancelFailed precedent from issue #1.
No code shared with sse_client.py (convention-dependency only per
issue #2's dependencies: block). 62 tests GREEN total (42 sse_client
+ 19 sessions + 1 boundary smoke). Ruff clean.
No refactor pass — the two functions are ~25 LOC each with distinct
error-routing branches that don't naturally share more than they
already do.