Completes slice 3c: the browser-facing trigger for the web bind. A 'Bifrost
binding' <select> (none / memory / affect) on the setup panel; startSession
sends bifrost_plane in the create body (the consumer key stays server-held,
never sent from the browser). On a bound 201 the identity line renders the
bound-state indicator (plane + endpoint, never the key); bind failures surface
the error_code + bifrost_error in the setup error line.
Closes the persistent-memory caveat: web chat can now bind its own Tier-3
provider (memory persistence + affect telemetry), not persona+debug only.
Web suites 63 green (presentation-contract included); pure static HTML/JS.
Slice 3c of issue #17 — the web surface of the bind trigger, server side. Closes
the INV-008 lockstep (CLI + TUI + web all carry the bind now). Implements the
contract's "web bind split": the browser selects only the PLANE; the consumer key
and the Worldtree-visible host are SERVER-HELD config and never reach the browser.
- create_app gains bifrost_consumer_key + bifrost_visible_host (server-held,
from env via the entrypoint: RATATOSKR_BIFROST_CONSUMER_KEY /
RATATOSKR_PROVIDER_VISIBLE_HOST).
- _create_session_endpoint reads an optional `bifrost_plane` from the browser
body, builds the BifrostBinding SERVER-SIDE via endpoint_for_plane(plane,
visible_host), and calls create_session(bifrost=, consumer_key=). The 201
response echoes bound-state {plane, endpoint, status: bound} for the UI
indicator — never the key (INV-008/INV-009).
- Error routing: invalid plane / unconfigured server -> 400; BifrostHandshakeFailed
-> 502 {bifrost_error}; BifrostConsumerKeyMissing (server misconfig) -> 400.
5 new web bind tests (server constructs binding + key-never-leaks + upstream
carries bifrost body + consumer-key bearer; unconfigured -> 400; invalid plane;
handshake 502; no-plane unbound regression). Full suite 470 green; added lines
ruff + mypy clean (pre-existing web-file backlog untouched).
Follow-on: the index.html plane selector (UI trigger) — the server capability is
complete and TDD'd; the browser-side dropdown is a thin separate change.
LIVE-SMOKE PROVEN (this session): the CLI bind drove a bound sindra session
against personal Worldtree :8081 -> handshake 200 -> the op-feed captured 2
recall searches correlated to the EXACT bound session_id (2c0c7482), with the
real #297/#298 union-recall scopes. Bind + observe proven end-to-end live.
Slice 3b of issue #17 — the TUI surface of the bind trigger (web is 3c). The TUI
consumes the same ParsedArgs the cli already parses (--bifrost-plane / --bifrost-url
/ consumer key from RATATOSKR_BIFROST_CONSUMER_KEY), so this wires the bind into
_resolve_then_run's pre-flight create_session:
- bifrost + consumer_key threaded into create_session at the pre-alt-screen
resolution layer, so bind failures land on the operator's REAL stderr BEFORE
the Textual alt-screen opens (INV-002, mirrors issue #6's pre-alt-screen
routing) — never eaten by the alt-screen teardown.
- BifrostConsumerKeyMissing -> exit 22; BifrostHandshakeFailed -> exit 23 with the
same 401-scoping hint, keyed on bifrost_error == bifrost.auth_rejected. Exit
codes + label vocabulary match cli._amain exactly (INV-006).
- Bound-state indicator on success (pre-alt-screen): ". bifrost: status=bound
plane=... endpoint=...".
3 new TUI bind tests (handshake-fail / consumer-key-missing / bound-create carries
binding + indicator, run_async stubbed). Full suite 465 green; added lines ruff +
mypy clean (pre-existing tui.py lint/type backlog untouched per surgical-changes).
Slice 3a of issue #17 — the CLI surface of the bind trigger (TUI + web follow,
INV-008 lockstep). ratatoskr can now self-drive a bound session from the CLI:
- New flags: --bifrost-plane {memory,affect} (dev shortcut -> endpoint_for_plane
over --bifrost-host / RATATOSKR_PROVIDER_VISIBLE_HOST) and --bifrost-url (the
direct HTTPS/prod endpoint, bypassing the plane shortcut). Mutually exclusive;
a binding is a session-CREATE concern (forbidden with --session).
- Consumer key resolved from RATATOSKR_BIFROST_CONSUMER_KEY only (the privileged
handshake identity — never a CLI flag, distinct from the canary WORLDTREE_API_KEY).
- _amain threads bifrost + consumer_key into create_session and routes the bind
failures: BifrostConsumerKeyMissing -> exit 22; BifrostHandshakeFailed -> exit
23 with the 401-scoping hint ("use the consumer key, not WORLDTREE_API_KEY")
keyed on bifrost_error == bifrost.auth_rejected.
- Bound-state indicator on success: ". bifrost: status=bound plane=... endpoint=..."
— shows WHICH identity/endpoint bound, not a bare boolean.
Also fixes a pre-existing test-isolation bug: test_no_textual_import did a live
importlib.reload(ratatoskr.cli) that mutated the shared module in place, breaking
class identity (isinstance / pytest.raises) for every test after it. The real
check is the static source grep; the reload was vestigial and is removed.
9 new CLI bind tests; full suite 462 green; ruff clean (no new mypy errors).
Slice 2 of issue #17 — the OBSERVE half. New ratatoskr.provider.opfeed:
- OpEvent{ts, plane, op, session_id, status, req_summary, resp_summary,
turn_id=None} — scope-only summaries, never record bodies / PAD content
- OpSink Protocol + JsonlOpSink (continuous append-only JSONL, INV-007)
- instrument_provider_app(app, *, plane, sink): an ASGI middleware over the
built bifrost provider app. Buffers+replays the request, captures the
response, reads session_id off the dispatch JWT's "sub" claim (INV-005 —
present for ALL verbs incl. search/get/delete, which bifrost withholds from
the store method), emits exactly one OpEvent per inbound bifrost-call incl.
handshake + errors. Read-only over dispatch; store scope semantics untouched
(INV-004). A sink/summary failure is swallowed + logged, never breaks serve
(POST-003).
- Per-verb summaries: search {scope_all,scope_any,top_k}->{hit_count,hits};
upsert_many {record_count,scopes}->{upserted,replayed}; get/get_many/
delete_many {ids}->{found_count|deleted}; emit (affect, opaque)->{stored};
handshake {caps_requested}->{caps_granted,ok}; error->{error: code}
- serve_memory/serve wired: opt-in via RATATOSKR_OPFEED_PATH (maybe_instrument)
Resolves the contract's open question: the dispatch JWT DOES carry session_id
(= the "sub" claim). Tests drive the REAL bifrost dispatch end-to-end with
minted JWTs. 11 new tests; full suite 453 green; ruff + mypy clean (opfeed.py).
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.
persona_state hard-404s every Tier-3 (colon-id) agent by design upstream
(WT api.py:1220, "Phase 2.0 has no Tier 3 persona") — so the Persona pane
showed "persona not available (HTTP 404)" for consumer-defined characters.
loadPersona now reads error_code + renders a clear Tier-3-aware message
(she still responds in character; only the affect/OCEAN readout is gated),
with distinct text for persona_not_configured / 403 / other.
Also (snapshot): sindra switched to thoughtful-character role
(mistral-small-4-reasoning); worldtree-dev pinged re Tier-3 persona_state
roadmap (thread 01KVCR6P); #17 (bifrost-binding the chat client) teed up as
the next-context target.
v0.17.7
Repin bifrost 0.7.0→0.8.0 and reimplement the memory store's search scope
filter to the v0.6 split (#11): scope_all (AND/intersection) + scope_any
(OR/union over a list of conjunctive scopes), at parity with the v0.6
reference _matches_scope / _validate_scope. No-compat: scope_filter removed.
scope_any is the union-visibility primitive that resolves the #295/#297
silent-zero AND foot-gun — a subset-scoped chunk now recalls via an OR
member. End-to-end cold recall now gated only on Worldtree emitting
scope_any on its recall path (#297, upstream).
- store: search(scope_all, scope_any); _scope_subset + _matches_scope + _validate_scope
- contract v1.2: search FN sig, INV-005 recomposed, PRE-003 both fields, scope_any_union test
- tests: scope_any union, scope_all∧scope_any compose, both-empty match-all; parity vs real 0.8.0 dispatch (433 green)
- #17 contract: sync stale scope_filter/_scope_matches-AND refs to scope_all/scope_any
- runbook + persistent-memory updated; provider bounced onto 0.8.0 (fresh empty db)
v0.17.6
bifrost 0.7.0 (wire v0.5) makes agent_self canonical: the scope lattice is
now {end_user, group, tenant, agent_self}. Our store was MORE permissive than
bifrost's reference (no _validate_scope_filter), which silently 0-zeroed the
#295 cold recall instead of a loud 400. Now matched: search rejects an
out-of-lattice axis with InvalidFilter (-> memory.invalid_filter 400), agent_self
admitted. Purely additive — everything that validated before still validates.
Closes the parity gap our own foot-gun flag opened (bifrost-dev shipped the
lattice add #10 off it). Pin bumped bifrost>=0.6.1 -> >=0.7.0. Contract
search PRE-003 + lattice_axes test; 2 new store tests; full suite 429 green.
Structured [memory-provider] request/response logging on the memory-call
path: search REQUEST (scope_filter, top_k) + RESPONSE (chunk_ids, scores,
scope), plus concise entry lines on upsert_many/delete_many. Self-contained
stdout handler so the lines reach the provider stdout under uvicorn.
Additive observability only — no search-semantics change (AND-parity with
bifrost's reference store holds). This is the first concrete brick of #17's
observe half, and the lens that root-caused #295's cold-recall miss (the
persist/recall scope-axis asymmetry) from the provider side.
The second plane of ratatoskr's Tier-3 Bifrost consumer: a durable memory
store Worldtree writes agent memory chunks into (upsert_many) and recalls
by vector similarity (search), with point reads + deletes. Implements
bifrost's own MemoryDataStore Protocol; conformance is #195 parity vs
InMemoryMemoryStore through the real dispatch_memory_call.
Store (memory_store.py): open_memory_store, describe_store, upsert_many
(replay/conflict idempotency, optimistic locking, injection rule, atomic
batch), search (cosine over sqlite-vec vec0, scope isolation INV-005,
over-fetch-then-filter so top_k counts in-scope), get/get_many,
delete_many, build_memory_provider_app. Dev shell (serve_memory.py):
ratatoskr-memory-provider entrypoint, port 8391.
TDD + heid-code-review (panel Groa/Hulda/Regin, zero true drift). Adopted
fixups: scope_filter dict guard, top_k<=0 -> [], stronger scope-isolation
+ delete-hit-search + handshake-POST tests. Partial-map optimistic-lock
semantics pinned against the reference via a new expected_revisions
parity test.
26 memory + 4 serve tests; #195 parity (upsert/search/expected_revisions)
green; ruff clean. Deps: +sqlite-vec.
The Intensity/Mood/Temperature/Willingness system-speak ban now explicitly
covers every output layer — third-person narrative, descriptive passages,
internal monologue — not just first-person dialogue, until the form reverts.
Clears the two ✗ FAIL (missing STEPS) the v2.1 parser surfaced.
#3: faithful STEPS for CliPresenterState.render, _format_duration_ms,
_format_usage (the two formatters also gain PRE/POST from their real
asserts). render STEPS enumerate AffectUpdate + AwaitingLlmFirstToken
as demoted telemetry (Worldtree #204/#201), extending POST-005 beyond
the issue #12 set.
#4: refresh the TUI presenter contract from the abandoned single-RichLog
double-display model to the shipped four-pane live-Markdown model
(v0.5.0-v0.14.0 + Worldtree #201/#204). Rewrites TuiPresenterState.render
and _stream_turn_worker (signature, POSTs, STEPS, TESTS), INV-005, the
[performance] constraint, the COMPOSE sketch, the CLASS block
(BRIEF/PROPERTIES/INV-WIRE-002), the resolved open_question, and the
_cancel_via_sse call site. Verified against src/ratatoskr/tui.py and the
real test names in tests/test_tui.py.
Both contracts: 0 validation errors (pre-existing multi-tracer warnings
on _run_turn / action_interrupt left untouched).
Pulls the upstream contract_parser.py canonical: v2.1 + issue-scoped
contract validation (target_module/scope/prd frontmatter, four new
test categories scenario/trace/adversarial/property, issue-aware
human/list output). Re-pins pinned_sha256_16 + pinned_at in the
manifest. canonical_drift.py reports 7/7 OK; no sync-induced contract
regressions.
Stand-up glue for the live affect negotiation smoke vs Worldtree v0.35.2.
- ratatoskr.provider.serve: build_app_from_env (tested env->app seam) + main
(uvicorn shell). Config from env: RATATOSKR_HEIMDALL_KEY (required),
RATATOSKR_AFFECT_DB, RATATOSKR_CONSUMER_ID, RATATOSKR_PROVIDER_HOST/PORT.
- console-script ratatoskr-provider = ratatoskr.provider.serve:main.
- Heimdall key utf-8-encoded into HS256 key_bytes; revisit if Worldtree signs
with base64/raw bytes (confirm format at wire-up).
The first slice of ratatoskr-as-Tier-3-Bifrost-consumer: a SQLite-backed,
conduit-opaque affect store Worldtree emits Tier-3 persona/affect snapshots
into, plus the thin build_affect_provider_app wiring. The bifrost library
owns the wire; this owns only the store + glue.
- ratatoskr.provider.affect_store: open_affect_store / emit / get /
build_affect_provider_app. Two-table SQLite schema (snapshots + idempotency).
- Conduit-opaque (INV-001): reads only the two addressing keys; whole-blob
hash only. LWW-by-arrival across distinct keys; replay-or-conflict
idempotency (actor-scoped from ctx), raising bifrost's typed exceptions.
- Conformance: #195 parity vs InMemoryAffectStore through the real
dispatch_affect_call engine. 17 provider tests; 395 full suite.
- Contract v1.1: idempotency model corrected to bifrost's actual semantics
(caught by real-lib TDD; the artifact-only review structurally could not).
Heid-panel reviewed (contract + code); idempotency-cache TTL pruning,
memory.* plane, and the combined two-plane server deferred (see Out of scope).
Onboard ratatoskr as the Tier-3 Bifrost consumer (durable memory +
persona/affect persistence Worldtree writes into). Lands the dependency
and the reviewed affect-plane spec; no production code yet — the patch
bump fires when the store lands at TDD-green.
- pyproject: bifrost>=0.6.1 in a `provider` optional-extra (+ starlette,
jsonschema); gitea PyPI index wired, bifrost sourced from it.
- uv.lock: bifrost 0.6.1 + jsonschema resolved from the gitea registry.
- docs/contracts/bifrost_affect_provider.contract.md: SQLite-backed,
conduit-opaque affect store (emit + ASGI wiring). Heid-panel-reviewed
(Groa/Hulda/Regin), amended for 8 text-ambiguity findings.
Establish agents/ as the home for Worldtree Tier-3 test-agent persona
sources (previously transient in /tmp). Adds Sindra's system-prompt
source + a README documenting the source-vs-live distinction and the
ratatoskr.tier3 patch sync command.
Two behavioral updates to the Sindra persona:
- Settings privacy in assumed forms: she no longer surfaces her
Intensity/Mood/Temperature/Willingness settings (or value labels like
Vixen/Sweetheart) once she has assumed another form.
- Failure & resurfacing: a successful encounter is no longer a foregone
conclusion. He can fail by disengaging or by failing to win her over
in the resist tiers; on failure she resurfaces as Sindra (reverting an
assumed form), tells him plainly, and offers comfort or a retry.
No version bump: test-agent assets + docs, no production-code change.
A design pass through /frontend-design on the web companion to retain
all debugging richness while raising usability and polish. Single-file
vanilla HTML/CSS/JS; no build, no CDN, no node_modules. HTTP surface,
endpoints, presentation contract, and INV-001..009 all unchanged.
Aesthetic direction: "Aurora telemetry instrument."
- Runic glyph + wordmark, live connection dot (idle = aurora-green,
streaming = pulsing cyan, error = dawn-red), session identity right-
aligned. Persona summary lives inline in the top bar with labeled
P/A/D micro-bars (centered baseline, [-1,1] mapped).
- Aurora signature band — thin cyan→blue→green shimmer animation
at the top edge, echoed on the setup card.
- Conversation column with turn-divider rules, cyan ❯ prompt echoes,
assistant text with a live cyan left-rule. Terminal events become
status chips (done = aurora-green, error = dawn-red, cancelled =
dawn-yellow) with metadata. Animated "awaiting first token · Ns"
indicator with elapsed counter.
- Telemetry column: tabs with live count badges that flash on new
events, sticky pane header carries the active pane name + copy
button, new-line flash highlight on each pane append, structured
empty states, persona pane structured render.
- Composer: real input, send/cancel buttons, streaming-lock state.
- Status line: keyboard legend + version footer.
- Centered setup card overlay with styled agent select on first open.
- All-monospace by intent (no-CDN constraint + right for a wire-
monitor tool); system mono stack. CSS-only motion (pulse, shimmer,
staggered rise, flash, awaiting dots).
Live Markdown — transcript response + thinking panes:
- Hand-rolled markdownSafe() renderer: escape-FIRST (INV-004
preserved), then a whitelist subset (headings, bold, italic, inline
code, fenced code blocks, ordered/unordered lists, blockquotes,
links). No raw HTML passthrough. Link href restricted to http(s):// +
conservative charset (rejects javascript:, attribute-breakout URLs).
- Per-turn live buffer; each text/thinking delta re-renders the
accumulated buffer in place (same pattern as the TUI's v0.9.0 live
MD rendering).
- Verified under node: rendering (bold/italic/code/lists/headings/
fences/safe links) + XSS neutralization (script tags, javascript:
schemes, attribute-breakout URLs, img onerror) all behave correctly.
- Tools/Debug/Persona panes stay literal monospace by deliberate
choice: they carry our structured audit lines + JSON, where MD would
corrupt readability (underscores in tool names, JSON braces, etc.).
Thinking pane per-turn breaks:
- Each turn lays down a labeled `── turn N ──` divider in the
thinking pane. The prior turn's live block is closed and a fresh
MD-rendered block opens below the divider, so each turn's chain-
of-thought is its own break-separated section.
Tests: 378 passing (no test change — server-side surface unchanged).
Markdown safety verified via standalone node harness exercising
rendering + 4 XSS vectors.
Minor per SemVer discipline: substantial new browser-side behavior
(live Markdown rendering, redesigned presentation) that consumers
would opt into via the next launch. No HTTP-caller adapts.
Initial AGENTS.md for the Codex-first coding discipline pilot.
Ratatoskr is the chosen pilot repo per operator decision 2026-05-29.
Defines: codex session's althing handle (ratatoskr-codex), reviewer
handle (ratatoskr-dev), branch + PR conventions, write-window
handshake protocol per discipline spec §7, attribution discipline,
out-of-scope files, and bootstrap protocol.
Depends on: brokkr-smithy/docs/codex-first-discipline.md v0.1
(brokkr-smithy commit 5dd061c, tag v0.5.3), corviduo-project-template/
docs/althing-monitoring.md (template commit 80e35d2) for inbound
transport canonical.
No version bump — convention/config-shape edit with no runtime effect.
Second Heid panel pass (thread 01KSPBMFRRQE) on the v0.16.0 tree:
Gróa returned zero findings; Hulda surfaced two minor tightening
items, both closed here.
1. test-gap — TestStreamFullEventVocab drove only 8 of 11 Event types
through the stream endpoint (omitted Error, Cancelled, AffectUpdate).
Serialization for all 11 was already covered by the presentation-
contract fixture tests; this was a stream-integration coverage gap.
- Added AffectUpdate to the vocab stream (non-terminal, coexists
with done).
- Added dedicated test_error_terminal_event + test_cancelled_terminal_event
(terminal events are mutually exclusive with done, so they can't
share one stream).
2. precision — the disconnect-cancel path caught bare `except Exception:
pass`, silently swallowing real CancelFailed / transport errors. The
contract intent is to swallow only the cooperative race
(CancelAlreadyCompleted). Narrowed: swallow CancelAlreadyCompleted /
CancelTurnNotFound as the no-op race; log unexpected cancel failures
as a structured stderr line for diagnosability. Never re-raises (we're
unwinding the cancelled generator and must not mask CancelledError).
Tests: +2 (376 → 378). Patch per SemVer discipline — coverage +
diagnosability tightening, no behavior change observable to callers.
Heid panel review (Gróa + Hulda, thread 01KSP5P6CSJH) on v0.15.0/
v0.15.1 surfaced one load-bearing bug + several precision items. This
pass closes them.
Load-bearing fix — cancel paths targeted the wrong turn_id:
- `_TURN_COUNTER` allocates browser-local ids (1, 2, 3…); the real
upstream Worldtree turn_id (e.g. 799) only arrives in the first SSE
event. The v0.15.x cancel/disconnect/shutdown paths posted to
/sessions/{sid}/turns/{LOCAL_ID}/cancel — wrong URL upstream.
- TurnHandle.upstream_response (dead field) → upstream_turn_id: int|None.
Captured from the first event's sse_id.turn_id in the stream
generator. All cancel paths now target it. Cancel before the upstream
stream starts (upstream_turn_id None) is a no-op
({"cancelled": false, "reason": "not_started"}).
- The old cancel tests mocked the local-id URL, so they encoded the bug;
rewritten to assert the UPSTREAM id is targeted.
Behavior change (minor-bump driver) — server-side end_user_id:
- create_app gains end_user_id kwarg; entrypoint reads
RATATOSKR_END_USER_ID and threads it in. POST /api/sessions uses
app.state.end_user_id, IGNORING any browser-supplied value (a client
can't impersonate an arbitrary end-user partition). JS no longer
sends end_user_id.
Precision fixes:
- Entrypoint missing-extras ImportError catch scoped to starlette/
uvicorn ONLY; baseline-dep / first-party import failures now
propagate as real tracebacks instead of masking as exit-12.
- Lifespan shutdown logs per-pending session_id + upstream_turn_id
(was a single aggregate count).
Tests (+18; 376 total):
- disconnect_triggers_upstream_cancel (INV-005 load-bearing — drives
the stream generator directly + cancels the consuming task; would
have caught the turn_id bug)
- cancel_targets_upstream_turn_id, cancel_before_started_is_noop,
cancel_failed_500
- server-side end_user_id: uses / ignores-body / omits-when-unset
- create_app: routes_registered / state_attached / factory_stored
- entrypoint: default_host / port_zero / happy_argv / open / no-open
- real_import_bug_propagates (precision guard)
- full_event_vocab at the stream-endpoint layer
Contract #16 amended: v0.16.0 amendment banner + INV-005/006 reworded
for upstream_turn_id + FN sketches corrected (server-side end_user_id,
upstream_response→upstream_turn_id, manual client lifecycle vs the
non-executable async-with sketch, not-started cancel branch).
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.
The four right-column panes (tools/debug/thinking/persona) have all
carried `wrap=True` since their introduction, but long lines were
still horizontally scrolling instead of wrapping. Root cause: Textual's
RichLog defaults `min_width=78`, and the App's render path takes
`max(renderable_width, min_width)` after the shrink step. The right
column is 1fr against the left column's 2fr, so at common terminal
widths (≤120 cols) the panes are narrower than 78 cells — the 78-cell
floor was forcing content to render at 78 wide and horizontally scroll
instead of wrapping at the actual pane width.
Set `min_width=0` on all four right-column RichLog instances so
shrink-to-widget-width can actually shrink. `wrap=True` now takes
effect on long lines as expected.
Patch per SemVer discipline: bug fix to a long-standing visible-UX
defect; no public API change, no behavior change for callers, every
existing caller continues to work — the substrate is more correct.
The CLI presenter at cli.py:201 carries its own isinstance check on
the Event union (mirroring the TUI presenter's same pattern). v0.11.0
+ v0.14.0 added AffectUpdate + AwaitingLlmFirstToken to the wire layer
but only updated the TUI presenter, leaving the CLI presenter stuck
on the pre-v0.11.0 event vocabulary.
Effect: `ratatoskr --send` crashes with AssertionError on any v0.28.0+
server emitting either of those events. Persona-enabled agents
(affect_update fires on every qualifying turn) and slow-first-token
turns (awaiting_llm_first_token heartbeats fire at 5s intervals) are
both reliably broken. Surfaced while running a wire-trace smoke test
against a Gemma4-based Tier 3 agent.
Patch-bump per SemVer discipline: corrects drift on the just-shipped
surface (v0.11.0 / v0.14.0 wire layer); no public signature change,
no new behavior, existing callers don't care — the bug fix lets them
keep working against current servers.
Routing additions in cli.py:
- AffectUpdate: stderr line with status + (for current) dominant_emotion
- AwaitingLlmFirstToken: stderr line with turn_id + elapsed (seconds)
Spec pin moved da93ca7 (v0.28.0) → 562001a (v0.29.0); vendored
conversation-api-spec.md + conversation_api.contract.md re-snapshotted.
The only material delta is Worldtree #201's awaiting_llm_first_token
SSE heartbeat — a top-level event (NOT a worker_phase extension, per
INV-053's three-field stability) that fires at a configurable interval
(default 5s) during the BuildingPrompt → CallingLLM gap.
Wire layer (sse_client.py):
- New `AwaitingLlmFirstToken` dataclass: sse_id / turn_id /
elapsed_ms_since_building_prompt (server-authoritative monotonic)
- Added to Event union + _envelope_for_type dispatch branch
- Without this, ratatoskr would crash on any slow-first-token turn
from a v0.29.0 server (unknown SSE event type → ValueError)
TUI layer (tui.py):
- Audit pipeline: per-event debug-pane line with elapsed in seconds
- Live transcript indicator: first heartbeat mounts a Static
("awaiting first token · 5.0s"); subsequent heartbeats update it
in place; any non-heartbeat event removes it (the gap closed)
- Turn-summary line now carries heartbeat count
- Indicator demoted via .awaiting-label CSS so it reads as ambient
progress, not content
Tests: 2 wire-layer (single + monotonic sequence) + 3 presenter
(audit line shape, single-mount semantic, indicator removal on gap
close). Suite: 318 passing.
Step 3 of the Worldtree #204 integration: visible persona-state UX.
Pairs with v0.11.0's AffectUpdate SSE event + v0.12.0's
get_persona_state HTTP client — together those gave the data; this
bump surfaces it.
Two surfaces (Option C: both):
Sticky persona-header (top of screen, dock=top, height=1):
- Shape: `agent_id · dominant_emotion · pad(P, A, D) · N emotions
active` — concise enough for at-a-glance scan above the chat
- Starts hidden via `.empty` CSS class; height collapses to 0 when
the agent has no persona surface
- Refreshes on AffectUpdate(status="current") snapshots
Persona TabPane (Ctrl+4):
- Full snapshot detail: dominant emotion, PAD axes with baseline +
delta, mood drift, active emotions with intensity + decay
(minutes-rounded), last_updated_at footer
- Replaced (not appended) on each new snapshot — snapshots are
absolute state, not incremental
Lifecycle:
- on_mount spawns a Textual worker that calls get_persona_state to
hydrate header + pane before turn 1
- PersonaNotConfigured (domari, muninn, Tier 3) → pane carries an
italic placeholder, header stays empty
- AgentNotAvailable / AuthScopeDenied / network error → italic
failure placeholder; audit-logged; never crashes
- Presenter's render() takes an optional `on_persona_snapshot`
callback so AffectUpdate(current) refreshes both surfaces during
a live turn (no widget coupling — App owns the callback)
Tests: 10 new (4 formatters, 3 presenter callback, 4 layout/binding/
hydration). Full suite: 313 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.
Spec pin moved 55101e9 (v0.19.0) → da93ca7 (v0.28.0); vendored
conversation-api-spec.md + conversation_api.contract.md re-snapshotted
from Worldtree at the new SHA. The only material delta consumed in
this bump is Worldtree #204's affect_update SSE event surface.
Wire layer (sse_client.py):
- New AffectUpdate dataclass: sse_id / status / turn_id / snapshot
(snapshot is None for status="scheduled")
- Added to Event union + _envelope_for_type dispatch branch
- Without this, ratatoskr would crash on any persona-enabled turn
from a v0.28.0 server (unknown SSE event type → ValueError)
TUI layer (tui.py):
- AffectUpdate routes through the v0.10.0 audit pipeline only — one
debug-pane line per arrival with dominant_emotion + PAD for
status="current", lightweight status+turn_id for status="scheduled"
- No transcript / tools / thinking pane writes — the persona UX shape
(Persona TabPane vs sticky header line) is deferred to a separate
bump pending operator direction
Tests: 2 new wire-layer tests for current+scheduled parsing + 2 new
presenter audit tests for routing and audit-line shape.
Not yet consumed: GET /agents/{id}/persona_state endpoint (step 2 of
the integration plan).