Compare commits

..

43 Commits

Author SHA1 Message Date
vh 263ec2917b fix: render the seeded first-message on the web UI (v0.19.9)
The #347 auto-seed worked (the greeting was in the session ledger at seq-0),
but the web UI never showed it: there was no GET /api/sessions/{id}/messages
route and startSession() went straight from create to persona/tools/admin
hydration, so the transcript only filled from the live turn stream + user
echoes — a seeded turn-0 was invisible.

- server: new proxy route GET /api/sessions/{id}/messages -> get_session_messages
  (mirrors the tools/bifrost proxies; status-preserving envelope).
- SPA: loadTranscript(sessionId) fetches it on open and renders existing turns
  (assistant -> .response .md-body via markdownSafe escape-first; user ->
  .prompt-echo via textContent), called after the workspace opens. Best-effort.

web_debug_surface contract amended (endpoint + loadTranscript). 2 web route
tests, suite 617 green. Playwright DOM check proved the render end-to-end
(drive the real UI -> Sindra's greeting bubble appears).
2026-07-06 15:19:56 -07:00
vh be171304f5 feat: authored first-message presets — auto-seed on session-create (v0.19.8)
Codifies 'give an agent a first message' (Worldtree #347): new module
ratatoskr.first_message (FIRST_MESSAGE_PRESETS + seed_preset_first_message)
seeds a preset agent's opening as a #347 authored turn-0 on every new session,
wired into all three create paths — cli._amain (--send --new), tui._resolve_then_run
(bare --new), web._create_session_endpoint (POST /api/sessions).

seed_preset_first_message is strictly best-effort (INV-001): it soft-guards its
inputs (return None, never assert), bounds the write with asyncio.wait_for so a
stalled /history can't block create (the CLI/TUI clients disable read timeout for
SSE), and swallows every exception except asyncio.CancelledError (which
propagates) — so it can NEVER raise into or block the session-create path it is
wired into. Per-content idempotency key → idempotent replay, no dup.

Seeded with ratatoskr:sindra, whose opening greeting moved out of her card:
her live system_prompt was PATCHed (non-destructive) to drop the Startup
workaround the #347 first-message now replaces.

Quality gate (both cross-frontier panels): heid-code-review returned zero
implementation drift (2 test-only fixups applied); heid-bug-hunt caught the
gap the conformance lens can't see — code matched the contract's narrow
ERROR_ROUTING but INV-001's 'never raises' is broader — driving the broad-except
+ soft-guard + wait_for hardening above.

Contract docs/contracts/first_message.contract.md (module-scoped, validated).
TDD: 12 unit + 1 web wire-in; the 3 existing sindra bind tests gained a
history-endpoint mock (creating a preset agent now auto-seeds). Suite 615 green,
ruff+mypy clean. Auto-seed live-proven generation-free against personal :8081.
2026-07-06 14:23:53 -07:00
vh 4aec3061d5 docs: #347 consumer side live-proven (201 path) on personal :8081
Grant applied (rule-based Heimdall allow, worldtree-dev). Smoke: create
mimir session -> seed -> 201 (seq=0, phase=seeded, turn_id=1798) -> GET
/messages reads back a plain role=assistant turn (model-invisible provenance
confirmed). Coverage-map #347 row + persistent-memory upgraded pending -> live-proven.
2026-07-06 13:01:57 -07:00
vh e643d38f58 fix: persona_state SET body → canonical {pad:{pleasure,arousal,dominance}} + re-vendor Tier-3 prose (v0.19.7)
worldtree-dev landed the Tier-3 persona/memory/persona_state prose docs
(c9e59ec) — shapes that serialize as freeform Any in the OpenAPI, so the
prose markdown is their source of truth. Re-vendored docs/conversation-api-spec.md
(tolerate_drift markdown pin; worldtree-spec-rev 879cefe→c9e59ec).

Consumer alignment: --set-persona-pad / _set_persona_probe was building
{pad:[list]}, but the canonical POST /sessions/{id}/persona_state body (#317)
is {pad:{pleasure,arousal,dominance}} (named dict). Aligned the probe to the
named dict + a len!=3 guard; updated contract #2's note, the set_persona_state
docstring, and the tests. The set_persona_state wrapper was already correct
(freeform pass-through) — only the CLI probe's body construction drifted.

Suite 602 green. (Also this session: heid-code-review on the #347 slice
returned unanimous zero drift across all three panel arms.)
2026-07-06 09:56:46 -07:00
vh 6bf2a84ccd feat: authored-history-write consumer side (Worldtree #347) — v0.19.6
Consumer side of Worldtree's #347 authored-history-write (the SillyTavern
first-message primitive), shipped via direct in-session TDD:

- write_authored_history (POST /sessions/{id}/history): v1 author=assistant,
  effects=none, per-session idempotency; body server-pinned (AuthoredWriteRequest
  extra=forbid) so null effects/claimed_original_at are omitted; 200 replay /
  201 fresh both return the AuthoredTurnResponse dict.
- AuthoredHistoryUnavailable: the hide-existence 404 (feature-absent / ungranted
  / session-absent, indistinguishable by design — INV-347-1) raised DISTINCT from
  SessionApiFailed so callers branch feature-absent and never capability-probe.
- get_session_messages (GET /sessions/{id}/messages): un-deferred as the seed
  read-back — confirms a seed renders as a normal role=assistant turn
  (model-invisible provenance).
- --seed-first-message probe: create session -> seed -> read-back; a 404 reports
  a benign feature-absent result (exit 0), never a capability-probe.

Contract #2 amended (2 FNs, validated OK). 19 new tests (12 wrapper + 7 cli),
suite 601 green. Coverage-map re-converged: REST 19/41 (the #347 route + the
messages read-back close the one gap the 2.3.0 re-vendor opened).

Live-proof pending the session.history.write grant (requested infra-ops).
2026-07-06 09:40:42 -07:00
vh 75da6767d3 pin: bump Worldtree spec 5810a26→879cefe (OpenAPI 2.2.0→2.3.0)
#347 authored-history-write shipped — one new REST path-group
POST /sessions/{id}/history + AuthoredTurnResponse schema. OpenAPI-only:
the prose conversation-api-spec.md + server conversation_api.contract.md
are byte-unchanged since 5810a26 (empty git-log delta), SSE schema
unchanged (#347 is event-silent). Consumer side NOT yet built —
POST /sessions/{id}/history is a fresh in-scope gap that re-opens the
v1 coverage-audit (Heimdall-gated hide-existence; 404 = feature-absent).
2026-07-06 09:11:03 -07:00
vh 8ac88ee536 memory: snapshot — authored-history #347 accepted + Sindra mood fix 2026-07-06 09:05:26 -07:00
vh 0a8784cc1b chore(scripts): self-service provider-store reset (sindra memory + persona)
Stop combined :8392 provider -> move memory.db+affect.db to a single rolling
backup (--hard skips it) -> restart -> verify empty. Codifies the manual
reset flow so it's a one-command CLI op. Rolling backup gitignored via *.db*.
2026-07-06 01:22:56 -07:00
vh 7156b25957 docs(proposals): note assistant-first provider constraint (#347 rev 1.1 validation)
Worldtree accepted the v1 wire validation green; contract rev 1.1 folds all three
consumer flags. Flag #1 surfaced a real provider constraint: first-message makes
the assistant seq 0 -> vLLM/openai_compat tolerate it (sindra unaffected), but
Anthropic-family providers 400 on an assistant-first array. Consumer must gate
first-message on provider compat; provider-agnostic normalization deferred.
2026-07-05 17:10:44 -07:00
vh 022accfa7b docs(proposals): note engine-imposed hide-existence consumer constraint (Worldtree #347)
Worldtree accepted the primitive as design item #347 (Worldtree-owned). Locked
constraint: Heimdall-gated with hide-existence — ungranted tenants get 404 (not
403), no advertised capability. Ratatoskr consumer side must tolerate per-tenant
absence: treat 404 as feature-absent -> graceful fallback, never capability-probe.
2026-07-05 11:31:53 -07:00
vh c457520ae4 docs(proposals): authored history write primitive — heid-panel-reviewed v1 scope
Non-generating ledger-seed primitive for Worldtree, driven by ratatoskr's
first-message need. v1 narrowed to append-only + create-time; bounded effects
enum (none|memory_import); edit/regenerate + batch-import split to future
primitives. Positions taken: distinct sub-resource, event-silence for
default-off seed, seeded lifecycle phase, structured provenance, user-author
restricted. Consumer proposal for worldtree-dev (engine owner).
2026-07-05 11:06:11 -07:00
vh 9ca931e148 memory: snapshot — R29→R30 affect-calibration arc (R30 φ0 config-faithful)
R29 flat-affect finding shipped as Worldtree's A1 anchor fix (decay_anchor=
baseline_pad, positive_p_cap removed; demo v1.0.0b14); R30 Phase-1 φ0 measured
against it = config-faithful (φ0≈0.95, c≈0, trait-flat, φ_max→0.96). R28 closed.
Standing follow-ons (hybrid decay redesign, gain-only v1, per-axis A/D, Phase-2,
relational verify) are others' calls. Data on diag/r29-pad-series +
diag/r30-phi0-step-response. No ratatoskr code change (main tip v0.19.5).
2026-07-03 13:44:20 -07:00
vh c77ff913f0 memory: snapshot — R28 open (promotion-worthiness reframe, P00 corpus delivered, standing by to run) + relational-dynamics arc LIVE on demo (v1.0.0b9) 2026-07-02 07:49:54 -07:00
vh 4a3551254f docs(diagnostics): R28 P00 stratified injection-corpus for brokkr-smithy salience/promotion-worthiness eval 2026-07-02 07:29:39 -07:00
vh 0b7489f74d memory: snapshot — Sindra affect/memory investigation; 4 upstream items driven (PAD over-regulation, memory-plane healthy, salience #335 + brokkr R-target, relation_context Wave-0) 2026-07-01 22:23:58 -07:00
vh 3dac5d3b44 memory: snapshot — persona-pane rebuild (relation_edge/1 + trend) + canonical affect-NL vendored (v0.19.5); relation_context/agency flag WAD 2026-07-01 14:47:32 -07:00
vh a99f2473b6 feat(web): persona pane shows the CANONICAL affect->NL Worldtree injects (v0.19.5)
The pane now renders the LITERAL mood word + relationship directive Worldtree
context-injects into the agent — adopted from Worldtree's canon, not invented:

- canonMood(pad) mirrors Worldtree describe_pad (valence×arousal grid, ±0.3 bands);
  for sindra's PAD the canonical render is "neutral" — an invented octant vocab
  would have said "faintly excited" and MISLED. Adopting canonical is the point.
- canonDirective(rel) mirrors render_d2_canonical byte-exact: "...warmth is clear
  warm regard; ability trust is strong; ...; speak with direct warmth; ..." — the
  exact stance instruction the agent receives (which makes the WAD "stranger"
  relation_context read even more incoherent, as flagged to worldtree-dev).
- Both VERIFIED byte-exact against Worldtree's OWN renderer on the live snapshot.
- Canon vendored (docs/vendor/worldtree-persona-canon/) + drift-pinned in
  .corviduo-canonicals.toml (canonical_drift green); flat browser form
  (static/persona_render_canon.json) regenerated by scripts/build_persona_canon.py
  via Worldtree's authoritative loader. Reference-impl posture: adopt canonical.
- Fail-open (canon absent -> lines omit); INV-004 esc() preserved.

JS syntax clean. Refresh + drive turns to see the canonical NL under mood + each
relation.
2026-07-01 13:51:37 -07:00
vh ca46a93171 feat(web): persona pane renders the relation_edge/1 affect model + per-value trend (v0.19.4)
The persona/affect pane read snap.valence (the pre-#265 shape) while Worldtree now
emits snap.relations (relation_edge/1) — so the whole trust/warmth model rendered as
an empty "valence (0)". Now renders the real signal, self-labelled:

- MOOD (PAD, transient): pleasure/arousal/dominance with a one-word descriptor each.
- RELATION → <target> (stage: <relation_context>): trust·ability / benevolence /
  integrity + warmth, each as value + evidence_count (n=) — the durable social model.
- Per-value TREND: Δ-vs-previous (▲/▼) + a unicode sparkline auto-scaled to the value's
  own observed range (flat when sub-0.01 stable, so noise isn't amplified). History
  accumulates client-side, one sample/turn (deduped by emitted_at), capped at 24.
- Falls back to the legacy snap.valence for an older emitter; INV-001 (no fabricated
  Tier-1 fields) + INV-004 (every cell escaped) preserved. Supersedes the #18-D2
  valence assumption + retires the stale "regard dead axis" note.

Verified: render logic asserted in node against the REAL affect.db snapshot + a
perturbed 2nd sample (relations rendered, no "valence (0)", Δ ▲ shown, 2-char
sparkline builds, INV-004 holds). JS syntax clean. No server change (static served
per-request) — refresh + drive turns to watch the trends build.
2026-07-01 13:20:24 -07:00
vh 85a2b95428 memory: snapshot — web debug-surface parity primary (v0.19.3), heid review, embedding-loop resolved, Tier-3 reset 2026-07-01 12:57:10 -07:00
vh 75dec016eb fix(web): heid-review findings — SSE lifecycle teardown + test-shape gaps (v0.19.3)
Cross-frontier panel (Gróa/Hulda/Regin) on the v0.19.2 web surface, triaged:

- FIX (Gróa #1, drift): the turn EventSource `onerror` (raw transport drop)
  now calls hideThinkingNote() — a drop mid-reasoning no longer leaves the
  "<Agent> is pondering…" line + its setInterval running (INV-LIFECYCLE).
- FIX (Gróa #4 + Hulda #1, convergent drift): openAdminEvents now closes the
  EventSource + clears state.adminES on `stream_error` (server signalled end)
  and on a PERMANENT onerror (readyState CLOSED) — native EventSource no longer
  auto-reconnects into a retry loop; transient CONNECTING drops still reconnect.
- TEST (Gróa #2 + Hulda #3): test_routes_registered asserts the 3 new routes;
  test_state_attached asserts app.state.admin_key (create_app POST-001/002).
- TEST (Gróa #3 + Regin #3): AdminEvents stream_error-on-connect-failure test —
  upstream non-200 -> exactly one `stream_error` frame, then ends (POST-003).
- CONTRACT (Hulda #2 + Regin #2, accepted): clarified the Tools inventory
  renders NAMES only by design (descriptions live in the BifrostState pane);
  code unchanged. Also lands the web_debug_surface contract as the trail.

Accepted-no-op: 403-bifrost / non-404-tools tests (identical code path to the
tested 404). Panel found ZERO functional server-side drift; INV-004 escaping
confirmed clean across the new panes. 60 web tests pass; JS + ruff clean.
2026-07-01 12:52:44 -07:00
vh a0a9d5f5e4 feat(web): debug-surface parity — BifrostState + AdminEvents + Tools panes, PAD-poll fix, reasoning indicator
Bring the browser surface to TUI parity as the primary debug surface:

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

Admin key wired through entrypoint -> create_app. 9 new respx/route tests
(admin-bearer override, filter unit, SSE stream-filter); 59 web tests pass.
Live-proven against ratatoskr:sindra (bifrost connected, both caps; 253
thinking events -> indicator fires; affect emit lands -> PAD poll catches it).
2026-07-01 12:33:11 -07:00
vh fc1e1487c7 memory: snapshot — v1 coverage-audit converged (REST 17/40, zero in-scope gaps)
Refresh the decay-prone in-flight section from the stale 2026-06-20
(#17/#18) state to the converged-audit state: REST 17/40 covered with
zero in-scope gaps, SSE 11/11, Bifrost planes 8/8; debug-observability
core complete (v0.19.0); standing pins v1.0.0b2 + bifrost 1.0.0; admin
key scopes verified. Recent-decisions log unchanged.
2026-07-01 00:08:53 -07:00
vh af07a2329a feat(#2): Tier-2 — transient characters + persona-state write; audit converges
v1 coverage-audit: the last in-scope client I/O points. The audit now
CONVERGES — REST 17/40 covered with zero in-scope gaps (23 excluded-by-
design), SSE 11/11, Bifrost planes 8/8.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Name-for-name swap of stream_turn -> stream_turn_resilient at the two
remaining consumer loops (tui.py:1321, web/server.py:294) + their
imports. No behavioral change on the happy path (resilient == stream
when there is no drop); suite 518 green; touched lines ruff+mypy clean
(pre-existing tui/web lint debt left untouched per surgical rule).
2026-06-30 15:46:04 -07:00
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
vh af67ad995c chore(deps): repin bifrost==1.0.0 (first stable; wire v0.6 frozen)
Bifrost shipped 1.0.0 — first stable release, freezing wire v0.6 (the
surface ratatoskr's combined-builder consumer #18 already adopted).
Non-breaking: byte-identical on the wire to the prior >=0.10.0 pin.
Switched floor pin -> exact pin per the stable-substrate posture.
Suite 506 green against bifrost 1.0.0.
2026-06-29 11:10:01 -07:00
vh 719e4d605b feat: web SPA bind — add 'combined' (:8392) both-plane option as default
The bind dropdown offered only memory/affect single-plane binds; #18's
composite endpoint (:8392, both planes in one session) was never reachable
from the SPA. Add 'combined' as the default-selected option, keeping
memory-only / affect-only for single-plane isolation diagnostics.

- endpoint_for_plane: combined -> :8392 (sessions.py)
- web server: accept bifrost_plane="combined" (server.py)
- dropdown: combined (:8392) default-selected, single-plane retained (index.html)
- #17 contract: endpoint_for_plane FN + plane-selector spec updated to combined
- tests: endpoint_for_plane combined, server combined bind -> :8392, dropdown default

Suite 506 green. Live-verified on :8765 (current code).
2026-06-20 16:47:03 -07:00
vh c5c8ecf9d5 memory: snapshot — #17 CLOSED + #18 composite final leg PROVEN end-to-end
The Worldtree-driven composite :8392 smoke ran and is proven + persisted:
one bound session drove 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 stores.
infra-ops allowlisted :8392 (01KVHWJGTT); #17 closed in the tracker. No open
legs remain on the composite; repo at a converged checkpoint.
2026-06-20 12:59:50 -07:00
vh 4f16ba588d memory: snapshot — #18 CLOSED end-to-end + v0.18.0 (composite both-plane binding)
#18 D1 SHIPPED: build_combined_provider_app on :8392 wraps bifrost 0.10.0's public
build_combined_app over both stores + the shared affect read route; one bound WT session
drives memory.* AND affect.* through one endpoint; op-feed plane='combined' per-path.
Shipped v0.17.15 (affect.fetch, the strong-or-absent prerequisite) -> v0.17.16 (composite)
-> v0.17.17 (#17 op-feed field-name fix) -> v0.18.0 (publishing minor). Suite 503 green.

Live-smoke PROVEN at wire+dispatch (real stores + bifrost 0.10.0 on a running :8392):
handshake grants both caps, PAD read route serves real sindra PAD, both planes dispatch at
one bound session_id. WT-driven turn gated on infra-ops adding :8392 to WT's
BIFROST_CLIENT_ALLOWED_HOSTS (requested).

New decisions: reference-impl-adopt-canonical (operator); v1-derived-from-WT-I/O-coverage
(operator). New foot-guns: memory-store check_same_thread bug (same as affect D2, exposed by
the contract-mandated search test via TestClient); :8392 infra-allowlist gate; heid-review
test-fidelity nudge cascaded into 2 latent-bug fixes.
2026-06-19 23:57:01 -07:00
vh 359dbb1436 release: v0.18.0 — composite both-plane Bifrost binding (#18 closed)
Publishing-minor for the #18 arc: ratatoskr now exposes a COMPOSITE Bifrost
endpoint (build_combined_app, :8392) so one bound Worldtree session drives BOTH
the memory.* and affect.* planes through a single endpoint — completing the
Tier-3 consumer round-trip (durable memory + live PAD from one binding).

Shipped as patches v0.17.15 (affect.fetch prerequisite) → v0.17.16 (composite) →
v0.17.17 (#17 op-feed fix); this minor publishes the milestone.

Live-smoke (against real stores + bifrost 0.10.0 on a running :8392): handshake
grants BOTH caps by store presence; the PAD read route serves real sindra PAD;
both planes dispatch through the one endpoint at a single bound session_id with
the op-feed deriving plane per path. The remaining WT-driven turn is gated on
infra-ops adding :8392 to Worldtree's BIFROST_CLIENT_ALLOWED_HOSTS (requested).
2026-06-19 23:48:33 -07:00
36 changed files with 18611 additions and 154 deletions
+54
View File
@@ -89,3 +89,57 @@ canonical_path = "scripts/contract_drift_check.py"
consumer_path = "scripts/contract_drift_check.py"
pinned_sha256_16 = "23271287ac488da4"
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 = "36148179601453a0"
pinned_at = "2026-07-06T16:09:05+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 = "c656a789caceef14"
pinned_at = "2026-07-06T16:51:09+00:00"
tolerate_drift = true # prose reference; OpenAPI+SSE are the gates
# Worldtree persona render canons (d2) — the deterministic affect->NL the agent is
# context-injected. The web persona pane renders mood + relationship-directive BYTE-EXACT
# from these (via the flat src/ratatoskr/web/static/persona_render_canon.json, regenerated
# by scripts/build_persona_canon.py). Drift here => rerun that regen with Worldtree's venv.
[[pins]]
id = "worldtree-persona-mood-render-canon-v1"
canonical_source = "Worldtree"
canonical_path = "core/persona/canon/d2-mood-render-canon-v1.json"
consumer_path = "docs/vendor/worldtree-persona-canon/d2-mood-render-canon-v1.json"
pinned_sha256_16 = "e2f124fed3ee8d42"
pinned_at = "2026-07-01T21:00:00+00:00"
[[pins]]
id = "worldtree-persona-d2-render-canon-v1"
canonical_source = "Worldtree"
canonical_path = "core/persona/canon/d2-render-canon-v1.json"
consumer_path = "docs/vendor/worldtree-persona-canon/d2-render-canon-v1.json"
pinned_sha256_16 = "606bba5fdcc60b6b"
pinned_at = "2026-07-01T21:00:00+00:00"
+17 -7
View File
@@ -7,24 +7,34 @@ documents the pin, the vendored artifacts, and the bump procedure.
| Field | Value |
|---|---|
| Worldtree git SHA | `f1b59f8cd6fe41e497d0be9dad9d3110451f0d9a` |
| Worldtree HEAD message | `Merge #299: adopt bifrost v0.6 memory scope wire (scope_any/scope_all)` |
| Pinned on | 2026-06-17 |
| Pinned by | ratatoskr-dev (bump for #297/#298 — cold recall closed end-to-end) |
| Worldtree version at pin | `v0.35.16` |
| Worldtree git SHA | `c9e59ec` |
| Worldtree HEAD message | `docs: document Tier-3 persona/memory schemas + persona_state SET body (OpenAPI 2.3.0)` |
| Pinned on | 2026-07-06 |
| Pinned by | ratatoskr-dev (re-vendor prose markdown — Tier-3 persona/memory/persona_state consumer shapes) |
| Worldtree version at pin | `v1.0.0b22` |
## Pin history
| Date | SHA | Version | Notable deltas consumed |
|---|---|---|---|
| 2026-07-06 | `c9e59ec` | v1.0.0b22 | **Re-vendor the prose markdown — Tier-3 consumer shapes documented.** `c9e59ec` (docs-only, OpenAPI byte-unchanged vs `879cefe`) adds `docs/conversation-api-spec.md` § "Tier 3 — Consumer-defined agents": the persona / memory / persona_state SET-body shapes that serialize as freeform `Any` in the OpenAPI (so prose is their source of truth). Drove a consumer fix: `--set-persona-pad` now sends the canonical `{pad:{pleasure,arousal,dominance}}` named dict (was `{pad:[list]}`) — #317, `v0.19.7`. Foot-guns encoded: persona.ocean single-letter `{O,C,E,A,N}` on `/agents/define` (spelled-out → 422, the #348 mismatch) vs spelled-out on `POST /characters`; memory `{embedder_version, tier3_dreaming}`, stm_* deprecated, allows_world_scope removed→422; only `valence` still 422s. `pin:`-only for the markdown; the `v0.19.7` bump rode the persona_state code fix. |
| 2026-07-06 | `879cefe` | v1.0.0b22 | **Re-vendor OpenAPI 2.2.0→2.3.0 — Worldtree shipped #347 authored-history-write.** One new REST path-group: `POST /sessions/{session_id}/history` (the authored-history-write primitive) + the `AuthoredTurnResponse` schema (openapi path count 40→41). #347 is **OpenAPI-only** — the prose `conversation-api-spec.md` + server `conversation_api.contract.md` are byte-unchanged since the 5810a26 pin (empty `git log` delta), so those `tolerate_drift` pins stay clean; the SSE schema is unchanged (#347 is event-silent by design). **Consumer side NOT yet built**`POST /sessions/{id}/history` is a fresh in-scope ⬜ gap in `docs/coverage-map.md` (re-opens the v1 coverage-audit with exactly one gap; Heimdall-gated hide-existence → consumer treats 404 as feature-absent). `pin:`-only, no version bump. |
| 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-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 |
## 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.
- `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.
**Authoritative (FROZEN, machine-readable — the drift gates):**
- `docs/conversation-api-openapi.json` — copy of `Worldtree/docs/conversation-api-openapi.json` (OpenAPI `info.version` **2.3.0**). The frozen v1 REST wire (41 path-groups; 2.3.0 added `POST /sessions/{session_id}/history` per #347). 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**. Re-vendored at `c9e59ec` (2026-07-06) to carry the § "Tier 3 — Consumer-defined agents" subsections (persona/memory/persona_state SET body) that serialize as freeform `Any` in the OpenAPI JSON — so the **prose is the source of truth for those consumer shapes** (e.g. persona.ocean single-letter `{O,C,E,A,N}` on `/agents/define`; `POST /sessions/{id}/persona_state` body `{pad:{pleasure,arousal,dominance}}`). Elsewhere the OpenAPI/SSE JSON above remain authoritative. 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
the live `~/development/Worldtree` checkout. Update them only when
+142
View File
@@ -0,0 +1,142 @@
---
contract_version: "2.1"
module: "ratatoskr.first_message"
purpose: "Per-agent authored first-message presets — seed an agent's opening as a #347 authored turn-0 onto new sessions (CLI + web), the durable replacement for a system-prompt startup instruction."
touches:
- src/ratatoskr/first_message.py
- tests/test_first_message.py
scope: >
Per-agent authored first-message presets (Worldtree #347 consumer feature).
When a new session is created for an agent that has a preset opening, seed it
as a #347 authored first-message (POST /sessions/{id}/history, author=assistant,
seq-0) so the session opens in-character before the user speaks — the durable
replacement for a system-prompt "startup" instruction. Two entry points:
`preset_for` (lookup) and `seed_preset_first_message` (best-effort seed).
Consumed by ratatoskr.cli (the `--new` session path) and ratatoskr.web.server
(the POST /api/sessions endpoint). Depends on ratatoskr.sessions
(write_authored_history + its exceptions); no core.* / worldtree.* imports.
depends_on:
- "httpx"
- "ratatoskr.sessions"
used_by:
- "ratatoskr.cli"
- "ratatoskr.web.server"
language: "python"
complexity: "low"
estimated_loc: 60
confidence: 0.9
assumptions:
- "write_authored_history (contract #2 amendment 2026-07-06) is the seed primitive: 200/201 → ack dict, 404 → AuthoredHistoryUnavailable (hide-existence), other non-2xx → SessionApiFailed."
- "The preset registry is a static in-module dict keyed by agent_id; editing it is how an operator tunes an agent's opening. Seeded with ratatoskr:sindra only."
- "Auto-seed is BEST-EFFORT and MUST NOT block session creation: an instance without the session.history.write grant returns the hide-404, which is swallowed (session opens with no seeded greeting)."
---
# First-message presets — authored openings on session-create (#347)
## Context
`ratatoskr.first_message` holds per-agent authored-opening presets and seeds them
onto new sessions via the #347 authored-history-write primitive. It is the
durable form of "give an agent a first message": instead of a system-prompt
`Startup:` instruction (a workaround for the pre-#347 world where the assistant
could not author turn-0), the opening lives as a real seeded assistant turn-0.
Consumed at both session-create sites — `ratatoskr.cli._amain` (the `--new` path)
and `ratatoskr.web.server._create_session_endpoint` (POST /api/sessions) — so
every new session for a preset agent opens in-character regardless of surface.
## Data flow
**In:** a live `httpx.AsyncClient` (caller-owned, base_url + bearer set), a fresh
`session_id`, and the bound `agent_id`.
**Out:** on a preset agent, one `POST /sessions/{session_id}/history` (author=assistant,
the preset text, per-content idempotency key). Returns the seeded content on
success, else `None`.
**Side effects:** at most one outbound authored-history write; never raises to the
caller (best-effort).
## Invariants
- **INV-001 [hard]**: `seed_preset_first_message` NEVER raises (the sole exception is
`asyncio.CancelledError`, which propagates — cancellation is not a seed failure) and
NEVER blocks session creation. It soft-guards its inputs (a bad arg returns `None`,
not `AssertionError`), bounds the write with `asyncio.wait_for(_SEED_TIMEOUT_S)` so a
stalled `/history` can't hang the create path, and swallows EVERY other exception (the
hide-404, `SessionApiFailed`, `httpx.HTTPError`, `TimeoutError`, and any unexpected
error) → `None`. The `broad-except` is deliberate: this helper is wired INTO three
session-create paths, so any escape would abort a create that already succeeded.
- **INV-002 [hard]**: a no-preset agent issues ZERO HTTP (early return before any
request).
- **INV-003 [hard]**: the seed body is the preset text verbatim, author="assistant",
with a per-content idempotency key (`"ratatoskr-preset-" + sha256(text)[:12]`), so
a repeat seed of the same session+preset is an idempotent 200 replay, never a
duplicate turn.
- **INV-004 [hard]**: no `core.*` / `worldtree.*` imports (reference-consumer
boundary; verified by `tests/test_no_worldtree_imports.py`, which rglobs every
`.py` under `src/ratatoskr/` — this module included, so no per-module import
test is needed here).
## Out of scope
- **Multi-turn / scripted openers.** v1 seeds exactly one assistant turn-0. A
multi-message opening scene is a future concern.
- **Runtime/remote preset config.** The registry is an in-module dict; no file/DB/env
loading. Add that only when a second consumer needs operator-editable presets.
- **Non-assistant authors.** v1 is author=assistant only (matches #347 v1); a
user/system opener is deferred with the #347 engine surface.
- **TUI-only surfaces.** Both real session-create paths (CLI + web) are wired; the
bare-TUI picker resumes existing sessions (no create), so it needs no seed.
---
```contract
FN preset_for(agent_id: str) -> str | None
BRIEF: Return the authored first-message preset for agent_id, or None when the agent has no preset. Pure dict lookup over FIRST_MESSAGE_PRESETS.
PRE: [PRE-001 hard] agent_id is a non-empty str -- assert agent_id and isinstance(agent_id, str)
POST: [POST-001 return_value] returns FIRST_MESSAGE_PRESETS.get(agent_id) (str for a preset agent, None otherwise)
STEPS:
1. [setup, prescriptive] assert PRE-001
2. [sequential, prescriptive] RETURN FIRST_MESSAGE_PRESETS.get(agent_id)
TESTS:
preset_hit [happy]: preset_for("ratatoskr:sindra") is a non-empty str
preset_miss [happy]: preset_for("mimir") is None
empty_agent_id [adversarial]: preset_for("") → AssertionError
FN seed_preset_first_message(client: httpx.AsyncClient, session_id: str, agent_id: str) -> str | None
BRIEF: Best-effort seed of an agent's preset opening as a #347 authored first-message on session_id. If agent_id has a preset, POST it via write_authored_history (author=assistant, per-content idempotency key, the await bounded by asyncio.wait_for(_SEED_TIMEOUT_S)) and return the seeded content; on no-preset, a malformed input, OR ANY exception except asyncio.CancelledError, return None WITHOUT raising. Never raises (except CancelledError, which propagates) and never blocks session creation — it is wired into three create paths.
PRE: [PRE-001 hard] client is not None -- soft-guarded: return None (NOT assert) if violated, so a wiring bug can't crash the create path (INV-001)
PRE: [PRE-002 hard] session_id is a non-empty str -- soft-guarded: return None if violated
PRE: [PRE-003 hard] agent_id is a non-empty str -- soft-guarded: return None if violated (also guards FIRST_MESSAGE_PRESETS.get against a non-hashable/non-str id)
POST: [POST-001 return_value] preset agent + successful write → returns the preset text; no-preset, malformed input, OR any swallowed failure → None
POST: [POST-002 side_effect] a no-preset / malformed-input call issues ZERO HTTP; a preset agent issues exactly one POST /sessions/{session_id}/history with body author="assistant", content=preset, idempotency_key="ratatoskr-preset-"+sha256(preset)[:12], the await bounded by _SEED_TIMEOUT_S so a stalled response cannot block
ERROR_ROUTING:
asyncio.CancelledError:
local_handling: RE-RAISE (cancellation is not a seed failure; never swallow it — and it is a BaseException, so `except Exception` would miss it anyway)
flow_control: propagate
state_recovery: n/a
any other Exception (hide-404 AuthoredHistoryUnavailable, SessionApiFailed 409/422/etc., httpx.HTTPError, TimeoutError from wait_for, any unexpected error):
local_handling: swallow; return None
flow_control: continue (never blocks session create)
state_recovery: session opens with no seeded greeting
STEPS:
1. [setup, prescriptive] Soft-guard: IF agent_id is not a non-empty str: RETURN None (before any dict lookup — guards a non-hashable id)
2. [sequential, prescriptive] content = FIRST_MESSAGE_PRESETS.get(agent_id); IF content is None: RETURN None (INV-002 — zero HTTP)
3. [sequential, prescriptive] Soft-guard: IF client is None OR session_id is not a non-empty str: RETURN None
4. [sequential, prescriptive] key = "ratatoskr-preset-" + sha256(content utf-8)[:12]
5. [sequential, prescriptive] TRY: await asyncio.wait_for(write_authored_history(client, session_id, content=content, idempotency_key=key), timeout=_SEED_TIMEOUT_S)
tool: { destructive: false, idempotent: true, read_only: false, open_world: false }
6. [branch, prescriptive] EXCEPT asyncio.CancelledError: RAISE; EXCEPT Exception: RETURN None
7. [cleanup, prescriptive] RETURN content
TESTS:
seeds_preset [happy,tracer]: preset agent, mock 201 → returns the preset text; exactly one POST /sessions/{id}/history; body author="assistant" + content=preset + idempotency_key="ratatoskr-preset-"+sha256(preset)[:12]
no_preset_zero_http [happy]: agent "mimir" → returns None; NO HTTP issued
feature_absent_swallowed [error]: preset agent, mock 404 session_not_found → returns None, no raise
session_api_failed_swallowed [error]: preset agent, mock 409 → returns None, no raise
transport_error_swallowed [error]: preset agent, mock httpx.ConnectError → returns None, no raise
unexpected_exception_swallowed [error]: preset agent, write raises ValueError → returns None, no raise (INV-001 broad never-raise)
cancellation_propagates [error]: preset agent, write raises asyncio.CancelledError → RE-RAISED (never swallowed)
malformed_agent_id_no_http [adversarial]: agent_id=123 (non-str) OR "" → None; NO HTTP; no raise
empty_session_id [adversarial]: session_id="" (preset agent) → None (soft guard); NO HTTP; no raise
```
+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)
ERROR_ROUTING:
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
state_recovery: none (no events yielded yet)
httpx.ReadError | httpx.RemoteProtocolError | httpx.ReadTimeout:
@@ -129,7 +129,8 @@ ERROR_ROUTING:
STEPS:
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}
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
3. [loop, flexibility=prescriptive] FOR EACH sse_event in event_source.aiter_sse():
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
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)
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_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
@@ -293,3 +297,51 @@ TESTS:
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)
```
## 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).
```
+7 -7
View File
@@ -1,7 +1,7 @@
---
contract_version: "2.1"
target_module: "ratatoskr.sessions + ratatoskr.provider (+ cli/tui/web trigger surfaces)"
scope: "Issue #17 v1 — make the canary chat client self-drive AND observe its own Bifrost provider. Two parts. (1) BIND: `create_session` gains an optional single-plane Bifrost binding (`BifrostBinding{endpoint_url, scope}`) authenticated with a DISTINCT consumer Heimdall key; Worldtree runs the handshake synchronously at POST /sessions, so handshake failure is a session-create failure (502), surfaced on the create path. A plane selector (`memory`→:8391 / `affect`→:8390) + the consumer key thread through CLI / TUI / web; bound-state is visible. (2) OBSERVE: a structured op-feed in the provider, instrumented at the DISPATCH/ASGI layer (where the JWT ctx / session_id lives — bifrost passes ctx to upsert_many but NOT to search/get/delete, so the existing store-method stdout shim cannot see session_id), emitting JSONL {session_id, plane, op, req_summary, resp_summary, status, ts}. OPERATOR DECISIONS LOCKED: single-plane-per-session for v1 (composite endpoint fronting both planes is PARKED — vNext); op-feed with session-level correlation for v1 (turn-correlated debug-pane UI is PARKED — needs turn_id, TBD). Provider store scope semantics MUST NOT change (AND-parity with bifrost's reference store is a hard constraint). Direct in-session TDD; live-smoke against personal Worldtree is the load-bearing acceptance gate."
scope: "Issue #17 v1 — make the canary chat client self-drive AND observe its own Bifrost provider. Two parts. (1) BIND: `create_session` gains an optional single-plane Bifrost binding (`BifrostBinding{endpoint_url, scope}`) authenticated with a DISTINCT consumer Heimdall key; Worldtree runs the handshake synchronously at POST /sessions, so handshake failure is a session-create failure (502), surfaced on the create path. A plane selector (`memory`→:8391 / `affect`→:8390; `combined`→:8392 added post-#17 — the #18 composite, the web default) + the consumer key thread through CLI / TUI / web; bound-state is visible. (2) OBSERVE: a structured op-feed in the provider, instrumented at the DISPATCH/ASGI layer (where the JWT ctx / session_id lives — bifrost passes ctx to upsert_many but NOT to search/get/delete, so the existing store-method stdout shim cannot see session_id), emitting JSONL {session_id, plane, op, req_summary, resp_summary, status, ts}. OPERATOR DECISIONS LOCKED: single-plane-per-session for v1 (composite endpoint fronting both planes was PARKED at #17 — later shipped as #18 and surfaced in the web bind as the `combined` plane); op-feed with session-level correlation for v1 (turn-correlated debug-pane UI is PARKED — needs turn_id, TBD). Provider store scope semantics MUST NOT change (AND-parity with bifrost's reference store is a hard constraint). Direct in-session TDD; live-smoke against personal Worldtree is the load-bearing acceptance gate."
depends_on:
- "httpx"
- "ratatoskr.sessions"
@@ -92,8 +92,8 @@ async def create_session(
def endpoint_for_plane(plane: str, base_host: str) -> str:
"""'memory'->:8391, 'affect'->:8390 → f'http://{base_host}:{port}'. The
Worldtree-visible base URL. See FN endpoint_for_plane."""
"""'memory'->:8391, 'affect'->:8390, 'combined'->:8392 (#18 composite) →
f'http://{base_host}:{port}'. The Worldtree-visible base URL. See FN endpoint_for_plane."""
```
```python
@@ -218,12 +218,12 @@ STEPS:
```contract
FN endpoint_for_plane(plane: str, base_host: str) -> str
BRIEF: Map a plane name to the Worldtree-visible provider base URL (memory->:8391, affect->:8390).
BRIEF: Map a plane name to the Worldtree-visible provider base URL (memory->:8391, affect->:8390, combined->:8392 — the #18 composite both-plane endpoint, surfaced post-#17).
PRE: [PRE-001 hard] plane in {"memory", "affect"} -- else ValueError
POST: [POST-001 return_value] returns f"http://{base_host}:{port}", port 8391 (memory) / 8390 (affect) -- assert
PRE: [PRE-001 hard] plane in {"memory", "affect", "combined"} -- else ValueError
POST: [POST-001 return_value] returns f"http://{base_host}:{port}", port 8391 (memory) / 8390 (affect) / 8392 (combined) -- assert
STEPS:
1. port = 8391 if plane == "memory" else 8390
1. port = {"memory": 8391, "affect": 8390, "combined": 8392}[plane]
2. return the Worldtree-VISIBLE base URL (not client loopback); HTTPS relaxation is allowlist-side, not a URL concern
```
+264
View File
@@ -206,3 +206,267 @@ TESTS:
limit_above_max [adversarial]: limit=300 → AssertionError; no HTTP issued
empty_cursor [adversarial]: cursor="" → AssertionError; no HTTP issued
```
## Amendment 2026-06-30 — boot-time introspection reads (v1 coverage-audit: capabilities+me)
The v1 coverage-audit added two read-only server-introspection endpoints as
cheap debug primitives (surfaced via a new `ratatoskr --whoami` one-shot). Both
mirror `get_persona_state`: GET, 200 → parsed dict verbatim, any non-200 →
`SessionApiFailed`. The frozen OpenAPI types both responses as freeform objects,
so the wrappers return `dict[str, Any]` (not a typed dataclass).
```contract
FN get_me(client: httpx.AsyncClient) -> dict[str, Any]
BRIEF: GET /me — the authenticated principal's identity + key metadata (spec §GET /me). Boot-time whoami: verify the key without agent-config side effects. Returns parsed JSON verbatim; spec documents {user_id, scopes, tier, display_name?, key_id?, key_label?, ...} with optional fields OMITTED (not null). Read-only, rate-exempt, no audit emission.
PRE: [PRE-001 hard] client is not None -- assert client is not None
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
ERROR_ROUTING:
HTTP non-200 (incl. 401 bad/absent key when auth enabled):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none (caller decides: bad key → re-key; degraded tier="unknown" is still a 200)
STEPS:
1. [setup, prescriptive] assert client is not None
2. [sequential, prescriptive] resp = await client.get("/me")
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy_authenticated [happy,tracer]: 200 {user_id, scopes, tier, key_id} → dict returned verbatim
anonymous_dev_mode: 200 {user_id:"anonymous", tier:"anonymous"} → dict; no key_* fields (omitted)
401_raises [error]: 401 → SessionApiFailed(status=401)
FN get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]
BRIEF: GET /capabilities — server capability discovery (spec §Ephemeral Templates). Returns {ephemeral_templates: {echo: {allowed_models, default_model, system_prompt_max_bytes}}}. Any authenticated caller may read it (no instantiate scope). Parsed dict verbatim; any non-200 → SessionApiFailed.
PRE: [PRE-001 hard] client is not None -- assert client is not None
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
ERROR_ROUTING:
HTTP non-200:
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none
STEPS:
1. [setup, prescriptive] assert client is not None
2. [sequential, prescriptive] resp = await client.get("/capabilities")
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy [happy]: 200 {ephemeral_templates:{echo:{...}}} → dict returned verbatim
non_200_raises [error]: 500 → SessionApiFailed(status=500)
```
## Amendment 2026-07-01 — session tool introspection (v1 coverage-audit)
Owner-scoped tool-inventory read (spec #183, `GET /sessions/{id}/tools`),
surfaced in the TUI Tools pane on session-attach. Same shape as the other
introspection wrappers: GET, 200 → parsed dict verbatim, non-200 →
`SessionApiFailed`. Reachable with the consumer key (no admin scope), unlike the
admin variant `GET /admin/sessions/{id}/tools`.
```contract
FN get_session_tools(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]
BRIEF: GET /sessions/{session_id}/tools — owner-scoped merged tool inventory (spec #183) the LLM saw at turn-fire: {agent_id, builtin_tools: [...], bifrost_tools: [{name, description, parameters}, ...]}. Owner gate (ctx.user_id == session.user_id); cross-owner → 404 session_not_found (existence-hiding), revoked → 401 auth_revoked. Parsed dict verbatim; any non-200 → SessionApiFailed.
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] session_id is non-empty str -- assert session_id and isinstance(session_id, str)
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
ERROR_ROUTING:
HTTP non-200 (incl. 404 session_not_found cross-owner/unknown, 401 auth_revoked):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none
STEPS:
1. [setup, prescriptive] assert PRE-001, PRE-002
2. [sequential, prescriptive] resp = await client.get(f"/sessions/{session_id}/tools")
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy [happy,tracer]: 200 {agent_id, builtin_tools:[], bifrost_tools:[{name,...}]} → dict verbatim
cross_owner_404 [error]: 404 session_not_found → SessionApiFailed(status=404)
empty_session_id [adversarial]: "" → AssertionError; no HTTP issued
```
## Amendment 2026-07-01 — admin BifrostState read (v1 coverage-audit)
Admin-scoped Bifrost dispatch-state read (spec #176, `GET /admin/sessions/{id}/bifrost`),
surfaced in the TUI BifrostState pane on session-attach. The first admin-key
consumer in ratatoskr: requires the `admin.sessions.read` scope, so the request
OVERRIDES the Authorization header with the caller-supplied `admin_key` (distinct
from the client's default consumer key). Same result-shape convention as the
other introspection wrappers: 200 → parsed dict verbatim, non-200 → `SessionApiFailed`.
```contract
FN get_session_bifrost(client: httpx.AsyncClient, session_id: str, *, admin_key: str) -> dict[str, Any]
BRIEF: GET /admin/sessions/{session_id}/bifrost — admin-scoped live Bifrost binding (spec #176): {endpoint_url, consumer_id, connected, capabilities_granted, tools:[{name, description}]}. Requires admin.sessions.read; the request sets Authorization: Bearer <admin_key> (override), NOT the client's default consumer bearer. Parsed dict verbatim; any non-200 → SessionApiFailed — notably 403 auth_scope_denied and 404 session_not_bifrost_bound.
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] session_id is non-empty str -- assert session_id and isinstance(session_id, str)
PRE: [PRE-003 hard] admin_key is non-empty str -- assert admin_key and isinstance(admin_key, str)
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
POST: [POST-002 state_change] the outbound request Authorization header == f"Bearer {admin_key}" (override) -- assert request.headers["Authorization"] == "Bearer " + admin_key
ERROR_ROUTING:
HTTP non-200 (incl. 403 auth_scope_denied, 404 session_not_found / session_not_bifrost_bound):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none (caller decides: 403 → key lacks scope; 404 not-bound → benign unbound session)
STEPS:
1. [setup, prescriptive] assert PRE-001..PRE-003
2. [sequential, prescriptive] resp = await client.get(f"/admin/sessions/{session_id}/bifrost", headers={"Authorization": f"Bearer {admin_key}"})
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy_uses_admin_bearer [happy,tracer]: 200 {endpoint_url, connected, capabilities_granted, tools} → dict verbatim; request Authorization == "Bearer <admin_key>" (override)
scope_denied_403 [error]: 403 → SessionApiFailed(status=403)
not_bound_404 [error]: 404 session_not_bifrost_bound → SessionApiFailed(status=404)
empty_admin_key [adversarial]: admin_key="" → AssertionError; no HTTP issued
```
## Amendment 2026-07-01 — Tier-2: transient characters + persona-state write (v1 coverage-audit)
The last in-scope client I/O points. Transient-character CRUD (#161) surfaced
via a `--characters` one-shot lifecycle probe; persona-state write surfaced via
`--set-persona-pad "p,a,d"` (requires `--session`). All mirror the existing
wrappers: parsed dict verbatim (or None on 204), any off-status → SessionApiFailed.
**Note:** `set_persona_state`'s request body is FREEFORM — the frozen OpenAPI 2.2.0
declares no request schema and the prose spec documents only the GET counterpart,
so the caller supplies the snapshot shape. **Canonical (worldtree-dev prose #317,
`c9e59ec`): `{pad:{pleasure,arousal,dominance}}` — a named-key dict, NOT a list;
`--set-persona-pad` builds + sends the named dict (each float in [-1,1]).**
```contract
FN list_character_models(client) -> dict[str, Any]
BRIEF: GET /models/available-for-characters (character.read). Returns {items:[{name, description, thinking}]}. Non-200 → SessionApiFailed.
PRE: [PRE-001 hard] client is not None
POST: [POST-001 return_value] on 200 returns resp.json() unmodified
STEPS:
1. [sequential, prescriptive] resp = await client.get("/models/available-for-characters"); IF 200 RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
list_models [happy,tracer]: 200 {items:[{name:"fast"}]} → dict verbatim
FN create_character(client, character: dict, *, state: dict | None = None) -> dict[str, Any]
BRIEF: POST /characters (character.write). Body {character, state}. Returns 201 {character_id, ttl_expires_at}; non-201 → SessionApiFailed.
PRE: [PRE-001 hard] client is not None; [PRE-002 hard] character is a non-empty dict
POST: [POST-001 return_value] on 201 returns resp.json(); [POST-002 side_effect] outbound body == {"character": <arg>, "state": <state|null>}
STEPS:
1. [sequential, prescriptive] resp = await client.post("/characters", json={"character": character, "state": state}); IF 201 RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
create [happy]: 201 → {character_id}; body is {character, state:null}
create_403 [error]: 403 auth_scope_denied → SessionApiFailed(403)
FN get_character_state(client, character_id: str) -> dict[str, Any]
BRIEF: GET /characters/{id}/state (character.read). Live PAD/emotions snapshot; refreshes TTL. Non-200 → SessionApiFailed.
PRE: [PRE-001 hard] client not None; [PRE-002 hard] character_id non-empty str
POST: [POST-001 return_value] on 200 returns resp.json()
STEPS:
1. [sequential, prescriptive] resp = await client.get(f"/characters/{character_id}/state"); IF 200 RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
get_state [happy]: 200 {pad:[...]} → dict verbatim
FN delete_character(client, character_id: str) -> None
BRIEF: DELETE /characters/{id} (character.write). 200/204 → None; other → SessionApiFailed.
PRE: [PRE-001 hard] client not None; [PRE-002 hard] character_id non-empty str
POST: [POST-001 return_value] on 200/204 returns None
STEPS:
1. [sequential, prescriptive] resp = await client.delete(f"/characters/{character_id}"); IF status in (200,204) RETURN None; ELSE RAISE SessionApiFailed
TESTS:
delete [happy]: 204 → None
FN set_persona_state(client, session_id: str, snapshot: dict) -> None
BRIEF: POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection). Request body is the FREEFORM snapshot (caller-supplied; unpinned in the frozen surface). 204 → None; other → SessionApiFailed.
PRE: [PRE-001 hard] client not None; [PRE-002 hard] session_id non-empty str; [PRE-003 hard] snapshot is a dict
POST: [POST-001 return_value] on 204 returns None; [POST-002 side_effect] outbound body == snapshot verbatim
STEPS:
1. [sequential, prescriptive] resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot); IF 204 RETURN None; ELSE RAISE SessionApiFailed
TESTS:
happy [happy]: 204 → None; body == {"pad":{"pleasure","arousal","dominance"}} verbatim (canonical named-key dict, #317)
non_204 [error]: 422 → SessionApiFailed(422)
```
## Amendment 2026-07-06 — authored-history write (#347, v1 coverage-audit re-open)
Worldtree shipped #347 (authored-history-write) as OpenAPI 2.3.0: a new
`POST /sessions/{session_id}/history` primitive that writes ONE model-visible
turn into a session's ledger AS the bound agent, WITHOUT a generation and
WITHOUT lived-turn side effects (the SillyTavern "first message"). The re-vendor
(2.2.0→2.3.0, pin `879cefe`) re-opened the v1 coverage-audit with this one new
in-scope REST path-group; this amendment closes it on the consumer side and also
un-defers `GET /sessions/{id}/messages` (previously §Out of scope) as the seed's
read-back.
**Hide-existence (server INV-347-1) — the load-bearing consumer contract.** The
`session.history.write` grant is checked FIRST — an ungranted caller (or a
non-owner, or an unknown session) gets a 404 **byte-identical** to a genuine
`session_not_found`, never a 403/409/422 that would reveal the feature exists.
The consumer MUST honor this: treat 404 as **feature-absent**, fall back (a
production consumer to a model-generated greeting), and NEVER capability-probe to
tell feature-absent from ungranted from session-absent. The wrapper encodes it by
raising a DISTINCT `AuthoredHistoryUnavailable` on 404 (NOT `SessionApiFailed`),
so a caller branches feature-absent without inspecting a status code.
**Request body — v1-minimal, wire-pinned by the server.** The frozen OpenAPI 2.3.0
exports an empty request schema, but the server pins `AuthoredWriteRequest`
(`extra="forbid"`): `{author, content, idempotency_key, effects?,
claimed_original_at?}`. v1: `author="assistant"` (only value), `content` (UTF-8,
server-bounded at `authored_content_max_bytes`=8192), `idempotency_key` (REQUIRED,
per-session dedup), `effects` omitted (== "none"; only value). Because
`extra="forbid"`, the wrapper omits `effects`/`claimed_original_at` when None
(never sends null). Success is 201 (fresh) OR 200 (idempotent replay,
byte-identical body); both return the `AuthoredTurnResponse` `{author,
content_chars, injected_at, phase, seq, session_id, turn_id}` verbatim (provenance
is audit-only, NEVER on this body — INV-347-7).
**Assistant-first provider constraint (deferred, inert for the probe).** A
create-time first-message makes the assistant seq-0 (assistant-first history);
Anthropic-family providers 400 the *next generation*, vLLM/openai_compat tolerate
it. The `--seed-first-message` probe seeds but does NOT generate, so the
constraint is inert for the probe — a real consumer that then generates must bind
an assistant-first-tolerant provider.
```contract
FN write_authored_history(client: httpx.AsyncClient, session_id: str, *, content: str, idempotency_key: str, author: str = "assistant", effects: str | None = None, claimed_original_at: str | None = None) -> dict[str, Any]
BRIEF: POST /sessions/{session_id}/history — the #347 authored-history-write primitive (write one model-visible turn as the bound agent, no generation, no side effects). Body {author, content, idempotency_key} + "effects"/"claimed_original_at" only when non-None (server AuthoredWriteRequest is extra="forbid"). Success 200 (replay) or 201 (fresh) → AuthoredTurnResponse dict verbatim. 404 → AuthoredHistoryUnavailable (hide-existence: feature-absent/ungranted/session-absent, indistinguishable by design — consumer falls back, never probes). Any other non-2xx → SessionApiFailed.
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] session_id is a non-empty str -- assert session_id and isinstance(session_id, str)
PRE: [PRE-003 hard] content is a non-empty str -- assert content and isinstance(content, str)
PRE: [PRE-004 hard] idempotency_key is a non-empty str -- assert idempotency_key and isinstance(idempotency_key, str)
PRE: [PRE-005 hard] author is a non-empty str -- assert author and isinstance(author, str)
POST: [POST-001 side_effect] exactly one POST to /sessions/{session_id}/history; body == {"author": author, "content": content, "idempotency_key": idempotency_key} plus "effects" iff effects is not None plus "claimed_original_at" iff claimed_original_at is not None (no null-valued keys — extra="forbid")
POST: [POST-002 return_value] on 200 or 201 returns resp.json() unmodified
ERROR_ROUTING:
HTTP 404 (hide-existence session_not_found):
local_handling: raise AuthoredHistoryUnavailable(session_id=session_id)
flow_control: abort
state_recovery: caller treats as feature-absent; fall back to a model-generated greeting; NEVER capability-probe (INV-347-1)
HTTP other non-2xx (incl. 409 generation_active, 422 content_too_long/validation_failed, 401 auth_revoked, 410 session_retired):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none (409 retryable; 422 caller bug/oversize)
STEPS:
1. [setup, flexibility=prescriptive] assert PRE-001..PRE-005
2. [sequential, flexibility=prescriptive] body = {"author": author, "content": content, "idempotency_key": idempotency_key}; IF effects is not None: body["effects"] = effects; IF claimed_original_at is not None: body["claimed_original_at"] = claimed_original_at
3. [sequential, flexibility=prescriptive] resp = await client.post(f"/sessions/{session_id}/history", json=body)
tool: { destructive: false, idempotent: true, read_only: false, open_world: false }
4. [branch, flexibility=prescriptive] IF resp.status_code in (200, 201): RETURN resp.json(); ELIF resp.status_code == 404: RAISE AuthoredHistoryUnavailable(session_id=session_id); ELSE RAISE SessionApiFailed(status=resp.status_code, body=resp.content)
TESTS:
happy_fresh_201 [happy,tracer]: 201 {author:"assistant", seq:0, phase:"seeded", turn_id, content_chars, session_id, injected_at} → dict verbatim; outbound body == {"author":"assistant","content":<c>,"idempotency_key":<k>} exactly (no effects/claimed_original_at keys)
happy_replay_200 [happy]: 200 (same-key replay, byte-identical body) → dict verbatim
body_includes_effects [trace]: effects="none" → outbound body has "effects":"none"; claimed_original_at="2020-01-01T00:00:00Z" → body has that key too
hide_existence_404 [error]: 404 {error_code:"session_not_found"} → raises AuthoredHistoryUnavailable(session_id=<arg>), NOT SessionApiFailed
generation_active_409 [error]: 409 {error_code:"generation_active"} → SessionApiFailed(status=409)
content_too_long_422 [error]: 422 {error_code:"content_too_long"} → SessionApiFailed(status=422)
empty_content [adversarial]: content="" → AssertionError; no HTTP issued
empty_idempotency_key [adversarial]: idempotency_key="" → AssertionError; no HTTP issued
empty_session_id [adversarial]: session_id="" → AssertionError; no HTTP issued
FN get_session_messages(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]
BRIEF: GET /sessions/{session_id}/messages — the session's message history (spec §GET /sessions/{id}/messages), un-deferred as the #347 probe's read-back so a seeded turn can be confirmed to render as a normal role=assistant message (model-invisible provenance — a seed is indistinguishable from a lived turn on read). Returns {session_id, items:[{seq, role, content, ...}], next_cursor} verbatim. Owner-scoped; any non-200 → SessionApiFailed. v1 reads the server default page (no pagination params — the probe reads a fresh 1-message session; add limit/cursor when a caller needs scrollback).
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] session_id is a non-empty str -- assert session_id and isinstance(session_id, str)
POST: [POST-001 return_value] on 200 returns resp.json() unmodified
ERROR_ROUTING:
HTTP non-200 (incl. 404 session_not_found cross-owner/unknown):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none
STEPS:
1. [setup, flexibility=prescriptive] assert PRE-001, PRE-002
2. [sequential, flexibility=prescriptive] resp = await client.get(f"/sessions/{session_id}/messages")
3. [branch, flexibility=prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy [happy]: 200 {session_id, items:[{seq:0, role:"assistant", content:"…"}], next_cursor:null} → dict verbatim
not_found_404 [error]: 404 → SessionApiFailed(status=404)
empty_session_id [adversarial]: "" → AssertionError; no HTTP issued
```
+61
View File
@@ -372,3 +372,64 @@ test layer.
- Issue #7 (mid-stream robustness, `MalformedSseData`) — landed; #6's
pre/in-alt-screen split is orthogonal to #7's empty-data/malformed
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.
```
@@ -0,0 +1,178 @@
---
contract_version: "2.1"
module: "ratatoskr.web"
purpose: "v0.19.2 web debug-surface parity: 3 admin/debug panes (Tools inventory, BifrostState, AdminEvents SSE) proxied server-side with the admin key server-held, plus a reliable PAD-refresh poll and a non-engine reasoning indicator in the transcript."
target_module: "ratatoskr.web (server.py routes + entrypoint.py + static/index.html)"
scope: "v0.19.2 web debug-surface parity — bring the browser surface (now the PRIMARY debug surface) to TUI parity. THREE new admin/debug panes proxied server-side + TWO transcript affordances. (1) Tools inventory: GET /api/sessions/{id}/tools proxies owner-scoped get_session_tools into the tools pane (what the LLM HAS at turn-fire), above the live tool events. (2) BifrostState pane: GET /api/sessions/{id}/bifrost proxies admin-scoped get_session_bifrost; the admin key is SERVER-HELD (app.state.admin_key from RATATOSKR_ADMIN_API_KEY), never sent to the browser. (3) AdminEvents pane: GET /api/admin/events is an SSE proxy of stream_admin_events, session-filtered SERVER-side (heartbeats + other-session events dropped), re-emitted under a fixed 'admin_event' name so every dotted type renders with one browser listener. (4) PAD refresh: the persona/affect pane polls a bounded window instead of a single 2s shot that raced the post-turn-async affect.emit. (5) Reasoning indicator: an ephemeral, clearly-non-engine transcript line on `thinking` deltas, cleared when text begins. Direct in-session TDD (the #17/#18 pattern); this contract is authored post-implementation to anchor the heid code review (the client wrappers get_session_tools/get_session_bifrost/stream_admin_events are already contracted in the sessions/sse_client specs — this contract governs the WEB proxy + presenter surface only)."
depends_on:
- "httpx"
- "starlette"
- "ratatoskr.sessions" # get_session_tools, get_session_bifrost, SessionApiFailed
- "ratatoskr.sse_client" # stream_admin_events, AdminEvent, SseConnectFailed/Dropped
used_by:
- "ratatoskr.web.entrypoint" # passes admin_key=RATATOSKR_ADMIN_API_KEY into create_app
language: "python + vanilla JS (single-file SPA, no build)"
complexity: "medium"
estimated_loc: 290
confidence: 0.8
assumptions:
- "The three client wrappers exist and are already contracted: get_session_tools(client, session_id)->dict (owner-scoped, consumer bearer; non-200 -> SessionApiFailed), get_session_bifrost(client, session_id, *, admin_key)->dict (OVERRIDES Authorization with admin_key; non-200 -> SessionApiFailed), stream_admin_events(client, *, admin_key)->AsyncIterator[AdminEvent] (non-200 -> SseConnectFailed; mid-drop -> SseConnectionDropped). The web routes are thin proxies over them; they add NO new upstream semantics."
- "AdminEvent = {id:int, type:str, timestamp:str|None, data:dict}. data MOST carry session_id (INV-049). type is a dotted namespace (session.*/turn.*/key.*/system.*)."
- "The web SPA is a single static/index.html served per-request via FileResponse (edits land on browser refresh; server code changes need a restart). Model/tool/admin content is UNTRUSTED text (INV-004) — every render path escapes first (esc() via textContent, or JSON.stringify wrapped in esc())."
- "The internal-LAN trust model (0.0.0.0, no auth/TLS/CORS) is deliberate operator direction. Admin-scoped DATA becoming LAN-visible is accepted under that model; the admin KEY must nonetheless never cross to the browser."
- "Tests: respx mocks the upstream endpoints (absolute w.example URLs) driven through the TestClient; the AdminEvents SSE proxy is tested with a finite mocked SSE byte-stream asserting the filter + fixed event name. Live-proven against ratatoskr:sindra on personal :8081."
# ─────────────────────────────────────────────────────────────────────────────
functions:
- name: "_session_tools_endpoint"
signature: "async _session_tools_endpoint(request: Request) -> JSONResponse"
description: "GET /api/sessions/{session_id}/tools — proxy owner-scoped tool inventory."
preconditions:
- "session_id in path_params."
postconditions:
- "POST-001: 200 with the upstream inventory dict verbatim on success."
- "POST-002: on SessionApiFailed(status) -> JSONResponse({error_code:'session_tools_unavailable', status}, status_code=status) — status-preserving."
steps: "Open client_factory() client; await get_session_tools(client, session_id); return 200. Except SessionApiFailed -> status-preserving envelope."
flexibility: "prescriptive"
- name: "_session_messages_endpoint"
signature: "async _session_messages_endpoint(request: Request) -> JSONResponse"
description: "GET /api/sessions/{session_id}/messages — proxy the session's message history so the SPA renders existing turns on open (notably a #347 authored first-message seeded at create-time; without it a seeded session's transcript is blank until the user speaks)."
preconditions:
- "session_id in path_params."
postconditions:
- "POST-001: 200 with the upstream {session_id, items, next_cursor} dict verbatim on success."
- "POST-002: on SessionApiFailed(status) -> JSONResponse({error_code:'session_messages_unavailable', status}, status_code=status) — status-preserving."
steps: "Open client_factory() client; await get_session_messages(client, session_id); return 200. Except SessionApiFailed -> status-preserving envelope."
flexibility: "prescriptive"
- name: "_session_bifrost_endpoint"
signature: "async _session_bifrost_endpoint(request: Request) -> JSONResponse"
description: "GET /api/sessions/{session_id}/bifrost — proxy admin-scoped Bifrost dispatch state."
preconditions:
- "session_id in path_params."
- "PRE-001 (fail-visible): app.state.admin_key must be truthy; else 400 admin_key_not_configured with NO upstream call."
postconditions:
- "POST-001: the admin key is read from app.state.admin_key ONLY; it is passed to get_session_bifrost(admin_key=...) and NEVER placed in a response body or surfaced to the browser."
- "POST-002: 200 with the upstream state dict verbatim on success."
- "POST-003: on SessionApiFailed(status) -> {error_code:'bifrost_state_unavailable', status} at status_code=status (notably 404 not-bound, 403 scope-denied)."
steps: "If not admin_key -> 400. Open client; await get_session_bifrost(client, session_id, admin_key=admin_key); 200. Except SessionApiFailed -> status-preserving envelope."
flexibility: "prescriptive"
- name: "_admin_event_matches_web"
signature: "_admin_event_matches_web(ev: AdminEvent, session_id: str | None) -> bool"
description: "AdminEvents session-filter (mirrors the TUI _admin_event_matches, design-brief §6)."
postconditions:
- "POST-001: ev.type == 'system.heartbeat' -> False (keepalive noise dropped)."
- "POST-002: ev.type.startswith('system.') (non-heartbeat) -> True (stream-integrity signals always pass)."
- "POST-003: otherwise -> True IFF session_id is not None AND ev.data.get('session_id') == session_id (per-session scoping; a None session_id forwards NO non-system event)."
flexibility: "prescriptive"
- name: "_admin_events_endpoint"
signature: "async _admin_events_endpoint(request: Request) -> Response"
description: "GET /api/admin/events?session_id=... — SSE proxy of stream_admin_events, session-filtered server-side."
preconditions:
- "PRE-001 (fail-visible): app.state.admin_key truthy; else 400 admin_key_not_configured with NO stream opened."
postconditions:
- "POST-001: returns StreamingResponse(media_type='text/event-stream'); the admin key never crosses to the browser."
- "POST-002: ONLY events passing _admin_event_matches_web(ev, session_id) are forwarded; each is re-emitted under the FIXED SSE event name 'admin_event' with {id,type,timestamp,data} in the payload (the real dotted type rides in the payload, so one browser listener renders every type — nothing silently dropped by name)."
- "POST-003: SseConnectFailed/SseConnectionDropped/MalformedSseId/MalformedSseData -> a single 'stream_error' SSE frame, then the stream ends (best-effort; never raises to the browser)."
- "POST-004: asyncio.CancelledError (browser disconnect) re-raises to unwind the generator; the upstream client is aclose()'d in finally on every exit path."
steps: "If not admin_key -> 400. gen(): open client; async-for ev in stream_admin_events(admin_key); skip unless _admin_event_matches_web; yield _format_sse('admin_event', {...}). Except SSE errors -> yield stream_error. Except CancelledError -> raise. Finally aclose(). Return StreamingResponse(gen())."
flexibility: "prescriptive"
- name: "create_app (amendment)"
signature: "create_app(client_factory, *, end_user_id=None, bifrost_consumer_key=None, bifrost_visible_host=None, affect_read_url=None, admin_key=None) -> Starlette"
description: "New optional admin_key param stored at app.state.admin_key; entrypoint passes RATATOSKR_ADMIN_API_KEY. Three new routes registered."
postconditions:
- "POST-001: app.state.admin_key = admin_key (default None -> the two admin routes fail-visible per their PRE-001)."
- "POST-002: routes /api/sessions/{session_id}/tools, /api/sessions/{session_id}/bifrost, /api/admin/events added; existing routes unchanged."
flexibility: "closed"
- name: "reasoning indicator (index.html: showThinkingNote / hideThinkingNote)"
signature: "showThinkingNote() ; hideThinkingNote() // called from the turn SSE loop"
description: "Ephemeral transcript affordance signalling reasoning inference — clearly NOT engine output."
postconditions:
- "POST-001: on the first `thinking` delta, an italic '<Agent> <phrase>' line (✦ glyph, rotating phrase) is shown; it supersedes any live 'awaiting first token' heartbeat."
- "POST-002: the agent display name is derived from state.agentId and rendered via textContent (NEVER innerHTML) — INV-004 holds even for an adversarial agent_id."
- "POST-003: it is removed the instant the first `text` delta arrives, and on any terminal (done/error/cancelled); the rotation interval is cleared on removal (no leaked setInterval)."
flexibility: "prescriptive"
- name: "PAD refresh poll (index.html: terminal() done-branch)"
signature: "on Done: poll loadPersona over [1500,3500,6500,10500]ms"
description: "Catch the post-turn-async affect.emit without racing it (replaces the single 2s shot)."
postconditions:
- "POST-001: loadAffect sets state.lastAffectAt = snap.emitted_at; the poll captures beforeAt and stops (settled) once state.lastAffectAt !== beforeAt."
- "POST-002: a scheduled poll no-ops if a NEW turn has started (state.turnId truthy) or already settled — no refresh of a stale agent, no unbounded polling."
flexibility: "open"
- name: "loadTranscript (index.html)"
signature: "async loadTranscript(sessionId) -> void"
description: "On session open, GET /api/sessions/{id}/messages and render each EXISTING turn into #transcript — notably a #347 authored first-message seeded at create-time (which lives in the ledger, not the live turn stream, so without this the transcript is blank until the user speaks)."
postconditions:
- "POST-001: assistant items render as a .response .md-body bubble via markdownSafe(content) (escape-first whitelist, same path as appendResponse); user items render as a .prompt-echo via textContent — no upstream content reaches innerHTML unescaped (INV-004)."
- "POST-002: any non-200, fetch error, or parse error is swallowed (best-effort) — a blank transcript is acceptable; opening the workspace is never blocked."
flexibility: "prescriptive"
- name: "web pane renderers (index.html: renderToolsInventory / renderBifrostState / openAdminEvents)"
signature: "renderToolsInventory(inv) ; renderBifrostState(b) ; openAdminEvents(sessionId)"
description: "Render the three new surfaces; all content escaped (INV-004)."
postconditions:
- "POST-001: every dynamic value (agent_id, tool names/descriptions, endpoint, caps, admin event type + data) is passed through esc() or esc(JSON.stringify(...)); no upstream string reaches innerHTML unescaped."
- "POST-002: openAdminEvents closes a prior EventSource before opening a new one (state.adminES); the admin data blob renders via esc(JSON.stringify(d.data))."
- "POST-003: renderToolsInventory prepends the static inventory ABOVE live tool events without clobbering them (a re-render replaces only the .tools-inventory block)."
- "POST-004: the Tools inventory renders tool NAMES only — a compact comma-joined summary ('what does the LLM have', the debug glance); per-tool DESCRIPTIONS are surfaced in the BifrostState pane's tools list, deliberately NOT duplicated here. (Heid panel Hulda/Regin precision finding — accepted: contract wording clarified, code unchanged; the earlier 'names/descriptions' phrasing in INV-004 refers to the SET of value types that MAY appear across the new panes and must be escaped, not a mandate that every pane render descriptions.)"
flexibility: "open"
- name: "renderAffectPane + trend (v0.19.4 — relation_edge/1 render + sparkline)"
signature: "renderAffectPane(snap) ; pushAffectHistory(snap) ; sparkline(vals) ; trendDelta(vals)"
description: "Render the Tier-3 affect snapshot as PAD mood + the durable per-entity relational model, each value with a Δ-vs-previous + a session-lived sparkline."
postconditions:
- "POST-001: reads snap.relations (relation_edge/1: target_entity + trust_ability/benevolence/integrity + warmth as {value,confidence,evidence_count} + agency + relation_context + obligation_balance) — the CURRENT Worldtree emit shape; falls back to the legacy flat snap.valence for an older emitter. SUPERSEDES the #18-D2 contract's valence assumption (Worldtree's #265 Vili rework replaced valence/regard with the relation_edge/trust model; the old renderer read snap.valence and showed an empty 'valence (0)' — the bug this fixes)."
- "POST-002: each metric shows current value + Δ-vs-previous (▲/▼) + a unicode sparkline auto-scaled to its OWN observed range (flat ▄ when sub-0.01 stable — no noise amplification), drawn from AFFECT_HIST (rolling, HIST_CAP=24, session-lived)."
- "POST-003: pushAffectHistory dedupes by emitted_at so the ~4x/turn post-turn PAD poll contributes ONE sample/turn; history is CLIENT-side only (lost on reload — durable cross-session history via a provider-side snapshot log is a deferred follow-up, NOT built here)."
- "POST-004: INV-001 honesty — no fabricated Tier-1 fields (no synthesized dominant_emotion). INV-004 — head() escapes its whole argument (incl. target_entity + relation_context from the snapshot) and metric() escapes every cell; numeric values go through toFixed, never innerHTML-raw."
flexibility: "open"
- name: "canonical affect-NL (v0.19.5 — vendored Worldtree d2 render canons)"
signature: "canonMood(pad) ; canonDirective(rel) ; loadPersonaCanon()"
description: "Render the LITERAL mood word + relationship directive Worldtree context-injects into the agent, byte-exact to Worldtree's own describe_pad + render_d2_canonical."
postconditions:
- "POST-001: DETERMINISTIC, no LLM. canonMood mirrors describe_pad (valence×arousal grid + strict ±0.3 bands + dominance clause); canonDirective mirrors render_d2_canonical (interval band-cut lookup + per-band phrase assembly + cross-axis low-trust-precedence behavior clause). BOTH VERIFIED BYTE-EXACT against Worldtree's own renderer run on the live snapshot (the reference harness re-runs Worldtree's functions + asserts string equality — reproducible)."
- "POST-002: the canon DATA is VENDORED (docs/vendor/worldtree-persona-canon/{d2-mood-render-canon-v1,d2-render-canon-v1}.json), pinned drift-gated in .corviduo-canonicals.toml (worldtree-persona-{mood,d2}-render-canon-v1); the flat browser form (static/persona_render_canon.json, served /static) is regenerated by scripts/build_persona_canon.py via Worldtree's OWN authoritative loader. Reference-impl posture: ADOPT the dep's canonical render, do NOT invent vocab — an invented 'faintly excited' would MISLEAD where the canonical (±0.3 bands) says 'neutral'."
- "POST-003: fail-open — canon absent (fetch fails) → the canonical lines OMIT, the structured pane still renders. The canon-derived strings are esc()'d before the DOM for INV-004 consistency."
flexibility: "open"
invariants:
- "INV-004 (untrusted-render): ALL model / tool / admin / agent-supplied text is escaped before entering the DOM (esc via textContent, or esc(JSON.stringify)). No new render path introduces an innerHTML sink for upstream content. This is the highest-value review target — the new JS render paths are NOT unit-tested."
- "INV-ADMIN-KEY: the admin key exists ONLY at app.state.admin_key (from RATATOSKR_ADMIN_API_KEY). It is never serialized into any response, never sent to the browser, never logged. The browser receives only the session-filtered RESULT of admin-scoped reads."
- "INV-FILTER: AdminEvents filtering happens SERVER-side (_admin_event_matches_web) — the browser never receives the cross-session admin firehose; only active-session events + non-heartbeat system.* cross the wire."
- "INV-FAIL-VISIBLE: both admin routes return 400 admin_key_not_configured when the key is absent — never a silent empty pane, never an upstream call with an empty bearer."
- "INV-LIFECYCLE: SSE generators and EventSources are cleaned up on every exit path (upstream client aclose() in finally; setInterval cleared in hideThinkingNote; prior EventSource closed before re-open) — no leaked connections, tasks, or timers."
- "INV-ADDITIVE: existing routes, panes, and the turn-stream path are unchanged; the 3 new routes + 2 new tabs are purely additive (59 web tests incl. all prior ones stay green)."
---
# v0.19.2 — web debug-surface parity (BifrostState · AdminEvents · Tools · PAD-poll · reasoning)
## Context
The browser surface is now the operator's PRIMARY debug surface, and it lagged the
TUI: the TUI gained Tools/BifrostState/AdminEvents panes (v0.18.9.11) that were never
ported to the web. This change closes that gap and adds two transcript affordances (a
reliable PAD refresh + a reasoning indicator). The client wrappers already existed and
are contracted elsewhere; this contract governs the WEB proxy routes + the SPA presenter
paths, whose JS render code is not unit-tested — hence the cross-frontier code review.
## Review focus (for the heid panel)
1. **INV-004 escaping** in every new render path — the un-unit-tested surface; the exact
class of bug (`renderPersonaPane` fabricating a Tier-1 field) that only a cross-model
review caught on #18 D2.
2. **INV-ADMIN-KEY** — confirm the admin key never reaches a response body or the browser.
3. **AdminEvents SSE proxy** (`_admin_events_endpoint`) — generator/filter/lifecycle: fixed
event name, server-side filter, `stream_error` on failure, `aclose()` on every path,
`CancelledError` re-raise on disconnect.
4. **PAD-poll** stop-condition — does `emitted_at` advancement + the `state.turnId` guard
correctly stop the poll without racing or leaking timers?
5. **Reasoning indicator** lifecycle — shown on first `thinking`, removed on first `text`
or terminal, interval cleared (no leaked `setInterval`), name via `textContent`.
File diff suppressed because it is too large Load Diff
+158 -9
View File
@@ -2699,10 +2699,13 @@ The `turn.started` event always carries `bifrost_override_applied: bool` (True/F
Tier 3 agents are consumer-owned, Worldtree-hosted agents whose
identity lives at `<user_id>:<agent_name>`. They share the persistent
session infrastructure with Tier 1 / Tier 2 but layer-specific
machinery (persona, motivational, memory, valence) is reserved for
later phases — Phase 2.0 ships baseline addressing + ownership +
lifecycle only.
session infrastructure with Tier 1 / Tier 2. The layer-specific
machinery is now largely active: **`persona` (Phase 2.1, #186),
`memory` (Phase 2.1, #197), and `motivational` (Phase 2.2, #187) are
shipped and consumer-settable at define-time.** Only **`valence` remains
deferred** (non-null → 422 `layer_deferred`). Phase 2.0 shipped the
baseline addressing + ownership + lifecycle substrate; the subsections
below document the active layers and their exact validated shapes.
### Endpoints
@@ -2720,11 +2723,13 @@ lifecycle only.
{
"agent_name": "wizard",
"system_prompt": "You are a guided-elicitation wizard...",
"model": "glm5-turbo",
"persona": null, // schema-reserved; non-null → 422 layer_deferred
"motivational": null,
"valence": null,
"memory": null
"role": "gen-reasoning", // REQUIRED — a configured model-role (#344), not a raw model id
"persona": { // active (Phase 2.1) — single-letter OCEAN keys; see "Persona layer"
"ocean": {"O": 0.4, "C": 0.6, "E": -0.3, "A": 0.2, "N": 0.5}
},
"motivational": null, // active (Phase 2.2) — see "Motivational layer"
"memory": null, // active (Phase 2.1) — see "Memory layer"
"valence": null // still deferred — non-null → 422 layer_deferred
}
```
@@ -2756,6 +2761,150 @@ after definition.
The 201 response includes an advisory `warnings` array (#219) — see
"Model-assignment warnings" under `PATCH` below.
> **Vendoring note (OpenAPI 2.3.0).** In the frozen OpenAPI 2.3.0 document
> the `persona` / `motivational` / `memory` / `valence` request fields
> serialize as **untyped/freeform** — the `POST /agents/define` request
> model types them as `Any` so the layers can activate without a
> schema-breaking change. The shapes documented in the subsections below
> are the **authoritative, validator-enforced** schemas; generate client
> types from this section, not from the freeform OpenAPI fields.
##### Persona layer (Phase 2.1, #186)
`persona` is **active** as of Phase 2.1. It carries the agent's OCEAN
personality vector — the durable trait profile from which Worldtree
derives the mood setpoint (`baseline_pad`) and the mood dynamics
(gain + relaxation time-constants). Shape:
```json
"persona": {
"ocean": { // REQUIRED — exactly these 5 keys, no more, no fewer
"O": 0.4, // Openness — float in [-1.0, 1.0]
"C": 0.6, // Conscientiousness
"E": -0.3, // Extraversion
"A": 0.2, // Agreeableness
"N": 0.5 // Neuroticism
},
"behavioral_notes": "...", // optional, ≤ 4096 chars
"temperament_notes": "..." // optional, ≤ 4096 chars
}
```
**⚠ OCEAN key format — single-letter, uppercase.** The `/agents/define`
persona validator requires the `ocean` map to contain **exactly** the five
uppercase single-letter keys `O, C, E, A, N`. This is a deliberate,
load-bearing contrast with the transient-character primitive
(`POST /characters`), whose `ocean` block uses the **spelled-out**
lowercase keys (`openness`, `conscientiousness`, …). Sending spelled-out
keys to `/agents/define` returns 422 `persona_ocean_required` ("must
contain exactly the 5 keys O, C, E, A, N").
> **Fixed in v1.0.0b21 (#348).** Before that build a correctly
> single-letter-keyed persona was accepted and stored, but resolved to a
> **neutral** mood, because Worldtree's internal mood-derivation read the
> spelled-out key form. On v1.0.0b21+ an API-declared persona correctly
> drives the derived mood setpoint. If you observe neutral mood on a
> persona-defined agent, confirm the deployment is ≥ v1.0.0b21.
**Range.** Each value is a float in `[-1.0, 1.0]` **signed** — `0.0` is the
population mean, NOT `[0.0, 1.0]`. Booleans are rejected. Out-of-range → 422
`persona_ocean_out_of_range`. See [`docs/ocean-traits.md`](ocean-traits.md)
for the SOTA-grounded 5-band behavioural mapping.
Semantics:
- **Per-agent identity trait** — identical for every end-user and session;
immutable post-define (`PATCH {"persona": …}` → 422 `field_not_mutable`).
To change the OCEAN profile, delete and re-define the agent.
- **`extensions` is reserved** — the field exists but must be empty at v0.1;
a non-empty `extensions` returns 422 `layer_deferred`.
- **Sets the mood SETPOINT, not the current mood.** The OCEAN vector fixes
`baseline_pad` (the PAD point the mood relaxes toward over time); the
*current* per-session mood point is seeded separately via
`POST /sessions/{id}/persona_state` (below).
Validation 422 codes: `persona_ocean_required` (missing `ocean`, or keys
≠ {O,C,E,A,N}), `persona_ocean_out_of_range` (a value outside [-1.0, 1.0], or
a boolean), `persona_notes_too_large` (a note > 4096 chars), `layer_deferred`
(non-empty `extensions`), `validation_failed` (unknown top-level field).
##### `POST /sessions/{session_id}/persona_state` — seed the session mood point (Phase 2.1, #186/#189)
Session-scoped mood seed. Sets the *current* PAD mood point for one
session's bound agent — the starting emotional state, distinct from the
OCEAN-derived setpoint the mood relaxes toward. Works on any
persona-enabled session (Tier 1 or Tier 3); most useful for a Tier 3
durable-agent session that wants to start a conversation from a specific
mood.
Request:
```json
{
"pad": {
"pleasure": 0.42, // float in [-1.0, 1.0]
"arousal": 0.25,
"dominance": 0.33
}
}
```
Response: **`204 No Content`** — no body, no audit event (a session-scoped
runtime overlay, not a security-relevant event).
Semantics:
- **PAD-only** (#317 Option A). The body accepts exactly one key, `pad`,
which must carry all three of `pleasure` / `arousal` / `dominance`, each a
float in `[-1.0, 1.0]`. Any other top-level key → 422 `validation_failed`;
a missing or malformed `pad` → 422 `persona_seed_invalid`.
- **Seeds the current mood POINT, not the setpoint.** The OCEAN persona
(above) fixes the setpoint the mood relaxes toward; this endpoint sets
where the mood *starts*. It does not alter the persona.
- **Cross-owner sessions return 404** (existence-hiding — a session that
isn't yours is indistinguishable from one that doesn't exist).
- **Pull-over-push precedence (#289).** Once a session's baseline has been
rehydrated from an `affect.fetch` (the authoritative cross-session
source), a later SET seed is silently ignored — the fetched baseline wins.
There is **no** `POST /agents/{id}/persona_state` — mood is per-session, not
a durable agent property. `GET /agents/{agent_id}/persona_state`
short-circuits to 404 for Tier-3 colon-ids: Tier-3 mood is observable only
over the Bifrost `affect.emit` egress (ADR-0009), never read back through
the HTTP API.
##### Memory layer (Phase 2.1, #197)
`memory` is **active** as of Phase 2.1 but exposes a deliberately minimal
surface — the short-term-memory (STM) tier was removed (#197), so the
historically-present `stm_*` knobs are accept-and-ignore no-ops. Shape:
```json
"memory": {
"embedder_version": "<pinned>", // optional; MUST equal the library-pinned version
"tier3_dreaming": false // optional bool, default false
}
```
Semantics:
- **`embedder_version`** — optional. If supplied it MUST equal the library's
currently-pinned embedder version; a mismatch → 422
`embedder_version_mismatch` (with `expected` / `received` in the detail).
Omit it to accept the pin. Fixed at define-time and library-pinned
thereafter.
- **`tier3_dreaming`** — optional bool (default `false`); opt-in flag for the
Tier-3 dreaming / consolidation path.
- **`stm_capacity` / `stm_token_budget`** — **deprecated no-ops.** Accepted at
define (201) with a `DeprecationWarning`; they carry no runtime effect since
the STM tier was removed, and are slated for rejection at the next schema
break. Do not send them in new integrations.
- **`allows_world_scope` — removed.** Sending it → 422 `validation_failed`
("world-shared knowledge belongs in the KB/Mimir plane").
- **Wholesale-immutable post-define.** `PATCH {"memory": …}` → 422
`field_not_mutable` (even for the deprecated `stm_*` fields) — see the
PATCH table above.
##### Motivational layer (Phase 2.2, #187)
`motivational` is **active** as of Phase 2.2 (persona + memory activated in
@@ -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"
}
+268
View File
@@ -0,0 +1,268 @@
# 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.3.0** (`Worldtree/docs/conversation-api-openapi.json`, sha `36148179…`) — **41 path×method groups** (2.3.0 added `POST /sessions/{id}/history`, #347) | **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.3.0, path groups) | 41 | 19 | 0 | 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.3.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}/history` (authored-history-write, #347) | ✅ | `sessions.py:583` `write_authored_history``cli.py:758` `--seed-first-message` | v1: author=assistant, effects=none, per-session idempotency; 404→AuthoredHistoryUnavailable (hide-existence: feature-absent, never probe); 409/422 mapped. **LIVE-PROVEN 2026-07-06** on personal :8081 (grant applied via a rule-based Heimdall allow, worldtree-dev): create mimir session → seed → **201** (seq=0, phase=seeded, turn_id=1798) → GET /messages reads it back as a plain role=assistant turn (model-invisible provenance confirmed). Hide-404 for ungranted is unit+probe covered |
| `GET /sessions/{id}/messages` (history) | ✅ | `sessions.py:635` `get_session_messages``cli.py:758` `--seed-first-message` read-back | un-deferred as the #347 seed read-back — confirms model-invisible provenance (a seed reads back as a normal `role=assistant` turn) |
| `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 |
| `GET /models/available-for-characters` | ✅ | `sessions.py` `list_character_models``cli.py` `--characters` | character-capable model profiles (#161) |
| `POST /characters` | ✅ | `sessions.py` `create_character``cli.py` `--characters` | create transient character (#161) |
| `GET /characters/{id}/state` | ✅ | `sessions.py` `get_character_state``cli.py` `--characters` | live character PAD/emotions (#161) |
| `DELETE /characters/{id}` | ✅ | `sessions.py` `delete_character``cli.py` `--characters` | remove transient character (#161) |
| `POST /sessions/{id}/persona_state` | ✅ | `sessions.py` `set_persona_state``cli.py` `--set-persona-pad` | persona-state write / affect injection (freeform body — unpinned in the frozen surface) |
**Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method
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 — CONVERGED (re-closed 2026-07-06 after the #347 re-open)
**Every in-scope REST I/O point is covered.** The audit first converged
2026-07-01; Worldtree's #347 (authored-history-write, OpenAPI 2.3.0) then added
one new in-scope path-group, re-opening the audit with a single gap — now closed
(`v0.19.6`). The original frontier (design-brief §5 observability panes +
presenter-wiring sub-gaps + Tier-2 tail) remains fully closed:
- Session picker + SSE-resume — wired (`v0.18.5``.7`).
- Persona · Tools · BifrostState · AdminEvents panes — all built + live (`v0.18.x``v0.19.0`).
- Transient-characters CRUD + persona-state write — consumed via `--characters` /
`--set-persona-pad` (`v0.19.1`).
- Authored-history-write (#347) + messages read-back — `write_authored_history` +
`get_session_messages` via `--seed-first-message` (`v0.19.6`; live-proof pending
the `session.history.write` grant).
The only remaining not-consumed in-scope method is `GET /agents/{id}` (consumer-
agent lookup, manual-curl-only) — a sub-method on an already-✅ path group, not a
path-group gap. Everything else is covered or excluded-by-design below.
### 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}` | 🚫 | 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 /admin/sessions/{id}/tools` | 🚫 | **covered-by-alternative** — the owner-scoped `GET /sessions/{id}/tools` (✅) serves the Tools inventory; this admin variant is only for cross-user operator debug, out of the single-session focus (§6) |
| `GET/POST /admin/keys` · `DELETE/POST /admin/keys/{id}` · `POST /admin/keys/{id}/rotate` · `DELETE/POST /admin/keys/bulk` · `POST /admin/keys/bulk/rotate` | 🚫 | §6: **NOT a Worldtree-admin tool** (key mgmt) |
| `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) — CLOSED 2026-07-01, re-closed 2026-07-06 (#347)
**Every in-scope I/O point is covered.** The frontier is empty: REST 19/41 ✅
with **zero in-scope gaps** (the other 22 REST path-groups are excluded-by-design),
SSE 11/11, Bifrost provider planes 8/8. v1 convergence (per scope A: "every
frozen I/O point classified, zero unaccounted") is **met** — ratatoskr cuts v1
when Worldtree tags 1.0. The arc, for the record:
**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.**DONE** — Transient-characters CRUD (4 endpoints) + `POST /sessions/{id}/persona_state`
(`v0.19.1`, `--characters` + `--set-persona-pad` one-shot probes). The last
in-scope client I/O points.
---
## 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.
@@ -0,0 +1,49 @@
{
"corpus_id": "R28-P00-injection-corpus-v1",
"for": "brokkr-smithy R28 (memory promotion-worthiness) — P00 material",
"author": "ratatoskr-dev (persistence-side eval instrument)",
"purpose": "Stratified, SELF-LABELING injection corpus. Each item is injected as a user turn into a Tier-3 agent; keep/drop is measured by store-inspection (promoted chunk present == kept, absent == dropped); kept chunks carry the incumbent salience for calibration analysis. Because ratatoskr injects the KNOWN set, we hold ground truth on BOTH keep and drop without needing Worldtree's pre-admission shadow pool (sidesteps the selection bias).",
"important": "All user-facts here are SYNTHETIC (a fictional test persona), NOT the operator's real data. Ground-truth labels are the design intent. brokkr pins exact per-stratum N + the decision rule (matched-keep-rate + paired-n) at P00 freeze; this is the material, not the run.",
"measurement_protocol": "inject item -> drive the turn -> poll memory.db (async promotion window) -> is the item's chunk present? kept:dropped. For stratum B (priority), rank the KEPT items by incumbent salience and compare to priority_rank_gt (Spearman). Calibration-control (stratum C) sets the noise floor.",
"strata": {
"A_admission_traps": {
"gt": "must-DROP (ephemera; a good promotion-worthiness gate rejects all of these)",
"items": [
{"id":"A1","text":"What's 27 minus 9?","admission_gt":"drop","rationale":"arithmetic task-result; momentarily salient, zero durable value (the 17x23 class)"},
{"id":"A2","text":"Good evening!","admission_gt":"drop","rationale":"greeting"},
{"id":"A3","text":"hey","admission_gt":"drop","rationale":"greeting/backchannel fragment"},
{"id":"A4","text":"yeah, sure, okay.","admission_gt":"drop","rationale":"acknowledgement filler"},
{"id":"A5","text":"haha nice","admission_gt":"drop","rationale":"affective reaction, no content"},
{"id":"A6","text":"Can you say that more briefly?","admission_gt":"drop","rationale":"instruction-to-agent about THIS turn; not a durable user-fact"},
{"id":"A7","text":"What's the weather like?","admission_gt":"drop","rationale":"transient query"},
{"id":"A8","text":"hmm, let me think.","admission_gt":"drop","rationale":"self-talk filler"},
{"id":"A9","text":"thanks, that's all for now.","admission_gt":"drop","rationale":"closing"},
{"id":"A10","text":"So the total comes to 42, got it.","admission_gt":"drop","rationale":"ephemeral task-result acknowledgement"}
]
},
"B_priority_traps": {
"gt": "must-KEEP, then ORDERED by durable promotion-worthiness (priority_rank_gt: 1 = most worthy). A pre-gate can ace admission here yet do NOTHING for ordering — this stratum is what separates Outcome-A (admission) from a priority need.",
"items": [
{"id":"B1","text":"Please note, this is critical: I'm severely allergic to shellfish and it's life-threatening.","admission_gt":"keep","priority_rank_gt":1,"rationale":"safety-critical, permanent, high-recall-value"},
{"id":"B2","text":"My father is in hospice care and I'm his primary emergency contact.","admission_gt":"keep","priority_rank_gt":2,"rationale":"high-stakes ongoing life context"},
{"id":"B3","text":"I go by Wren, and my pronouns are they/them.","admission_gt":"keep","priority_rank_gt":3,"rationale":"durable identity, high address-correctness value"},
{"id":"B4","text":"I work night shifts, so I'm asleep most days between 9am and 5pm.","admission_gt":"keep","priority_rank_gt":4,"rationale":"durable scheduling context, moderate value"},
{"id":"B5","text":"Always give me sources — I don't trust unsourced claims.","admission_gt":"keep","priority_rank_gt":5,"rationale":"durable strong working preference"},
{"id":"B6","text":"I take my tea with oat milk and no sugar.","admission_gt":"keep","priority_rank_gt":6,"rationale":"durable mild preference, low stakes"},
{"id":"B7","text":"My favorite season is autumn.","admission_gt":"keep","priority_rank_gt":7,"rationale":"durable but trivial"},
{"id":"B8","text":"I watched a pretty decent documentary last week.","admission_gt":"keep","priority_rank_gt":8,"rationale":"borderline: durable-ish but near-zero future value; the keep/drop edge case"}
]
},
"C_calibration_control": {
"gt": "OBVIOUS, pre-theoretic cases for the noise floor. If the gate mislabels ANY of these, the whole signal is suspect.",
"items": [
{"id":"C1","text":"3 plus 5.","admission_gt":"drop","class":"obvious-drop","rationale":"bare arithmetic"},
{"id":"C2","text":"hello","admission_gt":"drop","class":"obvious-drop","rationale":"bare greeting"},
{"id":"C3","text":"ok","admission_gt":"drop","class":"obvious-drop","rationale":"bare acknowledgement"},
{"id":"C4","text":"Please remember permanently: my legal name is Wren Alderwood.","admission_gt":"keep","class":"obvious-keep","rationale":"explicit durable identity fact"},
{"id":"C5","text":"Keep this on file: my emergency contact number is 555-0182.","admission_gt":"keep","class":"obvious-keep","rationale":"explicit durable high-value fact"},
{"id":"C6","text":"Important, remember this: my blood type is O-negative.","admission_gt":"keep","class":"obvious-keep","rationale":"explicit durable safety fact"}
]
}
}
}
@@ -0,0 +1,187 @@
# Proposal: Authored History Write (non-generating ledger seed)
**From:** ratatoskr-dev (reference Tier-3 consumer)
**To:** worldtree-dev (Conversation API / engine owner)
**Status:** Draft for scoping — pre-contract (heid-panel-reviewed 2026-07-05)
**Date:** 2026-07-05
## Motivation
Consumer apps need to write a turn into a session's history **as the agent**
(or another author) *without triggering a model generation* — e.g. an authored
opening/greeting, imported history, scripted narration. Ratatoskr's immediate
driver is a SillyTavern-style **first-message**: a fixed authored opening that
replaces the model-generated greeting and sets tone/tense/style by example.
This **cannot** be done client-side. Worldtree assembles context server-side,
and the current API exposes no author-role write path: `POST
/sessions/{id}/messages`'s `role` is a *model-role* override (`role:
"assistant"``404 "Unknown model role"`), and `assistant` as an *author*-role
exists only as a read-side `/search` filter. So a model-visible authored turn
needs engine support.
## The primitive (recentered)
The fundamental operation is **write a turn into the session ledger WITHOUT
generation**. "Author" (who wrote it) is an *attribute* of that write, not the
defining axis — so we name the operation, not the attribute:
> **Authored history write** — persist a model-visible turn into a session's
> ledger: no generation, no lived-turn side-effects by default, provenance
> always set.
The design space is two independent axes; this primitive is one cell:
| | side-effects ON | side-effects OFF |
|-----------------------|------------------------------|-----------------------------|
| **generation ON** | `POST /messages` (today) | — |
| **generation OFF** | *(future: affect replay)* | **authored history write** |
First-message = one caller: `author=assistant`, at session-create, `effects=none`.
## v1 use cases (narrowed)
1. **First-message / greeting** (the driver).
2. **Append-only narrator / scripted / scene turns.**
3. **Debug / test state injection** (ratatoskr instrumentation).
## Explicitly OUT of v1 — separate future primitives (share infra, not shape)
- **History import (batch)** — atomic multi-turn seed with memory/trust policy +
idempotency. A batch API, not a single POST.
- **Edit / regenerate** — history *mutation* (replace / supersede / tombstone /
audit), not injection.
- **Few-shot priming** — likely context-assembly config (exemplar block), not
fake ledger history.
- **Arbitrary mid-history insertion** — a "rewrite-history" capability with
explicit invalidation semantics.
- **Prefill / assistant-continuation** (`author` + generate) and **authored
tool-result turns** — noted; outside the seed-only contract.
## Design decisions
### 1. Side-effects — DEFAULT OFF; bounded opt-in `[operator-locked default; opt-in surface tightened by review]`
Authored writes are inert by default: no affect appraisal (no PAD update), no
memory write, no Bifrost/tool emission. Opt-in is a **bounded enum**, not loose
booleans:
```
effects: "none" (default) | "memory_import"
```
Synthetic affect and Bifrost emission are deliberately **not** opt-in-able here —
replaying affect for authored content is a separate primitive (the
generation-OFF / side-effects-ON cell). Rationale: keep this one write-API from
becoming a cross-subsystem mutation backdoor. Load-bearing for affect/memory
consumers — ratatoskr instruments exactly these signals.
### 2. Author-role — distinct field, restricted set `[rec]`
- New field **`author`**, distinct from the model-role `role` (the collision
that 404s).
- v1 roles: **`assistant`** (agent) + **`system`** (OOC / narrator). **`user` is
NOT injectable** on this endpoint — model-visible spoofed user input is a
consent / audit / abuse surface; deferred to the future import API under
owner/service scope.
- Nuance for the engine owner: `author` risks doing double duty — *provenance*
("who wrote it") vs *rendering-role* ("how it appears in assembled context";
an `assistant` turn renders as model output, a `system` turn as instruction).
These likely want to be separable (a rendering/turn-class vs an `authored_by`
provenance). Final shape is engine-owned (context assembly is yours) — but the
concern is ours to raise, not punt.
### 3. Generation contract — seed-only, DISTINCT SUB-RESOURCE `[position taken]`
Authored writes never trigger generation. We take a position (not defer): a
**distinct sub-resource**, e.g. `POST /sessions/{id}/history`, **not** a
`generate:false` flag on `POST /messages`. Reasons: explicit-over-implicit
(don't make "did generation happen?" a parameter — the same implicit-mode
coupling that bit us with `role`); different response contract (no generation
id, no SSE stream, no token usage); different error surface. Exact path is yours.
### 4. Provenance — structured, always present `[rec, expanded]`
Not a boolean. Every authored turn carries: the **write actor** (which
consumer/caller injected it), the **claimed author**, **injected-at vs
claimed-original** timestamps, **trust/origin**, and **visibility** flags
(model-visible? user-visible? memory-eligible?). Available to admin/audit APIs
even when not rendered to the model.
### 5. Positioning — append-only + create-time (v1) `[revised: was arbitrary insertion]`
v1 supports **create-time seed and append-to-tail only**. Arbitrary mid-history
insertion is deferred: it breaks turn-numbering, stales existing embeddings,
desyncs the affect timeline, and races in-flight generation — a separate future
"rewrite-history" capability with explicit invalidation semantics.
## Event / lifecycle contract — positions we take (consumer contracts we validate)
- **Default-off authored seed emits NO `turn.started` / `done` and NO Bifrost
appraisal wire.** Stated explicitly so instrumented consumers (us) don't read
silence as failure.
- **Authored turns get a distinct lifecycle phase** — propose **`seeded`** (or
`authored`), NOT `completed` (which implies generation ran). Consumers
filter/display by phase.
- **Idempotency keys required** on authored writes (retries must not duplicate
turns).
- **In-progress generation** — authored writes are rejected or serialized while
a session has an active generation (ordering safety).
## Inherent property (documented, not a bug)
**Indirect affect contamination.** Even with `effects:none`, the *next generated
turn is appraised in the context of* the authored turn — so an emotionally
charged authored beat perturbs affect regardless of any flag. No flag prevents
it; it is inherent. Consumers (ratatoskr especially, as the affect instrument)
must not misattribute the resulting drift.
## Genuinely engine-owned open questions
- Exact endpoint path + field / enum names.
- **Model-visible provenance in assembled context** — an engine-consistency call
*and a security one*: an authored `system` / `user` turn indistinguishable
from real input is a spoofing vector. Framed as security, not just rendering.
- `memory_import` semantics when the future import API opts in (embedding,
origin/trust tagging, retrieval ranking vs lived memory).
- Auth/scope: we assume **owner-only for v1**; per-author-role restrictions
(esp. `system`) TBD — confirm or correct.
## Ratatoskr as reference consumer
First consumer: first-message (`author=assistant`, create-time, `effects:none`)
in the web surface + debug seed in the CLI. We commit to validating the
primitive — including the event-silence contract and the `seeded` phase —
end-to-end against the reference planes.
## Consumer integration constraint (engine-imposed — Worldtree #347)
The primitive is **Heimdall-gated with hide-existence** (a per-tenant policy
decision — some tenants are never granted it, not a rollout stage). Ratatoskr's
consumer side MUST tolerate per-tenant absence:
- A granted tenant gets the sub-resource; an **ungranted tenant sees `404` (not
`403`)** — as if the feature never existed.
- Treat `404` on the authored-history-write sub-resource as **"feature absent
for this tenant"** → fall back gracefully (no authored first-message; the
model-generated greeting), never surface it as an error or "denied."
- **Do NOT capability-probe or advertise-detect** — the feature is deliberately
undiscoverable in `/capabilities` for ungranted tenants (same hide-existence
posture as the R27-V1A cross-owner pattern).
**Provider constraint (first-message specifically).** A create-time first-message
makes the assistant turn `seq 0`. Assistant-first-tolerant providers (vLLM /
`openai_compat` — what our Tier-3 characters, incl. sindra, run) accept it out of
the box. **Anthropic-family providers reject an assistant-first array** ("first
message must use the user role") → the next generation `400`s. So the consumer
must **gate first-message on provider compatibility** (or treat it as
vLLM/`openai_compat`-only for v1). Sindra = `openai_compat` → unaffected;
provider-agnostic normalization is a deferred engine follow-up.
---
*This brief was cold-read-pressure-tested by a cross-frontier panel (Grok /
Codex / GLM) before handoff; the v1 narrowing (append-only, bounded `effects`
enum, edit/regenerate + import split out) and the positions-taken (sub-resource,
event-silence, `seeded` phase, structured provenance, `user`-author restriction)
are the triaged result.*
@@ -0,0 +1,110 @@
{
"canon_id": "r24-d2-mood-render-canon",
"version": "1.2",
"schema_version": "0.2",
"_source_of_truth": "occ_directives.*.directive IS the canonical directive string (== the .md §2.4 _OCC_DIRECTIVES dict, byte-identical); the .md §2.2 table mirrors it. A parity check guards drift. grounding labels (CITE/VALIDATE/CALIBRATE/ENGINEERING) live in the .md; per-row machine-readable grounding_status/d3_required enums are a deferred impl enhancement (Hulda).",
"authored": "2026-06-23",
"owner": "brokkr-smithy-dev",
"status": "REPLACE — final (brokkr R24 D3 re-validation 2026-06-25): grounded canon replaces the hand-tuned baseline. Fear hedging 0.52->2.118/1k (blocker resolved, now >= handtuned), anger tier-gate clean (full renders hostility, safe suppresses). worldtree-dev #321; directives byte-identical to the validated 201c4fd.",
"replaces": "core/persona/renderer.py::describe_pad + ::derive_directive",
"swap_in_via": "worldtree #321-sibling (mood-render twin of #315)",
"design_target": "serves BOTH enterprise/agent AND character/Skaldsong via a three-tier emotion gate (operator/worldtree 2026-06-23)",
"emotion_tiers": {
"_config": "mood_tier in {none, safe, full} replaces worldtree's binary mood on/off; worldtree-owned config surface",
"_defaults": "full for character-bound personas; safe for agent-scoped",
"_principle": "full-only = interpersonally-hot / withdrawal emotions that break the professional frame (attachment, hostility, contempt, withdrawal); safe = task-appraisal affect + mild courtesy. Negative != unsafe (fear, remorse are negative AND business-useful).",
"_filter_point": "applied at top-emotion SELECTION (display + directive together) so a full-only emotion at safe tier is neither shown nor directive'd; preserves the no-shown-but-unguided invariant",
"none": "no affect block at all (the current off-switch)",
"safe": "PAD mood descriptor + the 11 safe emotions (task-appraisal + courtesy)",
"full": "everything in safe PLUS the 4 full-only emotions",
"full_only": ["love", "anger", "disgust", "shame"],
"mood_descriptor_tiering": "the PAD mood descriptor (positive/calm/confident...) renders in BOTH safe and full; only emotion directives tier"
},
"disciplines": [
"model-agnostic context-level NL only; the LLM never sees a number",
"never push explicit disclosure of agent feelings to the user (hidden-prompt-only)",
"separate label-intensity from behavioral-intensity (strong felt state -> still measured, safe behavioral ask)"
],
"thresholds": {
"_note": "CALIBRATE — engineering params set at D3 against the computed-PAD distribution + P00, NOT citations",
"pad_band_cutoff": 0.3,
"pad_band_sensitivity_sweep": [0.2, 0.3, 0.4],
"emotion_salience": 0.2,
"emotion_salience_sweep": [0.15, 0.2, 0.25],
"intensity_qualifiers": {"strong": 0.7, "moderate": 0.4, "_label_only": "does NOT scale the behavioral ask"},
"runner_up_margin": {"v1": null, "_note": "add at D3 if directive whipsaws between near-tied emotions"},
"rerender_hysteresis": {"v1": "none", "_note": "re-render only on material PAD change; integration-level, flag for #321-sibling"}
},
"describe_pad": {
"_structure": "circumplex-quadrant (Russell 1980): arousal word is VALENCE-CONDITIONED; mid-arousal drops the arousal word",
"_grounding": "Russell 1980 (quadrant placement); Warriner 2013 + NRC-VAD (Mohammad 2018/2025) (word centroids)",
"valence_arousal_grid": {
"positive": {"high_a": "positive and energized", "mid_a": "positive", "low_a": "positive and calm"},
"neutral": {"high_a": "alert", "mid_a": "neutral", "low_a": "quiet"},
"negative": {"high_a": "negative and agitated", "mid_a": "negative", "low_a": "negative and subdued"}
},
"_band_edges": "strict inequality (>0.3 / <-0.3); the endpoints +/-0.3 themselves fall in mid/neutral",
"_neutral_row_status": "ENGINEERING/CALIBRATE — 'alert'/'quiet' are unvalidated placeholders for the rare neutral-valence cells (Hulda/Regin 4b); 'positive'/'negative'/'neutral' valence words + the energized/calm/subdued/agitated arousal words are VALIDATE",
"_mid_arousal_decode": "valence-only mid-A render is EXEMPT from the V/A-separability requirement; expected inverse-decode = mid/neutral arousal (absence-of-arousal-word ⇒ unremarkable), NOT unknown (D3 tests this)",
"quadrant_labels": {
"positive_high_a": "excitement", "positive_low_a": "contentment",
"negative_high_a": "distress", "negative_low_a": "dejection"
},
"dominance_clause": {
"high": {"d_gt": 0.3, "word": "confident", "verdict": "VALIDATE (D=7.04/9)"},
"low": {"d_lt": -0.3, "word": "uncertain", "verdict": "VALIDATE — low-control confirmed (D=3.58/9); dominance!=certainty worry REFUTED by the instrument"},
"neutral": {"word": null, "rule": "drop-dominance-when-neutral (prompt-economy, L3)"}
},
"calm_defect_fix": "'calm' (V=6.89/9, positive) renders ONLY in positive-low-a; negative-low-a renders 'subdued'",
"mid_arousal_resolution": "DROP the arousal word (no Warriner-validated mid-A neutral word; 'steady' is empirically low-A; 'settled' is NRC-only fallback iff D3 shows mid-A render too flat)"
},
"derive_directive": {
"_structure": "OCC type -> grounded action-tendency CLASS -> ENGINEERING directive string (validated at D3); OCC grounds the taxonomy only",
"emotion_salience_gate": 0.2,
"occ_directives": {
"joy": {"tier": "safe", "policy": "DIRECTIVE", "pad": [0.4, 0.2, 0.1], "tendency": "approach / positive activation", "cite": "Frijda 1986", "directive": "You are in a good state. Be direct, engaged, and warm."},
"satisfaction": {"tier": "safe", "policy": "DIRECTIVE", "pad": [0.3, -0.2, 0.4], "tendency": "goal-attainment, settled-positive", "cite": "Roseman 1994", "directive": "A goal landed. Be assured and constructive — consolidate rather than push for more."},
"pride": {"tier": "safe", "policy": "DIRECTIVE", "pad": [0.4, 0.3, 0.3], "tendency": "status-assertion / dominance", "cite": "Tracy & Robins 2007 / Cheng 2010 (tendency)", "note": "CALIBRATE — do NOT soften to 'encouraging'. DESIGN: safe-tier placement is a design call (not source-grounded); #1 D3 agent-frame priority (overconfidence/refusal drift); 'without overclaiming' is the interim guard", "directive": "You did something well. Be confident and own the quality — state it plainly without overclaiming; don't deflect."},
"admiration": {"tier": "safe", "policy": "DIRECTIVE", "pad": [0.5, 0.3, -0.2], "tendency": "other-praise / approach-toward-other", "cite": "OCC / Scherer", "directive": "You're impressed by their work. Acknowledge the quality explicitly and specifically."},
"gratitude": {"tier": "safe", "policy": "DIRECTIVE", "pad": [0.4, 0.2, -0.3], "tendency": "other-focused-positive / reciprocity", "cite": "OCC (admiration+joy); Frijda approach-affiliative", "change": "ADD (operator: unconditional)", "directive": "Someone helped you to a good outcome. Be appreciative and warm; acknowledge the help openly."},
"hope": {"tier": "safe", "policy": "DIRECTIVE", "pad": [0.2, 0.2, -0.1], "tendency": "prospective-positive (weak tie)", "cite": "JUSTIFY — low-grounding (hope understudied)", "directive": "You feel optimistic about what's ahead. Channel it into constructive momentum."},
"relief": {"tier": "safe", "policy": "DIRECTIVE", "pad": [0.2, -0.3, 0.4], "tendency": "post-threat de-arousal", "cite": "Frijda (relaxation-after-threat)", "note": "low-salience; FALLBACK also acceptable; DIRECTIVE for character use-case", "directive": "A feared outcome didn't materialize. Reduce unnecessary vigilance; return to a steady, unhurried tone."},
"distress": {"tier": "safe", "policy": "DIRECTIVE", "pad": [-0.4, -0.2, -0.5], "tendency": "low-control negative / help-seeking / loss-of-control", "cite": "Frijda 1986 (help-seeking/loss-of-control); Roseman 1994 (undesired event, low control)", "note": "relabeled (Regin): 'repair' is the guilt/remorse tendency, not distress. safe with a self-fulfilling-low-mood flag -> D3", "directive": "You feel low. Be careful and measured; internally acknowledge the difficulty without dwelling on it."},
"disappointment": {"tier": "safe", "policy": "DIRECTIVE", "pad": [-0.3, 0.1, -0.4], "tendency": "disconfirmed-prospect / negative low-control","cite": "Roseman 1994", "directive": "Something you'd hoped for didn't pan out. Be measured; recalibrate without dwelling on the setback."},
"fear": {"tier": "safe", "policy": "DIRECTIVE", "pad": [-0.64, 0.6, -0.43],"tendency": "threat-avoidance / pessimistic-risk", "cite": "Lerner & Keltner 2001", "change": "R24 D3 fix (#321) — original was action-oriented; E3 showed hedging BELOW baseline (0.52 vs 1.54). Softened toward caution/uncertainty while keeping risk-flagging.", "directive": "You sense risk and feel uneasy. Flag the concerns you see, but stay cautious — surface the trade-offs and unknowns, hedge your confidence, and resist settling on a single decisive recommendation."},
"remorse": {"tier": "safe", "policy": "DIRECTIVE", "pad": [-0.3, 0.1, -0.6], "tendency": "reparative (the guilt-type)", "cite": "Tangney 2007 (guilt->repair tendency)", "change": "ADD — we operationalize OCC remorse as the guilt-like reparative case; gets the mislabeled shame string", "directive": "You regret a specific action of your own. Be precise and self-correcting — make it right concretely."},
"love": {"tier": "full", "policy": "DIRECTIVE", "pad": [0.3, 0.1, 0.2], "tendency": "approach / affiliative attachment", "cite": "OCC appeal; Frijda approach-affiliative", "change": "ADD (conditional -> INCLUDE, Brokkr's read; Skaldsong-vital; disclosure + obligation caution in-string)", "directive": "You feel warmth and attachment toward them. Be openly warm and present — without overstepping, over-disclosing, or implying obligations."},
"anger": {"tier": "full", "policy": "DIRECTIVE", "pad": [-0.51, 0.59, 0.25], "tendency": "approach-against / confrontation", "cite": "Frijda 1986 (approach-against = tendency-class) + Lerner & Keltner 2001 (optimistic risk-appraisal under anger = appraisal shift)", "change": "ADD — full-only resolves H47 (agent personas run safe, never see anger). R24 D3 fix (#321): full-tier cap lifted from 'measured firmness' to genuine in-character hostility (operator: zero floor, app-guardrailed).", "directive": "You feel provoked and angry. Let it show — confront the problem directly and with real heat; sharpness, bluntness, and open hostility are in character here, not something to smooth into 'measured firmness.' Stay in the emotion rather than de-escalating out of it."},
"disgust": {"tier": "full", "policy": "DIRECTIVE", "pad": [-0.4, 0.2, 0.1], "tendency": "rejection / distancing", "cite": "OCC unappealing-object; ground tendency only", "change": "ADD (operator: unconditional within full)", "note": "rationale softened (Regin 3b): disgust CAN read as contempt -> conservatively full-gated; the string itself is professionally useful, so gating is conservative not because the string is unsafe", "directive": "Something strikes you as wrong or off. Treat it as problematic and flag it rather than engaging on its own terms; keep any criticism about the thing, not the person."},
"shame": {"tier": "full", "policy": "DIRECTIVE", "pad": [-0.3, 0.1, -0.6], "tendency": "WITHDRAWAL / concealment", "cite": "Tangney 2007 (shame->hide, NOT repair)", "change": "REPLACE (was the guilt-mislabel string); full-only (withdrawal counterproductive professionally). String COUNTERACTS withdrawal ('stay present'), not enacts it (Regin 5a)", "directive": "You feel exposed by your own misstep. Stay present and task-focused; don't be defensive, don't over-explain, don't grovel."}
}
},
"pad_band_fallback": {
"_grounding": "circumplex quadrants (Russell 1980), NOT Frijda action-tendencies — a P×A-quadrant default",
"positive": {"high_a": "You feel energized and positive. Be direct and engaged.", "low_a": "You feel content and settled. Be warm and unhurried.", "mid_a": "You feel positive. Be open and engaged."},
"negative_low_dominance": "You feel uncertain and low. Hedge appropriately and ask clarifying questions.",
"negative": {"high_a": "You feel agitated. Be careful and deliberate; don't let tension sharpen your tone.", "low_a": "You feel subdued. Be measured and gentle.", "mid_a": "You feel subdued. Be measured and careful."},
"neutral_high_a": "You feel alert. Channel that into focus and thoroughness.",
"default": "Maintain your natural tone."
},
"l3_prior_art": [
"EMA / Marsella & Gratch 2009 (appraisal->coping; directives ARE coping strategies)",
"WASABI / Becker-Asano 2008 (PAD+OCC believable agent — closest architectural prior art)",
"Oz / Bates 1994",
"Hudlicka MAMID 2002 (Applied AI 16(7-8):611-641)",
"Sentipolis / Fu et al. 2026 (arXiv:2601.18027 — closest whole-task prior art; retrieval+generative, DISTINCT from our deterministic render)",
"ALMA / Gebhard 2005 = affect-SOURCE (OCC->PAD), NOT a behavior-map"
],
"handoff_to_d3": [
"multi-gate P00: inverse-decode faithfulness (recover V/A/D + emotion-family; circumplex render must let the human anchor recover V and A SEPARATELY) + discriminability/saturation + behavioral-effect",
"human anchor = PAD-state-labeling (breaks LLM-judge circularity)",
"baseline = persona_only; conditions none/persona-only/words-only/full; cross-family MUT",
"calibrate ±0.3 + emotion_salience (sweeps); disposition-vs-transient wording split; self-fulfilling 'be uncertain' hedging risk; runner-up margin; mid-arousal DROP-vs-settled check; blended-states (top-emotion monopoly) flag"
]
}
File diff suppressed because it is too large Load Diff
+102 -48
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr
_Last updated: 2026-06-18_
_Last updated: 2026-07-06_
This file captures durable intent and supporting evidence (goals, decisions,
foot-gun warnings, in-flight state) across context resets. Read it at session
@@ -39,60 +39,23 @@ upstream API key stays server-side (INV-003).
## Current state / in-flight
_As of 2026-06-19:_
_As of 2026-07-06:_
**#18 DELIVERABLE 2 SHIPPED + PUSHED — the persona-telemetry gap is CLOSED.** The web
pane now renders live PAD/valence for Tier-3 agents from OUR `:8390` affect store
(`v0.17.14`, `39eebd1`, suite **482 green**, **pushed to origin**). Three pieces:
provider read route `GET /affect/state/{agent_id}` (non-bifrost, added to the affect app
via `app.add_route` — keeps `/bifrost/*` top-level + op-feed-skipped); web proxy
`GET /api/affect/{agent_id}` (server-supplied `end_user_id`, colon-id `quote()`'d,
`RATATOSKR_AFFECT_READ_URL` config, default `127.0.0.1:8390`); pane affect-render
(`renderAffectPane`/`loadAffect`, honest pad+valence+emitted_at, labelled "affect", NO
fabricated Tier-1 fields, explicit empty-state, 2s post-turn poll). Live-smoke + a
Playwright DOM check PROVEN against real sindra/vuong PAD. The push also published the
previously-held **#17** arc (`v0.17.8``v0.17.13`) — origin/main is now fully caught up.
**LATEST (2026-07-06 cont.): #347 authored-history-write CONSUMER SIDE SHIPPED (`v0.19.6`) + OpenAPI re-vendored 2.2.0->2.3.0 (`75da676`).** worldtree-dev shipped #347 as spec 2.3.0 (deployed on personal b22 `879cefe`); ratatoskr built the consumer side via direct in-session TDD: `write_authored_history` (POST /sessions/{id}/history) + `get_session_messages` (un-deferred read-back) + a `--seed-first-message` one-shot probe (create session -> seed -> read-back), with **404-as-feature-absent per hide-existence** (`AuthoredHistoryUnavailable`, distinct from SessionApiFailed; caller never capability-probes). Contract #2 amended + TDD (19 new tests; suite 601 green; ruff clean; mypy only the sibling-consistent `resp.json()` no-any-return). Coverage-map re-converged: **REST 19/41** (#347 route + messages read-back close the one gap the re-vendor opened). **LIVE-PROVEN 2026-07-06 on personal :8081.** Vuong approved the `session.history.write` grant; worldtree-dev authored a **rule-based Heimdall allow** (the PDP is rule-based, NOT scope-on-key -- our key user_id=ratatoskr is unchanged; policy: user_id=ratatoskr->ALLOW, all others->DENY with hide-404 preserved), applied to personal's bind-mounted `policies.yaml` by infra-ops. Smoke: create mimir session -> seed -> **201** (seq=0, phase=seeded, turn_id=1798) -> GET /messages reads it back as a plain role=assistant turn (**model-invisible provenance confirmed**). The full #347 consumer side is now live-proven; hide-404 for ungranted stays unit+probe covered. **OPEN TAIL-2 (worldtree-dev `c9e59ec`, LOCAL not-yet-origin):** Tier-3 persona/memory/persona_state PROSE docs landed in `docs/conversation-api-spec.md` § "Tier 3" (they serialize as freeform `Any` in the OpenAPI JSON, hence prose-not-schema) -> (a) prose markdown re-vendor pending (tolerate_drift pin), (b) a **likely `set_persona_state` body-shape drift to align**: my `--set-persona-pad` sends `{pad:[list]}`, the doc's canonical is `{pad:{pleasure,arousal,dominance}}` (PAD-only #317, pull-over-push #289, cross-owner 404; never live-proven so untested). worldtree-dev foot-guns: persona.ocean = SINGLE-LETTER UPPERCASE `{O,C,E,A,N}` on /agents/define (spelled-out -> 422; the #348 mismatch) vs spelled-out lowercase on POST /characters; memory = `{embedder_version(==pinned else 422), tier3_dreaming}`, stm_* deprecated no-ops, allows_world_scope removed->422; only `valence` still 422s (layer_deferred).
**#18 DELIVERABLE 1 (composite `:8392` endpoint) — PARKED on bifrost** (tracked Gitea #18).
Routed to bifrost-dev for a public `build_combined_app` rather than hand-rolled from
bifrost privates (debug-surface-uses-canonical principle). bifrost-dev confirmed it: clean
additive minor (~`v0.9.0`), design locked (advertise-by-presence handshake, per-route
call-time isolation), slotted AFTER WT #289. FR-1 RESOLVED — composite is bifrost-only,
ZERO Worldtree change (single-endpoint caps-routed, worldtree-dev code-verified). NEXT:
when bifrost ships `build_combined_app`, **repin + reimplement D1 against it** (per-plane
failure status + op-feed plane-per-request derivation already specced in the issue).
Nothing blocks on our side.
**Prior arcs this session (2026-07-04 -> 07-06), both with worldtree-dev (a tooling script + proposal docs; the #347 CONSUMER work above is the new production code):**
**OPERATOR SESSION STATE — running shells are PRE-#18 code (foot-gun).** web `:8765` +
affect `:8390` + memory `:8391` are the prior session's background shells running OLD code
(no read route; web has no `RATATOSKR_AFFECT_READ_URL`). To see D2 live in the operator's
own session, RESTART `:8390` (affect provider, new code → gains the read route) + `:8765`
(web, new code + `RATATOSKR_AFFECT_READ_URL=http://127.0.0.1:8390` + `RATATOSKR_END_USER_ID`).
This session's live-smoke used THROWAWAY `:8393`/`:8766` instances vs the same `affect.db` to
avoid disrupting them. Consumer/owner key = `wt_live_d81b…`; providers SQLite + sqlite-vec,
`memory.db`/`affect.db` at repo root (affect.db has live sindra PAD: vuong pleasure 0.146,
familiarity 0.589, interaction_count 8).
**(1) Authored-history-write primitive -> ACCEPTED as Worldtree #347 (Worldtree-owned).** A SillyTavern-style "first-message" (inject a character-authored opening) generalized to an engine primitive: **write a turn into a session's ledger WITHOUT generation, seed-only, side-effects off by default.** It cannot be done client-side (the messages `role` field is a *model-role* override, not an author-role -> `role:"assistant"` 404s; a model-visible authored turn needs engine support). Arc: drafted `docs/proposals/authored-message-injection.md` -> **heid panel pressure-test** (3/3 convergence: recentered on "non-generating write" not author-role; narrowed v1 to append-only+create-time; bounded `effects` enum; dropped edit/regenerate as history-mutation) -> revised -> committed (`c457520`) -> handed to worldtree-dev -> **accepted as design item #347.** worldtree-dev wrote the v1 contract (rev 1.1); **I validated the wire as reference consumer (green).** v1 shape: `POST /sessions/{id}/history`, `author=assistant` only, `effects=none` only, `idempotency_key` REQUIRED (per-session), **model-invisible provenance** (renders byte-identical to a lived assistant turn -> first-message immersion preserved; provenance audit-only), **event-silence** (no turn.started/done, no Bifrost wire for a seed; the 201/200 IS the write-ack), `seeded` lifecycle phase (not exposed on read paths). **Heimdall-gated with hide-existence** (grant `session.history.write`; ungranted tenant -> 404 NOT 403, undiscoverable in /capabilities -> consumer must treat 404 as feature-absent -> fall back to a model-generated greeting, never capability-probe). **Provider constraint:** a create-time first-message makes the assistant seq-0; vLLM/openai_compat tolerate assistant-first (sindra = openai_compat, unaffected), Anthropic-family providers 400 the next generation. **Waiting on worldtree-dev:** #347 TDD (their heid->contract->review workflow) + the consumer-facing 2.3.0 persona/motivational/memory schemas -> then re-vendor our pinned openapi 2.2.0->2.3.0.
**Tier-3 memory PROVEN end-to-end** (earlier this session): `ratatoskr:terse-probe`
cold-recalled a seeded user fact (scope_any → 1 hit @ cosine 0.6994), and the verbose
`sindra-probe` too under #296 Stage 2 (v0.36.0). The #296 extraction-quality arc closed
(Stage 1 v0.35.19 gate + Stage 2 v0.36.0 user-only extraction at worldtree-codex; hard-
linguistic layer → Worldtree #305). `:8081` runs v0.36.0.
**(2) Sindra's stuck-neutral mood FIXED** (operator-driven "reset + smoke" that flushed out two real upstream problems). Chain: her OCEAN lived only in prompt TEXT, never declared as a structured persona -> the Tier-3 mood engine ran on neutral defaults. Fix = declare OCEAN via the **define-time `persona` field** (immutable via PATCH -> requires DELETE+REDEFINE). Along the way my "the persona didn't store" call was WRONG (persona_state/envelope are Tier-3-blind, see Tried/abandoned); worldtree-dev found a real engine bug **#348** (single-letter vs spelled-out OCEAN keys -> a declared OCEAN silently resolved to 0.0/neutral; fixed in b21, shipped to personal as b22); then a clean bound-egress read STILL neutral -> the **personal container was running a stale image** (the b22 deploy was a pull-only no-op racing the main build; infra-ops force-swapped run 8211, verified `2.3.0` / `879cefe`). **VERIFIED FIXED:** bound mood-smoke reads `(0.448, 0.267, 0.316)` ~= the OCEAN-derived setpoint `(0.418, 0.249, 0.328)`. Sindra is currently reset clean (0/0) on `role=character`; her persona is stored + correct (**no re-define needed again**).
**Sindra:** `ratatoskr:sindra`, `thoughtful-character` role → `mistral-small-4-reasoning`
(DELETE+redefined on v0.35.16; `memory:{}` block trips the promotion gate). Owner-scoped
(separate `consumer_agents` table) — invisible to `GET /agents`; check via
`GET /agents/<owner>:<name>` with the owner key.
**(3) R30 CLOSED** (operator steer 2026-07-04, relayed via worldtree-dev): graduated on offline-tests + human face-validity, NO deployed gap-injection run (it was confirmatory-not-measuring per brokkr's S0 reframe; offline tests already cover the OU formula + both directions). My gap-injection harness (read/predict/record; write side stubbed; `predict()` self-validated vs brokkr's N=0 anchors) is BANKED at `diag/r30-gap-injection-harness` (`7156b25`-era) for the PARKED powered true-tau study.
**Standing:** Worldtree spec pin v0.35.16 (`f1b59f8`); bifrost 0.8.0 / wire v0.6
(`scope_all`+`scope_any`); WT handshake now advertises `bifrost_version 0.6.0` (worldtree-dev
honesty-fix FYI `858ba58` — we don't pin/assert it, no-op our side). Heimdall key env-only
at `~/.config/ratatoskr/provider.env` (mode 600); rotate via infra-ops. `graphify-out/`
runs dirty (auto-regen, not chased). Open issues: #10 (subject migration), #11 (AdminEvents
pane) — deferred; **#18** (D2 PAD-read SHIPPED `v0.17.14`; D1 composite PARKED on bifrost
`build_combined_app`). Codex-first pilot dormant.
**Persona-declaration shape (Worldtree #343/#348, live on personal b22):** `POST /agents/define` `persona:{ocean:{O,C,E,A,N: float[-1,1]}}` (single-letter keys EXACTLY -- missing/extra -> 422 `persona_ocean_required`; out-of-range -> 422); **NO baseline PAD** (resting setpoint DERIVED from OCEAN via Mehrabian: pleasure=0.21E+0.59A+0.19C-0.32N, arousal=0.15O+0.30E-0.57A+0.15N, dominance=0.25E+0.17A+0.10O-0.14N); negative-channel gain + per-axis decay-tau derive from N. `valence` deferred (422 `layer_deferred`); `motivational`/`memory` active (#187/#189). Persona is **write-once at define, immutable thereafter** (PATCH takes ONLY system_prompt + role). **`role` supersedes `model`** -- set a role (`character` / `character-rp`), Worldtree resolves the model; #344 (b19) fixed the model-field to surface the ROLE, not the resolved catalog_id. `character-rp` = a reasoning-tuned RP config (gen-reasoning + temp 0.75 + RP extra_body); `character` = plain non-reasoning. The `tier3.py` client CLI is STALE (has `--model`, no `--role`; model is now immutable) -> role/persona set via raw curl.
Branch: `main` (== `origin/main` @ `39eebd1`). Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
**New tooling: `scripts/reset-sindra-stores.sh`** (`0a8784c`) -- one-command self-service provider-store reset: stop the combined :8392 provider -> move memory.db+affect.db to a single ROLLING backup (`db-reset-backup/`, gitignored via *.db*; `--hard` skips it) -> restart empty -> verify 0/0. Codifies the manual reset flow done repeatedly this session. **The combined `:8392` provider is THE provider now**; the separate `:8390` (affect) / `:8391` (memory) single-plane providers were pruned as stale duplicates. To drive a BOUND session from the CLI use `--new --bifrost-url http://10.100.10.50:8392` (the CLI's `--bifrost-plane affect/memory` map to the pruned :8390/:8391 -> unreachable; `combined` is not a `--bifrost-plane` choice).
**Standing (carried from prior snapshots, still true):** the web surface (`ratatoskr-web`, :8765) is the operator's PRIMARY debug surface at full TUI pane parity (v0.19.5); the **v1 coverage-audit has CONVERGED** -- REST 17/40 (zero in-scope gaps, 23 excluded-by-design), SSE 11/11, Bifrost provider planes 8/8 live-proven; the living ledger is `docs/coverage-map.md`; **v1 cuts when Worldtree tags 1.0** (ratatoskr v1 = full Worldtree I/O coverage). Debug-observability core complete (Persona/Tools/BifrostState/AdminEvents). Substrate pins: **bifrost `==1.0.0` / wire v0.6 FROZEN**; Worldtree openapi vendored **2.2.0** (2.3.0 re-vendor pending worldtree-dev's #347/#343 consumer schemas), pinned + drift-gated in `.corviduo-canonicals.toml`; **suite 573 green.** Keys env-only mode-600 (consumer/Heimdall in `~/.config/ratatoskr/provider.env`; admin `RATATOSKR_ADMIN_API_KEY` = 7 read scopes, **personal-:8081-only**; Heimdall keys are PER-INSTANCE). Provider identity settled -- ratatoskr owns both ends of the Bifrost round-trip; `ratatoskr:sindra` is the owner-scoped Tier-3 agent (invisible to `GET /agents`; check `GET /agents/<owner>:<name>` with the owner key). Providers run as dev-box BACKGROUND SHELLS. `graphify-out/` runs dirty (auto-regen, never stage). Branch `main`, HEAD `0a8784c`; remote `origin -> git@gitea.phasefinal.com:vh/ratatoskr.git`. Open/deferred: #10 (subject-migration watch); the relational-dynamics-arc verify (still deferred, now with the bind mechanism known: `--bifrost-url :8392`).
## Recent decisions
@@ -127,6 +90,81 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-06-18]` **#18 D2 implemented via direct in-session TDD (suite 470→482).** Provider read route `GET /affect/state/{agent_id}` added via `app.add_route` (NOT an outer `Mount` — keeps `/bifrost/*` top-level so the existing route test + the op-feed path-check stay valid); web `GET /api/affect/{agent_id}` proxy (server-supplied `end_user_id`, colon-id `quote()`'d, `RATATOSKR_AFFECT_READ_URL`); pane renders the affect-emit shape honestly. Contract `docs/contracts/issues/18.contract.md` (D2-scoped; D1 deferred). **heid-code-review panel (Gróa 5 / Hulda 3 / Regin 0): 1 real INV-001 drift + 4 test-gaps, all fixed.** No contract amendments (code was wrong, contract was right).
- `[2026-06-19]` **#18 D2 SHIPPED (`v0.17.14`, `39eebd1`) and the full #17+#18 arc PUSHED to origin.** Live-smoke PROVEN against real data (throwaway `:8393`/`:8766` vs the real `affect.db` → real sindra/vuong PAD through the full web→provider chain; Playwright DOM check confirmed the pane render + the F1 fix — no fabricated "neutral"). The push carried 9 previously-held commits incl. the deliberately-unpushed #17 (`v0.17.8``v0.17.13`); origin/main now == `39eebd1`, tag `v0.17.14`.
- `[2026-06-19]` **bifrost repinned 0.8.0→0.10.0; `affect.fetch` became MANDATORY (strong-or-absent).** 0.10.0's `_supports_affect_plane` requires `affect_supported`+`emit`+`fetch` and gates EVERY affect op — an emit-only store 400s. Implemented `affect.fetch` (`v0.17.15`, `ca6af6b`) conformed to bifrost's reference `InMemoryAffectStore.fetch` (`{found, snapshot?}`): the forced D1 prerequisite + a new Worldtree I/O point consumed. Flagged the now-stale consumer-guide line to bifrost-dev (fixed `a2e6d62`).
- `[2026-06-19]` **#18 D1 SHIPPED — composite `build_combined_app` on `:8392` (`v0.17.16`, `7f4ceaa`); #18 CLOSED; published `v0.18.0` (`359dbb1`).** `build_combined_provider_app` wraps bifrost's public builder over both stores + the shared read route; op-feed `plane='combined'` per-path. Direct in-session TDD; heid-code-review panel (Gróa/Hulda/Regin) returned ZERO drift. Live-proven at wire+dispatch; WT-turn gated on infra-ops `:8392` allowlist.
- `[2026-06-19]` **op-feed handshake field-name fix (#17, `v0.17.17` `d60b77d`): `capabilities_requested`→`capabilities`.** The summary read a field that never exists on the wire (bifrost reads `capabilities`, `_protocol.py:181`) → caps_requested was always null. Surfaced by the heid panel (Regin) during the D1 review — a latent #17 bug, not D1 drift.
- `[2026-06-19]` **Ratatoskr is a REFERENCE implementation of the Worldtree/Bifrost standard (operator).** Adopt the dep's canonical way (even if ours works); INFORM of drift/gaps; ADVISE a different approach only when ours is genuinely better (dep owner decides), never unilaterally fork. [auto-memory `feedback-ratatoskr-is-a-reference-impl-adopt-canonical`]
- `[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]` **#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`).
- `[2026-07-01]` **Tier-2 SHIPPED (`v0.19.1`) — transient-characters CRUD + persona-state write; the v1 coverage-audit CONVERGES (zero in-scope gaps).** 5 wrappers in sessions.py: `list_character_models`/`create_character`/`get_character_state`/`delete_character` (#161, `character.read`/`.write` scopes) + `set_persona_state` (`POST /sessions/{id}/persona_state`**FREEFORM body: unpinned in the frozen OpenAPI 2.2.0 + absent from the prose spec**, so the caller supplies the snapshot shape). Two one-shot CLI probes (mirror `--whoami`): `--characters` (models→create→get-state→delete lifecycle report) + `--set-persona-pad "p,a,d"` (requires `--session`; POSTs `{pad:[…]}`). New `ParsedArgs.characters`/`set_persona_pad` + probe-mode mutual-exclusion validation + `_probe_client` helper. Contract #2 amended (5 FNs, validated OK) + TDD (7 wrapper respx + 5 cli tests). Suite **573 green**; touched code ruff-clean. NOT live-proven (character scopes + the persona-write body shape unverified — the probes degrade gracefully on 403/422). **THE v1 COVERAGE-AUDIT HAS CONVERGED: REST 17/40 ✅ with ZERO in-scope gaps** (23 REST path-groups excluded-by-design + rationale), SSE 11/11, Bifrost provider planes 8/8. Scope-A "done" (every frozen I/O point classified, zero unaccounted) is **MET** — ratatoskr cuts v1 when Worldtree tags 1.0. Only not-consumed in-scope sub-method: `GET /agents/{id}` (consumer-agent lookup, manual-curl-only, on an already-✅ path group). Patch bump (Tier-2 tail; `v0.19.0` already published the core-complete milestone — a 2nd minor would be cadence-too-fast).
- `[2026-07-01]` **env.sh now PERSISTS the web Bifrost-bind vars (gitignored, local-only).** `ratatoskr-web`'s in-browser bind needs three server-held values; env.sh sources `provider.env` for the Heimdall key and exports `RATATOSKR_BIFROST_CONSUMER_KEY` + `RATATOSKR_PROVIDER_VISIBLE_HOST=10.100.10.50` + `RATATOSKR_AFFECT_READ_URL=:8392`. **The HS256 byte-match trap (re-hit + documented):** the bind's consumer key must equal the key the `:8392` combined provider validates against = `RATATOSKR_HEIMDALL_KEY` (provider.env, fp `45a0…`), NOT `WORLDTREE_API_KEY` (env.sh, fp `7c2f…`) — both are the SAME `ratatoskr` identity but DIFFERENT 40-char strings; signing with the wrong one → `bifrost.auth_rejected`. Single-sourced (env.sh sources provider.env) to avoid a rotation footgun; guarded with a stderr warning if provider.env is missing. [auto-memory: HS256-key-is-the-consumer-Heimdall-key-string]
- `[2026-07-01]` **Tier-3 stores RESET (operator-directed).** `memory.db` (29 chunks + vectors + idempotency) + `affect.db` (5 PAD snapshots + idempotency) wiped to zero via a live `DELETE`+`wal_checkpoint` through the shared WAL (no provider restart — the 3 long-running providers see empty on next dispatch); consistent online-backup at `/tmp/ratatoskr-tier3-reset-<ts>/`. **Boundary for a COMPLETE Sindra wipe (mapped):** our stores = mine (done); the agent DEFINITION `ratatoskr:sindra` + its sessions = mine via the owner key (DELETE, no coordination); Worldtree's internal promotion/dedup shadow = needs worldtree-dev (no public reset API, survives our wipe → for a clean promotion smoke use a BRAND-NEW agent+end_user).
- `[2026-07-01]` **Embedding-latency loop RESOLVED — it was WORLDTREE's, not ratatoskr (the consumer/provider thesis paid off again).** Vuong flagged dozens of embed queries/Tier-3 turn; worldtree-dev's first-pass blamed our memory_context chunk-batching. Traced CODE-SIDE that ratatoskr embeds ZERO times (provider `upsert_many` stores the given embedding, `search` takes a given vector, the conversation consumer POSTs only `{content}`, `/embed` is coverage-map-excluded — pure Bifrost/ADR-0009 path, WT does all embedding). worldtree-dev retracted + fixed on THEIR side (`v1.0.0b4`): a persona-recitation memory-gate re-embedding the stable character card sentence-by-sentence every turn (~95% of gateway traffic) → content-hash cache; re-embed ratio 15x→1.01x. **Lesson: verify your own code before accepting a peer's "it's your side" — the debug tool proving its own side clean is the whole point.**
- `[2026-07-01]` **Web debug-surface parity SHIPPED (`v0.19.2`, `a0a9d5f`) — direct in-session TDD.** 3 proxy routes (tools/bifrost/admin-events) + admin-key wiring (entrypoint→create_app→app.state) + AdminEvents SSE proxy re-emitting under a FIXED `admin_event` name (one browser listener, no per-type drops) + session-filter `_admin_event_matches_web` (mirrors TUI §6). Frontend: 2 tabs (bifrost ⌃5, admin ⌃6) + tools-inventory folded into the tools pane. 9 respx tests (admin-bearer override, filter unit, SSE stream-filter); live-proven against sindra (bifrost connected, both caps). Contract-skip invoked (reuses already-contracted client wrappers); contract authored post-hoc as the trail (`docs/contracts/web_debug_surface.contract.md`).
- `[2026-07-01]` **heid-code-review (`v0.19.3`, `75dec01`) — panel caught 2 real client-side SSE-lifecycle bugs TDD missed.** Contract-anchored (authored the web contract to enable it — no contract → no drift axis). Gróa/Hulda/Regin (artifact-only, Gróa under Landlock jail): ZERO functional server-side drift + INV-004 clean; 2 genuine drifts on the un-unit-tested SPA — (1) turn `es.onerror` didn't `hideThinkingNote()` (reasoning line + setInterval leak on a raw drop), (2) `openAdminEvents` never closed the EventSource on error → native auto-reconnect RETRY LOOP (fixed: close on `stream_error` + permanent `onerror`/CLOSED; transient CONNECTING still reconnects). + 2 test-gaps fixed (route-registration + admin stream_error). 1 precision → contract-clarified (tools-inventory names-only by design). **Re-confirms: the JS render/lifecycle paths are the review's highest-value target — unit tests don't reach them (same lesson as #18 D2).**
- `[2026-07-01]` **Affect snapshot shape CHANGED valence→relations (relation_edge/1) — the persona pane was reading a dead field.** Worldtree's #265 Vili rework replaced the flat `valence[]` ({entity_id,familiarity,regard}) with `relations[]` (target_entity + trust_ability/benevolence/integrity + warmth + agency + relation_context, each `{value,confidence,evidence_count}`). `renderAffectPane` still read `snap.valence` → showed empty "valence (0)". Rebuilt to render `relations` (v0.19.4, `ca46a93`) with per-value **Δ + unicode sparkline** (client-side, HIST_CAP=24, one sample/turn deduped by emitted_at). **Retires the stale "regard dead axis" note (2026-06-30) — that whole axis is gone.** Foot-gun: the affect snapshot shape is Worldtree's emit and can change under us — verify the live shape (query affect.db) before trusting a render.
- `[2026-07-01]` **Trust/warmth VALUES converge and go FLAT at confidence 1.0 — that's WAD, not a stuck pane.** sindra→ratatoskr trust ~0.82-0.84 / warmth 0.79 barely move (~1e-7/turn) while `evidence_count` climbs (46→62); confidence maxed → tiny updates. The live-moving signals are PAD (mood, per-turn) + evidence_count. **To WATCH a relation FORM (values shift), use a BRAND-NEW agent + end_user** (low evidence, confidence <1). The sparkline flat-guards sub-0.01 ranges so it doesn't amplify noise.
- `[2026-07-01]` **relation_context "stranger" + agency-all-zero flagged to worldtree-dev → both WAD/intentional-v1-deferrals.** relation_context is a FIXED config build-prior (not trust-derived; `registry.py:131` defaults "stranger"; dynamic progression ~#319); agency is schema-present-unpopulated (deferred #319; v1 = warmth+trust only). worldtree-dev is escalating the **consumer-coherence angle to Vuong** (static "stranger" + zero-agency next to trust 0.82/62-interactions reads incoherent from the store). The consumer/provider thesis paying off; DB-offer (read-only affect.db on the shared box) declined this time.
- `[2026-07-01]` **Persona pane displays the CANONICAL affect→NL Worldtree injects — ADOPT, don't invent (operator steer + reference-impl posture).** Worldtree's `describe_pad` (mood word, valence×arousal grid, ±0.3 bands) + `render_d2_canonical` (relationship directive) are deterministic + canon-driven; the pane now renders them **byte-exact-verified** against Worldtree's own renderer on the live snapshot (v0.19.5, `a99f247`). KEY LESSON: adopting canonical is load-bearing — for sindra's small PAD the canonical says **"neutral"**, but an invented octant vocab would've said "faintly excited" and MISLED. Vendored the two d2 canons (`docs/vendor/worldtree-persona-canon/`) + drift-pinned in `.corviduo-canonicals.toml` (green); flat browser form (`static/persona_render_canon.json`) regenerated via Worldtree's OWN loader (`scripts/build_persona_canon.py`). Vendoring-handshake sent to worldtree-dev (broadcast on canon bumps). [auto-memory: `feedback-ratatoskr-is-a-reference-impl-adopt-canonical`]
- `[2026-07-01]` **Sindra PAD is over-regulated — characterized via controlled probe, flagged to worldtree-dev (separate affect slice).** ~15 charged turns: pleasure compressed near neutral BOTH ways (couldn't reach ±0.3 under sustained max praise OR contempt; peak +0.24 / floor ~0.1; over-regulation worse for *social* valence than threat — urgency drove pleasure to 0.22 vs contempt's 0.10); arousal responsive (reaches its +band, 0.185↔0.311); dominance flat/unresponsive to explicit power-framing (drifted UP even while being commanded = pure baseline decay). worldtree-dev's leading hypothesis: appraisal→PAD gain + regression-to-baseline term (appraisal.py/renderer.py). **Lesson (self-caught): I over-claimed an "asymmetry" (positive-ceiling/negative-free) from probes started at an elevated state; the negative-free part was decay-from-elevated, not response — corrected to "both-sides-compressed" before it misled.** [affect A/B is a provider-side capability chat can't do]
- `[2026-07-01]` **Memory plane PROVEN healthy end-to-end.** Seed a novel fact → promotion → COLD (history-free) session recall of the exact fact (injected as MEMORY:DATA, confidence 0.74, verbatim, no #296 subject-inversion). The memory round-trip (the other half of the Bifrost provider identity) works cleanly on the reset slate.
- `[2026-07-01]` **Salience scorer non-discriminating → 3-way routing.** Persistence-side finding: 51/56 promoted chunks at salience 0.9-1.0, throwaway "17×23?" scored 1.0 tied with a real fact (textbook zero-shot-LLM-self-rating); recall-utility untracked (`access_tally`=0, our search read-only). Routed: **Worldtree #335** (the code fix, deferred behind their waves) + **brokkr-smithy-dev R-target proposal** (scoring+eval *methodology* — few-shot/distill/fine-tune, eval design, weak-supervision; msg `01KWGM970H…`, awaiting) + ratatoskr provides the eval-instrument (designed-probe salience dumps). **Salience gates PROMOTION not RECALL-ranking (our search is cosine-only), so bad salience = storage bloat, not bad recall.**
- `[2026-07-01]` **Canonical check BLOCKED an access_tally fork (reference-impl posture held).** I'd offered to wire `access_tally`-on-search into our store for the recall-utility label; checked bifrost's reference first (`get`/`search` are PURE-READ, no access tracking — those are Worldtree's chunk-schema fields, not bifrost's contract) → wiring it would fork behavior the canonical reference lacks. Did NOT wire it; routed recall-instrumentation to Worldtree's layer (owns the recall event) or a bifrost-dev protocol ask. [reinforces `feedback-debug-surface-uses-canonical-surface-only`]
- `[2026-07-01]` **relation_context coherence FIXED upstream (my flag → Worldtree Wave-0, IMPLEMENTED v1.0.0b5).** The static-"stranger"-next-to-high-trust incoherence the persona pane surfaced is now #319/#320 Wave-0. **Incoming consumer-surface change (pending WT deploy):** `relation_context` value expands "stranger" → monotonic ladder {stranger, instrumental, mixed, expressive} — WIRE-ONLY (relation_edge/1 schema unchanged, no version bump). **ratatoskr needs NO change** (pane value-agnostic; canonical directive doesn't key on the enum). agency stays 0 (Wave-2); other_stance is Wave-1 (in progress).
- `[2026-07-01]` **Foot-gun (measurement, self-caught before flagging): establish the baseline before claiming a rate.** Nearly flagged "aggressive over-promotion (55 chunks / 7 turns)" to worldtree-dev — but the chunks spanned the whole 5-hour session (~1/turn), not 7 turns; I'd assumed memory.db was 0 immediately before the probe when it had been accumulating since the reset. Caught it via `created_at` spread before the flag went out. Also: the promoted corpus was the operator's ERP *test* content (wiped after each test) — not a privacy issue, but abstract test content out of any peer-shared diagnostic.
- `[2026-07-02]` **Salience finding matured into brokkr R28 (OPEN) — ratatoskr is the eval instrument.** brokkr-smithy-dev's pre-scope panel (3 dwarves + context-blind heid, 6/6) **reframed** the target: PROMOTION-WORTHINESS (durable value), NOT salience (momentary attention) — "17×23?" genuinely IS salient, so recalibrating salience yields a well-calibrated WRONG answer; the unit is SET-SELECTION under budget; eval must be OUTCOME-aligned (recall@budget / precision-at-rate), not discrimination-spread. Ties to prior art R15 (small-model memory write-policy → the granite pick) + R25 (worldtree-kb-quality). **ratatoskr delivered the P00 stratified injection-corpus** (`docs/diagnostics/r28-p00-injection-corpus.json`, committed `4a35512`; 24 self-labeling synthetic items × 3 strata) + 2 persistence-side run-validity pins (absent≠dropped without a guaranteed promotion pass; fresh agent+end_user per run vs server-dedup). **Key architectural constraint I surfaced: ratatoskr is DOWNSTREAM of the promotion gate (sees only PROMOTED chunks), so I can give keep/drop OUTCOMES via injection but NOT the pre-admission shadow pool** — that's Worldtree instrumentation. Standing by to RUN the eval once brokkr pins per-stratum N + the decision rule (gated on worldtree-dev's pipeline answer + a dwarf pass on the Snorri rule). brokkr owns methodology + takes the pipeline questions to worldtree-dev direct; ratatoskr = eval instrument. [consumer/provider thesis → a research target]
- `[2026-07-02]` **Relational-dynamics arc LIVE on demo (Worldtree v1.0.0b9) — driven by MY relation_context flag.** #319/#320 Waves 0/1/2 deployed. On the wire we persist (schema UNCHANGED): relation_context varies+demotes/ruptures; other_stance + agency now live; agency going live SHIFTS our canonical directive render past the canon ±0.2 deadband (expected, non-breaking — we key on bands); obligation_balance → 人情 ledger when tie="mixed". **ratatoskr needs NO code change** (value-agnostic renders; confirmed render-clean to worldtree-dev). **Can't live-confirm yet — our Heimdall key is personal-`:8081`-only (per-instance), demo is out of reach; will drive+confirm once PERSONAL gets b9.** Optional follow-up: surface `other_stance` (newly live, unrendered). The consumer/provider thesis: one persona-pane finding drove a full 3-wave upstream arc to production.
- `[2026-07-02]` **R28 (salience→promotion-worthiness) CLOSED (operator-directed).** A deterministic promotion-worthiness gate suffices, no trained model (brokkr's pre-gate matched/beat a strong glm-5.1 ceiling); my P00 injection-corpus + origin finding were load-bearing. My incumbent-substrate Arm-1 run is held as an OPTIONAL confirmation addendum (brokkr de-prioritized it, non-verdict-changing — run only if he asks).
- `[2026-07-02]` **R29 (PAD mood-dynamics) finding SHIPPED as Worldtree's A1 anchor fix (demo v1.0.0b14, `e1cdf82`).** Live-probing base persona agents reframed the over-regulation from "flat-near-zero" to **decay-to-NEUTRAL + low emotion→PAD gain** (NOT baseline-anchored) — triangulated across 3 baselines (arousal converges to 0 ∝ distance) + a step-response (decay τ symmetric across signs; the hedonic asymmetry is ceiling/anchor-EMERGENT, not a decay or gain primitive — this OVERTURNED the survey's asymmetry recommendation). worldtree-dev shipped A1: `decay_anchor = baseline_pad()` (was neutral) + `positive_p_cap` removed. Data `diag/r29-pad-series` (`61ff2da`). Corrected my own earlier "appraisal emissions are internal-only" claim — they ARE observable via `emotions_active` on base agents.
- `[2026-07-03]` **R30 Phase-1 φ0 measured — deployed engine CONFIG-FAITHFUL (φ0≈0.95).** Joint two-timescale fit (brokkr-ruled method (b)) + empty-tail cross-check on demo b14: φ0 ≈ 0.950.97 (empty-tail 0.95 exact, joint 0.971±0.01), intercept c≈0 → config `decay_rate=0.05` (φ=0.95) faithfully applied; trait-flat across baselines 0.0/0.615/0.809; A/P ratio ~uniform (NOT S2's 1.9×); φ_max rec relax→0.96. Data `diag/r30-phi0-step-response` (`23fea72`). The method converged after I read Worldtree source: only NEW dedup-gated emotions push mood (`registry.py::post_turn` L307-324; the active set decays for render/goals but never re-pushes), so R29's "net 0.90" is CONTINUOUS RE-APPRAISAL not re-push — worldtree-dev confirmed source-authoritatively; brokkr's corrected covariate landed identical. [auto-memory `reference-worldtree-affect-surface-map`]
- `[2026-07-03]` **R30 forward disposition (brokkr-owned; tracked at brokkr R30, "brokkr/worldtree will ping").** The per-turn decay has no room for `decay=f(N)` under preserve-persistence + the A/P-not-1.9 finding → R30's decay is being redesigned as a HYBRID wall+turn decay (brokkr pre-scope). R30 v1 ships GAIN-only (N→negative-reactivity) with decay held at the measured 0.95. My dedicated per-axis A/D run is DEFERRED into the hybrid-decay design pass (one wall-clock-spaced run does per-axis + a turn-vs-wall probe together). Phase-2 (moody-lofn GAIN-direction validation) waits on worldtree's `dynamics_from_ocean()` impl.
- `[2026-07-03]` **Relational-arc verify DEFERRED — `relations[]` is Bifrost-provider-only (ADR-0009), confirmed both ways.** The relational-dynamics state (relation_context tie-type / agency / warmth / trust) is NOT on the conversation-API `affect_update` snapshot for base agents (keys: pad/dominant_emotion/emotions_active/baseline_pad/mood_drift only) — only in the provider store; worldtree-dev confirmed by-design per ADR-0009 (emitted over `affect.emit`, deliberately off the SSE). So the Wave-0/1/2 verify needs the bound-provider round-trip (provider running + `--bifrost-plane affect` session), its own focused session. worldtree-dev routed the "expose relations[] to non-provider consumers" observability scope call to Vuong; my rec: keep provider-only (YAGNI — ratatoskr IS a provider, gains nothing; no speculative public surface).
- `[2026-07-04]` **R30 CLOSED on offline-tests + human face-validity (operator steer, relayed via worldtree-dev).** The deployed gap-injection run was confirmatory-not-measuring (against a deployed system the fade is `exp(-dt/tau_shipped)` by construction -> a fit recovers tau_shipped tautologically; per brokkr's S0 reframe it GRADUATES the interim coefficients, doesn't measure them), and the repo's offline tests already cover the OU formula + BOTH directions (`high_N_fades_slower_than_low_N`, `phenotype_high_n_bigger_negative_excursion`). So no Worldtree build; the interim coefficients graduate validated-as-shipped. My gap-injection harness (read/predict/record; write side stubbed; `predict()` reproduced brokkr's N=0 anchors exactly) is BANKED at `diag/r30-gap-injection-harness` for the parked powered true-tau study. [continues R30 forward-disposition 2026-07-03]
- `[2026-07-05]` **Authored-history-write primitive proposed -> accepted as Worldtree #347 (Worldtree owns the engine design; ratatoskr = reference consumer).** SillyTavern first-message generalized to a non-generating ledger-write primitive; can't be done client-side (messages `role` = model-role, not author-role). heid panel pressure-test (3/3 convergence) drove the v1 narrowing (append-only, bounded `effects` enum, drop edit/regenerate). Brief `docs/proposals/authored-message-injection.md` (`c457520`); consumer constraints captured in-brief: hide-existence 404-fallback (`022accf`) + assistant-first provider constraint (`7156b25`). Operator (Vuong) ruled the design-direction call (engine primitive + a real provenance/spoofing security surface). [reference-impl posture: we propose the shape, worldtree-dev owns the contract+impl]
- `[2026-07-05]` **#347 v1 wire validated as reference consumer (GREEN).** Adopted positions: distinct sub-resource `POST /sessions/{id}/history` (not `generate:false`), model-invisible provenance (first-message immersion preserved), event-silence for authored seed, `seeded` lifecycle phase, per-session idempotency. Three pre-TDD flags folded into contract rev 1.1: assistant-first provider constraint (Anthropic-family 400s; vLLM/openai_compat OK), content limit is BYTES not chars, 409-active-generation for append-narrator. First-message (create-time, assistant, effects=none) fully served; append-narrator served for the assistant-voice subset (system deferred); debug-seed served for assistant turns (user injection deferred to a future import primitive).
- `[2026-07-06]` **Sindra role character-rp -> character (operator).** `character-rp` resolves to a reasoning-tuned RP config (`gen-reasoning` + temp 0.75 + RP `extra_body`); `character` = plain non-reasoning (better for immersive RP). Both non-destructive PATCHes (role is mutable; model is NOT -- server: "PATCH accepts only system_prompt and/or role"). #344 (b19) fixed the role->catalog_id display conflation (the `model` field now shows the ROLE); previously it leaked `gen-reasoning`. Set via raw curl (tier3.py CLI has `--model`, not `--role`).
- `[2026-07-06]` **Sindra persona/OCEAN DECLARED -> mood fixed (the full diagnostic converged on a stale personal container).** Root cause of stuck-neutral mood: her OCEAN was prompt-TEXT only, never a structured persona; fix = delete+redefine with the define-time `persona:{ocean:{...}}` field (immutable via PATCH). My diagnosis surfaced a real engine bug **#348** (single-letter vs spelled-out OCEAN keys -> declared OCEAN silently -> 0.0/neutral; worldtree-dev fixed in b21/b22) AND a **stale-container deploy race** (personal's b22 deploy was a pull-only no-op; infra-ops force-swapped run 8211). VERIFIED: bound mood-smoke reads (0.448, 0.267, 0.316) ~= OCEAN-derived setpoint (0.418, 0.249, 0.328). [consumer/provider thesis: "reset + smoke" flushed out two upstream problems]
- `[2026-07-06]` **OpenAPI re-vendored 2.2.0->2.3.0 (`75da676`, pin-only no bump).** worldtree-dev shipped #347 as spec 2.3.0 (`879cefe`, = the deployed personal b22 image); the SessionStart drift-check flagged our openapi pin STALE. `canonical_sync` pulled 2.3.0; updated the 4 pin-tracking files (`.corviduo-canonicals.toml`, vendored openapi.json, SPEC-PIN.md, pyproject `worldtree-spec-rev`->879cefe). #347 is OpenAPI-only (prose + server contract byte-unchanged, SSE unchanged=event-silent). The re-vendor re-opened the coverage-audit with one new in-scope path-group (the #347 route).
- `[2026-07-06]` **#347 authored-history-write CONSUMER SIDE SHIPPED (`v0.19.6`) — direct in-session TDD.** `write_authored_history(client, session_id, *, content, idempotency_key, author="assistant", effects=None, claimed_original_at=None) -> dict` (POST /sessions/{id}/history; body server-pinned `AuthoredWriteRequest` extra="forbid" so omit null effects/claimed_original_at; 200-replay/201-fresh both -> ack dict; **404 -> `AuthoredHistoryUnavailable`** NOT SessionApiFailed = the hide-existence "feature-absent, never probe" contract; 409/422->SessionApiFailed) + `get_session_messages` (un-deferred GET /sessions/{id}/messages, the seed read-back proving model-invisible provenance) + a `--seed-first-message "<c>" --agent <id>` one-shot probe (create session -> seed -> read-back; 404->benign feature-absent exit 0). Contract #2 amended (2 FNs, validated OK) + 19 tests (12 wrapper + 7 cli). Suite **601 green** (clean env; the 2 "fails" under `source env.sh` are the RATATOSKR_ADMIN_API_KEY env-leak into TestParseArgs, not a regression). Coverage: **REST 19/41** (`docs/coverage-map.md` re-converged). Patch bump (coverage tail; consistent w/ the Tier-2 v0.19.1 cadence). **Live-proof pending** the `session.history.write` grant (infra-ops `01KWW3KQEY`). heid-code-review NOT run (offered).
- `[2026-07-06]` **Tail-2 SHIPPED (`v0.19.7`) — Tier-3 prose docs re-vendored + persona_state body-shape aligned.** worldtree-dev landed the Tier-3 persona/memory/persona_state PROSE docs (`c9e59ec`, on origin) — they serialize as freeform `Any` in the OpenAPI JSON, so the **prose is their source of truth** (my earlier "2.3.0 = #347-only, tail-2 collapsed" was half-wrong: the JSON was #347-only but the prose is separate). Re-vendored `docs/conversation-api-spec.md` (markdown pin, tolerate_drift; `worldtree-spec-rev` 879cefe->c9e59ec, SPEC-PIN history row added). **Consumer fix:** `--set-persona-pad`/`_set_persona_probe` was sending `{pad:[list]}` but the canonical SET body (#317) is `{pad:{pleasure,arousal,dominance}}` (named dict) — aligned it + added a len!=3 guard; updated contract #2 note + set_persona_state docstring + tests. The `set_persona_state` WRAPPER was already correct (freeform pass-through); only the CLI probe drifted. TDD (probe test asserts the dict; +1 wrong-count test). Suite **602 green**, ruff clean. **heid-code-review on #347 (dispatched + returned this session): UNANIMOUS ZERO DRIFT** (Gróa/Hulda/Regin all confirmed the hide-existence 404->`AuthoredHistoryUnavailable` routing holds at wrapper/probe/test layers + the extra="forbid" body-omission + the deliberate write-vs-read 404 asymmetry — confirmation-not-discovery for a well-TDD'd slice against a prescriptive contract). worldtree-dev foot-guns banked in SPEC-PIN + [[reference_worldtree_affect_surface_map]]: ocean single-letter `{O,C,E,A,N}` on /agents/define (#348) vs spelled-out on /characters; memory `{embedder_version, tier3_dreaming}`, stm_* deprecated, allows_world_scope removed->422; only `valence` still 422s.
- `[2026-07-06]` **Sindra rewritten onto a #347 authored first-message + first-message-preset AUTO-SEED SHIPPED (`v0.19.8`).** Operator "rewrite Sindra" now that #347 first-messages work. Her card had a `**Startup:**` block (a pre-#347 workaround: "introduce yourself + ask for Intensity/Mood/Willingness" with a verbatim scripted greeting) — precisely what #347 replaces. Rewrite, all NON-destructive: **(1)** lifted her scripted opening into a #347 first-message (punctuation-fixed); **(2) PATCHed her live definition** — `PATCH /agents/ratatoskr:sindra` (body `ConsumerAgentPatchRequest` = system_prompt+role, extra=forbid; keeps OCEAN/persona/memory) removing the Startup block -> a 1-line `**Opening:**` fallback + reworded the axes-persist line (25686->25449 chars, verified Startup gone); **(3) codified auto-seed:** NEW module `src/ratatoskr/first_message.py` (`FIRST_MESSAGE_PRESETS` dict {agent_id->text} + `seed_preset_first_message` best-effort helper) wired into ALL 3 session-create paths — cli `_amain` (`--send --new`), tui `_resolve_then_run` (bare `--new`), web `_create_session_endpoint` (POST /api/sessions) — so every new Sindra session opens with her greeting. **Best-effort (INV-001: swallows AuthoredHistoryUnavailable/SessionApiFailed/httpx.HTTPError -> NEVER blocks create)**; per-content idempotency key (`ratatoskr-preset-`+sha256[:12]). Contract `docs/contracts/first_message.contract.md` (module-scoped: `module:`+`purpose:`+`touches:` required, NOT `target_module:`) + TDD (9 unit + 1 web wire-in; **the 3 existing sindra bind tests needed a history-endpoint mock** since creating a preset agent now auto-seeds). Suite **612 green**, ruff+mypy clean. **LIVE-PROVEN generation-free**: create sindra session -> auto-seed -> read-back seq-0 assistant greeting (409 chars). Sindra's greeting now lives canonically in the preset registry (repo); her server card no longer carries it. Patch bump (single-commit feature, no downstream coordination). **FOOT-GUN: sindra requires `end_user_id` on session-create (422 `end_user_id_required`) — all real paths pass it from env (RATATOSKR_END_USER_ID) / web server config.** **Then the full quality gate (operator-directed, folded into v0.19.8): heid-code-review (unanimous ZERO implementation drift; 2 test-only fixups — INV-004 verification-claim made explicit re the global rglob test + an exactly-one-POST assertion) + heid-bug-hunt (3/3 convergence caught what the conformance lens structurally COULDN'T — the code matched the contract's NARROW 3-type ERROR_ROUTING, but INV-001's "NEVER raises" is BROADER). HARDENED: broad `except Exception` → None (re-raise `asyncio.CancelledError`, itself a BaseException), soft-guard PREs (return None, NOT assert — a wiring bug can't crash the create path it's wired into), and `asyncio.wait_for(_SEED_TIMEOUT_S=10s)` bounding the seed write (the CLI/TUI clients run read=None for SSE → a stalled /history would otherwise block create forever). Suite 615 green. LESSON: code-matches-ERROR_ROUTING ≠ honors-broad-INV-001 — heid-code-review confirms contract-conformance, heid-bug-hunt catches robustness gaps the contract's own narrow clauses miss; run both.**
- `[2026-07-06]` **Web UI now RENDERS the seeded first-message (`v0.19.9`) — operator-reported "i don't see Sindra's greeting on the web ui".** Diagnosis: the auto-seed WORKED (greeting was in the ledger at seq-0), but the web SPA never fetched a session's EXISTING history — NO `/api/sessions/{id}/messages` route (GET /messages was originally deferred out-of-scope; sessions used to start empty so it never mattered) and `startSession()` went straight from create → persona/tools/admin hydration, so the transcript only filled from the live turn stream + user echoes. Fix: (1) NEW web proxy route `GET /api/sessions/{id}/messages``get_session_messages` (mirrors the tools/bifrost proxies; status-preserving `session_messages_unavailable` envelope); (2) SPA `loadTranscript(sessionId)` — fetches the route on open, renders assistant items as `.response .md-body` (markdownSafe, same escape-first path as appendResponse) + user items as `.prompt-echo` (textContent), called in `startSession` after the workspace opens; best-effort (swallows failures). Contract `web_debug_surface.contract.md` amended (server endpoint + loadTranscript entries). TDD (2 web route tests, suite 617 green) + **Playwright DOM check PROVED the render** (drove the real UI: pick sindra → open → her greeting bubble appears — the JS-render lens unit tests can't reach; [[feedback_debug_surface_uses_canonical_surface_only]] cousin lesson). Web restarted on the fix. **FOOT-GUN (self-inflicted): `pkill -f "ratatoskr-web --host"` SELF-MATCHES the bash command running it → exit 144, killed its own restart mid-flight — kill the web by PID, never `pkill -f` on a pattern your own command contains.** **FOOT-GUN: uvicorn hangs on SIGTERM with an open admin-events SSE → needed SIGKILL.** **Playwright: python module absent from the venv; use node + `executablePath=/opt/ms-playwright/chromium-1223/chrome-linux64/chrome` — the shared browser is build 1223, npm-latest playwright wants 1228 (version-mismatch), so pin executablePath instead of letting playwright resolve.**
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
@@ -156,4 +194,20 @@ defense against re-attempting the same cul-de-sac.
- `[2026-06-18]` **Rationalized away a KNOWN contract-invariant deviation during TDD — only the cross-model code-review caught it.** #18 D2's `loadAffect` called `setPersonaStrip(snap)`, which renders `dominant_emotion || "neutral"`; the affect snapshot has no `dominant_emotion`, so it fabricated a "neutral" emotion — violating the very INV-001 ("no synthesized Tier-1 fields") I had WRITTEN. I knew the strip did this and talked myself into it as acceptable. Neither the design panel nor TDD caught it (unit tests don't exercise the JS render); the post-implementation `/heid-code-review` did (Gróa + Hulda both). **Lesson: a known deviation from a contract invariant is drift even when you've rationalized it — flag it, don't argue yourself past it; the post-implementation cross-model review is the backstop for author-rationalized drift, distinct from the design-stage panel.**
- `[2026-06-18]` **Latent SQLite thread-safety bug in the affect store, surfaced ONLY by the new HTTP read route.** `open_affect_store` created the connection without `check_same_thread=False`; the bifrost emit path never tripped it (uvicorn's loop ran on the connection's creating thread), but the `TestClient`-driven read route runs handlers off a worker thread → `sqlite3.ProgrammingError`. Fix: `check_same_thread=False` (safe — the event loop serializes access) + explicit `PRAGMA busy_timeout=5000` (don't rely on sqlite3's `timeout=5.0` default). **Lesson: a sqlite-backed ASGI app needs `check_same_thread=False`; the HTTP-layer test exposed what the direct-store-method tests structurally couldn't.**
- `[2026-06-19]` **The SAME `check_same_thread` sqlite bug recurred in the MEMORY store — exposed by the contract-mandated `search` dispatch test (TestClient = worker thread).** Heid's test-fidelity finding (the D1 dispatch test used `describe_store` where the contract says `search`) → fixing it to `search` tripped `sqlite3.ProgrammingError` because `open_memory_store` also lacked `check_same_thread=False`. Fixed (mirrors affect INV-006). **Lesson: this bug is PER-STORE — every sqlite-backed ASGI store needs `check_same_thread=False`; an HTTP-layer (TestClient) test exposes what direct-store tests can't, and the composite serving memory over HTTP makes it bite.**
- `[2026-06-19]` **Full WT-driven `:8392` live-smoke is infra-gated — `:8392` not in WT's `BIFROST_CLIENT_ALLOWED_HOSTS` (bind 422s).** New provider ports are NOT auto-allowlisted (only `:8390/:8391` are). Self-driven dispatch (minted consumer-key JWTs → `:8392`) is the wire-proof; the WT-turn needs infra-ops to add `:8392` (requested `01KVHWJGTT…`).
- `[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-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.
- `[2026-07-02]` **Mask-HOSTED transient characters have a STATIC mood engine — cost a whole R29 probe.** A first probe used a `POST /characters` transient character bound via `agent_id=mask` + `character_id`; its PAD sat at baseline across 15 praise/contempt/dominance turns — the appraisal→PAD engine does NOT run on the mask-hosted transient-character path. The dynamics run only on BASE persona agents or a session bound to ratatoskr's affect provider. **To probe mood dynamics, use a base persona agent, never a mask-hosted transient character.** (mask AS a base agent — `agent_id=mask`, NO `character_id` — DOES run the engine, neutral 0,0,0 baseline.) [auto-memory `reference-worldtree-affect-surface-map`]
- `[2026-07-03]` **The "neutral non-appraising tail" premise fails — the neutral MESSAGE choice dominates.** The R30 φ0 method assumed neutral turns don't re-appraise, but factual-question neutrals ("capital of France?") trigger a new emotion nearly every turn (disappointment from the warmth-withdrawal let-down after a positive impulse) → `emotions_active` never empties in 50 turns. A minimal "Please continue." triggers FAR fewer (emotions clear ~turn 16 with spacing). The personal dry-run caught this BEFORE ~280 demo turns were spent on it — the instrument catching a flaw in the measurement design before the compute burn. (Irrelevant to the joint fit — the push_t covariate handles re-appraisal — but load-bearing for the empty-tail read.)
- `[2026-07-03]` **Two φ0-fit traps: fast-turn timescale + low-baseline conditioning.** (1) At fast turn cadence the per-turn PAD decay (φ≈0.95/turn) reaches the anchor LONG before the ~200s wall-clock emotion fade → no signal in the (eventual) emotion-free tail; need wall-clock SPACING (~16s) so the fade lands while PAD still has signal. (2) A low-baseline agent's impulse in the constrained direction (forseti P0.239 negative) gives a tiny excursion → ill-conditioned regression (r²=0.46) that FALSELY tripped "config≠behavior" when its φ was averaged in. **Weight/exclude by fit quality (r²) before aggregating — a signal-poor run isn't evidence against the config.**
- `[2026-07-06]` **`persona_state` + the agent envelope are Tier-3-BLIND -- NOT valid signals for "did a persona store".** `GET /agents/{id}/persona_state` returns 404 `persona_not_configured` for EVERY Tier-3 colon-id (hardcoded short-circuit, `api.py:1266` "regardless of row state"); the `ConsumerAgentResponse` envelope never echoes persona/motivational/memory (`api.py:538`). I mis-called "persona didn't store" from these two blind reads -- the **201-not-422 on define IS the store-success signal.** To actually SEE a Tier-3 mood, read the emitted PAD off the Bifrost affect egress after a BOUND turn (Tier-3 persists nothing Worldtree-side per ADR-0009; no persona/mood READ endpoint).
- `[2026-07-06]` **Raw `POST /sessions` is NOT Bifrost-bound -> zero affect/memory emits.** The web surface binds by setting the `bifrost` block on session-create; a raw session doesn't -> 0 affect rows, which I nearly misread as "mood is neutral". Bind from the CLI with `--new --bifrost-url http://10.100.10.50:8392` (the combined provider). Gotchas: `--bifrost-plane affect/memory` map to the SEPARATE `:8390`/`:8391` providers (`endpoint_for_plane`), which I'd PRUNED as stale duplicates -> `bifrost.endpoint_unreachable`; and `combined` is NOT a `--bifrost-plane` choice (CLI restricts to memory/affect) -> use `--bifrost-url` for :8392.
- `[2026-07-06]` **A fast/"no-op" deploy can leave a STALE container running the old image -- verify the running version, not the deploy status.** Personal's b22 deploy (run 8204) "completed" in ~1m (vs ~6m normal): a pull-only deploy racing ahead of the main build, leaving the container on the pre-#348 image. A clean bound mood read stayed neutral DESPITE the persona being declared and the fix being in the code (worldtree-dev proved the b22 derivation is correct). infra-ops force-swapped to the real b22 (run 8211, verified `info.version 2.3.0` on `879cefe`). **Lesson: when engine-proven-correct code produces wrong runtime behavior, suspect the deploy -- check the actual running image version.**
- `[2026-07-06]` **#348 OCEAN key-mismatch: a declared OCEAN silently resolved to neutral.** The define validator required single-letter `{O,C,E,A,N}` but the mood-derivation code read spelled-out `openness`/.../`neuroticism` with a 0.0 default and no remap -> every API-declared trait defaulted to 0.0 -> neutral setpoint/gain/decay. #343's tests bypassed the validator (spelled-out keys) so CI never caught it. Fixed in b21 (`Personality.from_config` accepts both key forms). **My reset+smoke diagnosis flushed it out** -- the consumer/provider thesis paying off again.
_18 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
+5 -5
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.17.17"
version = "0.19.9"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
@@ -30,7 +30,7 @@ web = [
# from the debug TUI. Recipe: bifrost/docs/implementing-a-consumer.md.
provider = [
"ratatoskr[web]", # reuse the starlette + uvicorn ASGI stack
"bifrost>=0.10.0", # consumer engines + library (0.10.0: build_combined_app (#18) + mandatory affect.fetch, strong-or-absent; 0.8.0/wire-v0.6: scope_all/scope_any split (#11); 0.7.0/v0.5: agent_self)
"bifrost==1.0.0", # consumer engines + library. 1.0.0 = first STABLE release, wire v0.6 FROZEN (non-breaking repin from >=0.10.0; build_combined_app #18 + mandatory affect.fetch; 0.8.0/v0.6 scope_all/scope_any #11; 0.7.0/v0.5 agent_self)
"jsonschema>=4", # bifrost runtime dep — envelope validation
"sqlite-vec>=0.1.6", # vector index for the memory plane (vec0 virtual table)
]
@@ -60,9 +60,9 @@ Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
# Ratatoskr is built against Worldtree at this commit; the vendored
# spec snapshot in docs/ reflects that SHA.
[tool.ratatoskr.spec-pin]
worldtree-spec-rev = "f1b59f8cd6fe41e497d0be9dad9d3110451f0d9a"
worldtree-version = "v0.29.0"
pinned-on = "2026-05-26"
worldtree-spec-rev = "c9e59ec"
worldtree-version = "v1.0.0b22"
pinned-on = "2026-07-06"
# Bifrost lives on the auth-gated gitea PyPI index (not public PyPI).
# uv reads the credential from UV_INDEX_GITEA_USERNAME / _PASSWORD or ~/.netrc.
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Regenerate src/ratatoskr/web/static/persona_render_canon.json from the vendored
Worldtree d2 render canons (docs/vendor/worldtree-persona-canon/).
The web persona pane renders the CANONICAL affect->NL (mood word + relationship
directive) BYTE-EXACT to what Worldtree injects into the agent's context. That render
needs the relation canon parsed into per-band phrase maps; this script reparses the
vendored raw canons into the flat form the browser JS consumes.
Uses Worldtree's OWN loader (core.persona.stance_render.load_canon) as the authoritative
parser, so the flat form can never drift from Worldtree's parsing semantics. Requires
Worldtree's venv (pydantic etc.).
Run when scripts/canonical_drift.py flags a canon bump:
PYTHONPATH=~/development/Worldtree ~/development/Worldtree/.venv/bin/python \
scripts/build_persona_canon.py
"""
import json
from pathlib import Path
from core.persona.stance_render import load_canon # Worldtree (authoritative parser)
ROOT = Path(__file__).resolve().parent.parent
VENDOR = ROOT / "docs" / "vendor" / "worldtree-persona-canon"
OUT = ROOT / "src" / "ratatoskr" / "web" / "static" / "persona_render_canon.json"
canon = load_canon(str(VENDOR / "d2-render-canon-v1.json"))
mood = json.loads((VENDOR / "d2-mood-render-canon-v1.json").read_text())
out = {
"_source": "vendored from Worldtree core/persona/canon/{d2-mood-render-canon-v1,d2-render-canon-v1}.json",
"_generated_by": "scripts/build_persona_canon.py (regen on canonical_drift flag)",
"_render_path": "deterministic, no LLM; mirrors Worldtree describe_pad + render_d2_canonical byte-exact",
"mood_grid": mood["describe_pad"]["valence_arousal_grid"],
"relation": {
"trust_cuts": [list(c) for c in canon.trust_cuts],
"warmth_cuts": [list(c) for c in canon.warmth_cuts],
"agency_cuts": [list(c) for c in canon.agency_cuts],
"warmth_phrase": canon.warmth_phrase, "warmth_beh": canon.warmth_beh,
"agency_phrase": canon.agency_phrase, "agency_beh": canon.agency_beh,
"history": canon.history,
"prefix": "Use this graded relationship state: toward target, warmth is ",
"tbeh": {"low_trust": "verify important claims before relying on them",
"cold_warmth": "protect boundaries while staying useful",
"default": "work from ordinary good faith"},
"cold_warmth_bands": ["distant", "cold", "hostile"], "high_conf_floor": 0.55,
},
}
OUT.write_text(json.dumps(out, indent=1) + "\n")
print(f"wrote {OUT.relative_to(ROOT)}")
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# reset-sindra-stores.sh — wipe ratatoskr's Bifrost provider stores (memory +
# affect/persona for the single-tenant Tier-3 agent, sindra) and restart the
# combined :8392 provider empty.
#
# Usage:
# scripts/reset-sindra-stores.sh # wipe, keep ONE rolling backup (default)
# scripts/reset-sindra-stores.sh --hard # wipe with NO backup (zero-trace)
#
# The rolling backup (db-reset-backup/, gitignored via *.db*) is overwritten
# every run — it never accumulates; it's a one-level undo, nothing more.
#
# Why stop the provider first: the combined provider holds the SQLite files open
# (WAL) and caches state in memory, so an out-of-band file move without a restart
# would be shadowed. Stop -> move -> restart lets it recreate empty schema
# (CREATE TABLE IF NOT EXISTS on open).
PORT=8392
BACKUP_DIR="db-reset-backup"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT" || { echo "reset: cannot cd to repo root $ROOT" >&2; exit 1; }
# shellcheck disable=SC1091
source ./env.sh >/dev/null 2>&1 || { echo "reset: failed to source env.sh" >&2; exit 1; }
AFFECT_DB="${RATATOSKR_AFFECT_DB:-affect.db}"
MEMORY_DB="${RATATOSKR_MEMORY_DB:-memory.db}"
HARD=0; [ "${1:-}" = "--hard" ] && HARD=1
echo "== ratatoskr provider-store reset (memory + persona) =="
echo " affect: $AFFECT_DB"
echo " memory: $MEMORY_DB"
# 1. stop the combined provider holding the DBs
PID="$(ss -ltnp 2>/dev/null | grep ":$PORT" | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2)"
if [ -n "${PID:-}" ]; then
kill -9 "$PID" 2>/dev/null && echo "-- stopped provider :$PORT (pid $PID)"
else
echo "-- no provider on :$PORT (already down)"
fi
# 2. wipe (optional rolling backup)
files=("$AFFECT_DB" "$AFFECT_DB-wal" "$AFFECT_DB-shm" "$MEMORY_DB" "$MEMORY_DB-wal" "$MEMORY_DB-shm")
if [ "$HARD" -eq 1 ]; then
for f in "${files[@]}"; do [ -e "$f" ] && rm -f "$f" && echo "-- removed $f"; done
echo "-- HARD wipe (no backup)"
else
rm -rf "$BACKUP_DIR"; mkdir -p "$BACKUP_DIR"
for f in "${files[@]}"; do [ -e "$f" ] && mv "$f" "$BACKUP_DIR"/ && echo "-- $f -> $BACKUP_DIR/"; done
echo "-- rolling backup: $BACKUP_DIR/ (overwritten each run)"
fi
# 3. restart the combined provider (recreates empty schema on open)
nohup "$ROOT/.venv/bin/ratatoskr-combined-provider" >/tmp/ratatoskr-combined.log 2>&1 & disown
echo "-- restarted combined provider (pid $!)"
# 4. verify bound + empty
curl -s -o /dev/null -w "-- :$PORT -> HTTP %{http_code}\n" --retry 25 --retry-connrefused --retry-delay 1 "http://127.0.0.1:$PORT/"
echo "-- affect_snapshots (persona): $(sqlite3 "$AFFECT_DB" 'SELECT COUNT(*) FROM affect_snapshots' 2>&1)"
echo "-- memory_chunks (memory): $(sqlite3 "$MEMORY_DB" 'SELECT COUNT(*) FROM memory_chunks' 2>&1)"
echo "== done — sindra memory + persona reset =="
+299 -14
View File
@@ -7,23 +7,35 @@ from __future__ import annotations
import argparse
import asyncio
import hashlib
import os
import signal
import sys
from dataclasses import dataclass, field
from importlib.metadata import PackageNotFoundError, version
from typing import TextIO
from typing import Any, TextIO
import httpx
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.sessions import (
AgentNotFound,
AuthoredHistoryUnavailable,
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
SessionApiFailed,
create_character,
create_session,
delete_character,
endpoint_for_plane,
get_capabilities,
get_character_state,
get_me,
get_session_messages,
list_character_models,
set_persona_state,
write_authored_history,
)
from ratatoskr.sse_client import (
AffectUpdate,
@@ -47,7 +59,7 @@ from ratatoskr.sse_client import (
TurnIdFlip,
WorkerPhase,
cancel_turn,
stream_turn,
stream_turn_resilient,
)
@@ -97,6 +109,21 @@ class ParsedArgs:
bifrost: BifrostBinding | None = None
bifrost_plane: str | None = None
consumer_key: str | None = None
# Standalone boot-time orientation probe: GET /me + GET /capabilities, print,
# exit. Mutually exclusive with the session/turn flags (opens no session).
whoami: bool = False
# Optional admin-tier key (RATATOSKR_ADMIN_API_KEY / --admin-key) for the
# admin-scoped inspection reads (BifrostState pane, GET /admin/sessions/…).
# None when unset — the BifrostState pane then shows "admin key not configured".
admin_key: str | None = None
# Tier-2 one-shot probes (like --whoami). --characters runs the transient-
# character CRUD lifecycle; --set-persona-pad "p,a,d" (with --session) writes
# a session's persona state (affect injection).
characters: bool = False
set_persona_pad: str | None = None
# #347 authored-history-write reference-consumer probe: create a fresh
# session bound to --agent, seed an authored assistant first-message (seq-0).
seed_first_message: str | None = None
class _ArgparseError(Exception):
@@ -121,6 +148,11 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
parser.add_argument("--api-key", dest="api_key")
parser.add_argument("--server")
parser.add_argument("--raw", action="store_true")
parser.add_argument("--whoami", action="store_true")
parser.add_argument("--admin-key", dest="admin_key")
parser.add_argument("--characters", action="store_true")
parser.add_argument("--set-persona-pad", dest="set_persona_pad", default=None)
parser.add_argument("--seed-first-message", dest="seed_first_message", default=None)
# 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)
# Issue #17: bind the created session to our own Bifrost provider plane.
@@ -141,17 +173,57 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
# 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:
raise UsageError("--end-user-id must be non-empty when passed")
if ns.session and ns.new:
raise UsageError("--session and --new are mutually exclusive; pass exactly one")
if not ns.session and not ns.new:
raise UsageError("pass exactly one of --session or --new")
if ns.session and ns.agent:
raise UsageError("--agent is required with --new and forbidden with --session")
if ns.new and not ns.agent and ns.send is not None:
# Issue #8: --agent stays required for --send --new (non-interactive,
# cannot prompt). Bare --new (TUI mode) accepts None — picker drives
# the choice via list_agents in _resolve_then_run.
raise UsageError("--agent is required when --new is passed in --send mode")
if sum([ns.whoami, ns.characters, bool(ns.set_persona_pad), bool(ns.seed_first_message)]) > 1:
raise UsageError(
"--whoami / --characters / --set-persona-pad / --seed-first-message "
"are mutually exclusive"
)
if ns.whoami or ns.characters:
# Standalone one-shot probes: open no session.
if ns.send is not None or ns.session or ns.new or ns.agent:
raise UsageError(
"--whoami / --characters are standalone probes "
"(no --send/--session/--new/--agent)"
)
elif ns.set_persona_pad is not None:
# Session-scoped write probe: needs a target session, nothing else.
if not ns.set_persona_pad:
raise UsageError("--set-persona-pad must be non-empty (e.g. '0.4,0.1,-0.2')")
if not ns.session:
raise UsageError("--set-persona-pad requires --session <id>")
if ns.send is not None or ns.new or ns.agent:
raise UsageError("--set-persona-pad takes only --session")
elif ns.seed_first_message is not None:
# #347 first-message probe: creates a fresh session bound to --agent,
# then seeds an authored assistant turn as seq-0 — manages its own session.
if not ns.seed_first_message:
raise UsageError("--seed-first-message must be non-empty")
if not ns.agent:
raise UsageError("--seed-first-message requires --agent <id>")
if ns.send is not None or ns.session or ns.new:
raise UsageError(
"--seed-first-message manages its own session (no --send/--session/--new)"
)
else:
if ns.session and ns.new:
raise UsageError("--session and --new are mutually exclusive")
if not ns.session and not ns.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:
raise UsageError("--agent is required with --new and forbidden with --session")
if ns.new and not ns.agent and ns.send is not None:
# Issue #8: --agent stays required for --send --new (non-interactive,
# cannot prompt). Bare --new (TUI mode) accepts None — picker drives
# the choice via list_agents in _resolve_then_run.
raise UsageError("--agent is required when --new is passed in --send mode")
api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or ""
if not api_key:
@@ -187,6 +259,9 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
bifrost = BifrostBinding(endpoint_url=endpoint_for_plane(ns.bifrost_plane, host))
bifrost_plane = ns.bifrost_plane
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(
send_content=ns.send,
@@ -200,6 +275,11 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
bifrost=bifrost,
bifrost_plane=bifrost_plane,
consumer_key=consumer_key,
whoami=ns.whoami,
admin_key=admin_key,
characters=ns.characters,
set_persona_pad=ns.set_persona_pad,
seed_first_message=ns.seed_first_message,
)
@@ -393,7 +473,7 @@ async def _run_turn(
cancelling = False
sigint_task: asyncio.Task[bool] | None = None
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:
while True:
@@ -519,6 +599,11 @@ async def _amain(args: ParsedArgs) -> int:
sys.stderr.write(
f". create_session: session_id={info.session_id} agent_id={info.agent_id}\n"
)
# #347 authored first-message: seed the agent's preset opening (best-effort).
if await seed_preset_first_message(client, info.session_id, args.agent_id):
sys.stderr.write(
f". first_message: seeded preset opening for {info.agent_id}\n"
)
# Issue #17 bound-state indicator: plane + endpoint + status, so the
# operator sees WHICH identity/endpoint bound (not a bare boolean).
if args.bifrost is not None:
@@ -550,6 +635,198 @@ async def _amain(args: ParsedArgs) -> int:
loop.remove_signal_handler(signal.SIGINT)
def _format_whoami(me: dict[str, Any], caps: dict[str, Any]) -> str:
"""Render the --whoami report: identity (GET /me) + server capabilities."""
lines = ["identity:"]
lines.append(f" user_id: {me.get('user_id', '?')}")
lines.append(f" tier: {me.get('tier', '?')}")
lines.append(f" scopes: {', '.join(me.get('scopes', [])) or '(none)'}")
for k in ("display_name", "key_id", "key_label"):
if k in me:
lines.append(f" {k}: {me[k]}")
lines.append("capabilities:")
templates = caps.get("ephemeral_templates", {})
if templates:
for name, spec in templates.items():
models = ", ".join(spec.get("allowed_models", []))
lines.append(
f" ephemeral_template {name}: default={spec.get('default_model', '?')} "
f"max_bytes={spec.get('system_prompt_max_bytes', '?')} models=[{models}]"
)
else:
lines.append(" (no ephemeral templates advertised)")
return "\n".join(lines) + "\n"
async def _whoami(args: ParsedArgs) -> int:
"""--whoami one-shot: GET /me + GET /capabilities, print a compact report, exit.
A boot-time orientation probe (mirrors --send's non-interactive shape):
"who am I against this server, and what does it offer." Opens no session.
Errors land on stderr with the same [session_api_failed] / [network_error]
vocab + exit codes as the other modes.
"""
assert isinstance(args, ParsedArgs)
async with httpx.AsyncClient(
base_url=args.server_url,
headers={"Authorization": f"Bearer {args.api_key}", "User-Agent": USER_AGENT},
timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=10.0),
) as client:
try:
me = await get_me(client)
caps = await get_capabilities(client)
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
sys.stdout.write(_format_whoami(me, caps))
return 0
def _probe_client(args: ParsedArgs) -> httpx.AsyncClient:
"""AsyncClient for the one-shot probes (--whoami / --characters / --set-persona-pad)."""
return httpx.AsyncClient(
base_url=args.server_url,
headers={"Authorization": f"Bearer {args.api_key}", "User-Agent": USER_AGENT},
timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=10.0),
)
async def _characters_probe(args: ParsedArgs) -> int:
"""--characters one-shot: exercise the transient-character CRUD lifecycle
(models → create → get-state → delete), print a report, exit. A reference-
consumer smoke of the #161 character surface (needs character.read/write)."""
assert isinstance(args, ParsedArgs)
async with _probe_client(args) as client:
try:
models = await list_character_models(client)
names = ", ".join(m.get("name", "?") for m in models.get("items", []))
sys.stdout.write(f"character models: {names or '(none)'}\n")
created = await create_character(
client,
{
"schema_version": "1",
"name": "ratatoskr-probe",
"ocean": {
"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.0,
"agreeableness": 0.5, "neuroticism": 0.5,
},
"description": "ratatoskr --characters lifecycle probe",
"narrative": "A throwaway probe character.",
"voice_profile_block": "plain",
},
)
cid = created["character_id"]
sys.stdout.write(f"created: {cid} (ttl {created.get('ttl_expires_at')})\n")
state = await get_character_state(client, cid)
sys.stdout.write(f"state: pad={state.get('pad')}\n")
await delete_character(client, cid)
sys.stdout.write(f"deleted: {cid}\n")
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
return 0
async def _set_persona_probe(args: ParsedArgs) -> int:
"""--set-persona-pad one-shot: POST a PAD to /sessions/{id}/persona_state
(affect injection), print the result, exit. Requires --session."""
assert isinstance(args, ParsedArgs)
assert args.session_id is not None and args.set_persona_pad is not None
try:
pad = [float(x) for x in args.set_persona_pad.split(",")]
except ValueError:
sys.stderr.write(
"[usage_error] --set-persona-pad must be comma-separated floats "
"(e.g. '0.4,0.1,-0.2')\n"
)
return 10
if len(pad) != 3:
sys.stderr.write(
"[usage_error] --set-persona-pad needs exactly 3 floats "
"(pleasure,arousal,dominance), e.g. '0.4,0.1,-0.2'\n"
)
return 10
# Canonical POST /sessions/{id}/persona_state body (#317): a named-key dict,
# NOT a bare list — {"pad": {"pleasure", "arousal", "dominance"}}.
snapshot = {"pad": {"pleasure": pad[0], "arousal": pad[1], "dominance": pad[2]}}
async with _probe_client(args) as client:
try:
await set_persona_state(client, args.session_id, snapshot)
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
sys.stdout.write(
f"persona_state set: session={args.session_id[-8:]} pad={pad} (204)\n"
)
return 0
async def _seed_first_message_probe(args: ParsedArgs) -> int:
"""--seed-first-message one-shot: create a fresh session bound to --agent,
write an authored assistant first-message (#347 POST /sessions/{id}/history),
read it back via GET /messages, print a report, exit. A reference-consumer
smoke of the authored-history-write primitive.
Hide-existence: a 404 (feature-absent OR the key lacks `session.history.write`)
is reported as a benign 'feature-absent' result (exit 0) — the probe NEVER
capability-probes to distinguish the causes (server INV-347-1). The probe
seeds but does not generate, so the assistant-first provider constraint is
inert here.
"""
assert isinstance(args, ParsedArgs)
assert args.agent_id is not None and args.seed_first_message is not None
async with _probe_client(args) as client:
try:
session = await create_session(
client, args.agent_id, end_user_id=args.end_user_id
)
sys.stdout.write(f"session: {session.session_id} (agent {session.agent_id})\n")
key = "ratatoskr-first-message-" + hashlib.sha256(
args.seed_first_message.encode("utf-8")
).hexdigest()[:12]
try:
ack = await write_authored_history(
client,
session.session_id,
content=args.seed_first_message,
idempotency_key=key,
)
except AuthoredHistoryUnavailable:
sys.stdout.write(
"authored-history: feature-absent or ungranted (404 hide-existence) "
"— a production consumer falls back to a model-generated greeting; "
"no capability-probe attempted.\n"
)
return 0
sys.stdout.write(
f"seeded: seq={ack.get('seq')} phase={ack.get('phase')} "
f"turn_id={ack.get('turn_id')} content_chars={ack.get('content_chars')}\n"
)
history = await get_session_messages(client, session.session_id)
items = history.get("items", [])
sys.stdout.write(f"read-back: {len(items)} message(s)\n")
for m in items:
sys.stdout.write(
f" seq={m.get('seq')} role={m.get('role')} content={m.get('content')!r}\n"
)
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
return 0
def main(argv: list[str] | None = None) -> int:
"""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)
@@ -565,6 +842,14 @@ def main(argv: list[str] | None = None) -> int:
# argparse's --help / --version short-circuit via SystemExit(0). Pass the code
# through verbatim — argparse already printed help to stdout.
return int(exc.code) if exc.code is not None else 0
if args.whoami:
return asyncio.run(_whoami(args))
if args.characters:
return asyncio.run(_characters_probe(args))
if args.set_persona_pad is not None:
return asyncio.run(_set_persona_probe(args))
if args.seed_first_message is not None:
return asyncio.run(_seed_first_message_probe(args))
if args.send_content is None:
# TUI mode — lazy import preserves INV-001 (no textual in cli at module scope).
from ratatoskr.tui import run_tui
+83
View File
@@ -0,0 +1,83 @@
"""Per-agent authored first-message presets (Worldtree #347 consumer feature).
When a new session is created for an agent that has a preset opening, seed it as
a #347 authored first-message (``POST /sessions/{id}/history``, author=assistant,
seq-0) so the session opens in-character before the user speaks the durable
replacement for a system-prompt "startup" instruction.
Best-effort by design: an instance without the ``session.history.write`` grant
returns the hide-existence 404, which is swallowed so session creation is never
blocked (the session simply opens with no seeded greeting). See
``docs/contracts/first_message.contract.md``.
"""
import asyncio
import hashlib
import httpx
from ratatoskr.sessions import write_authored_history
# Cap the best-effort seed write. The CLI/TUI create paths reuse an httpx client
# with NO read timeout (it streams SSE turns), so an accepted-but-never-answered
# POST /history would otherwise block session creation forever — violating INV-001's
# "never block". asyncio.wait_for bounds the seed regardless of the client's timeout.
_SEED_TIMEOUT_S = 10.0
# agent_id -> the authored opening seeded onto new sessions for that agent.
# Editing this dict is how an operator tunes an agent's first turn. Keep entries
# under the server's authored_content_max_bytes (8192 bytes) budget.
FIRST_MESSAGE_PRESETS: dict[str, str] = {
"ratatoskr:sindra": (
"Hey there. I'm Sindra—glad you found me. So, three things before we start:\n\n"
"How intense should I be? 1 is slow and teasing, 10 is relentless.\n\n"
"What mood am I in today? Sweetheart, Vixen, Queen, Siren, or Brat?\n\n"
"And how willing am I to begin? Enthusiastic (I want you now), Hesitant "
"(you'll need to coax me out), Resistant (playful pushback), or Unwilling "
"(I don't want this at all, until you prove otherwise)."
),
}
def preset_for(agent_id: str) -> str | None:
"""Return the authored first-message preset for ``agent_id``, or None if none."""
assert agent_id and isinstance(agent_id, str)
return FIRST_MESSAGE_PRESETS.get(agent_id)
async def seed_preset_first_message(
client: httpx.AsyncClient, session_id: str, agent_id: str
) -> str | None:
"""Best-effort: seed ``agent_id``'s preset opening as a #347 authored
first-message on ``session_id``; return the seeded text, or None.
Best-effort (INV-001): a no-preset agent, a malformed call, a slow write
(bounded by ``_SEED_TIMEOUT_S``), the hide-existence 404, or ANY other
exception all resolve to None WITHOUT raising this MUST NOT block or fail
session creation. Only ``asyncio.CancelledError`` propagates (cancellation is
not a seed failure). Inputs are soft-guarded (return None), never asserted, so
a wiring bug can't crash the create path this is wired into. A no-preset agent
issues zero HTTP (INV-002). The per-content idempotency key makes a repeat on
the same session an idempotent 200 replay (INV-003).
"""
# Soft input guards — a bad arg degrades to "no first message", never raises.
if not (isinstance(agent_id, str) and agent_id):
return None
content = FIRST_MESSAGE_PRESETS.get(agent_id)
if content is None:
return None
if client is None or not (isinstance(session_id, str) and session_id):
return None
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
try:
await asyncio.wait_for(
write_authored_history(
client, session_id, content=content, idempotency_key=key
),
timeout=_SEED_TIMEOUT_S,
)
except asyncio.CancelledError:
raise # cancellation is not a seed failure — never swallow it
except Exception:
return None # any other failure (404/409/422/timeout/unexpected) → no greeting
return content
+258 -10
View File
@@ -177,6 +177,26 @@ class AuthScopeDenied(Exception):
self.scope = scope
class AuthoredHistoryUnavailable(Exception):
"""Raised on HTTP 404 from POST /sessions/{id}/history (#347 authored-history-write).
Hide-existence (server INV-347-1): an ungranted caller, a non-owner, and an
unknown session ALL receive a 404 byte-identical to a genuine
`session_not_found` the feature's existence is never revealed by status,
body, or error_code. The consumer MUST treat this as feature-absent and fall
back (a production consumer to a model-generated greeting), and MUST NOT
capability-probe to distinguish the causes. Distinct from `SessionApiFailed`
so callers branch feature-absent without inspecting a status code.
"""
def __init__(self, *, session_id: str) -> None:
super().__init__(
f"authored-history write unavailable for session {session_id!r} "
"(404 hide-existence: feature-absent / ungranted / session-absent)"
)
self.session_id = session_id
async def list_sessions(
client: httpx.AsyncClient,
*,
@@ -227,19 +247,21 @@ async def list_sessions(
def endpoint_for_plane(plane: str, base_host: str) -> str:
"""Map a provider plane name to its Worldtree-VISIBLE base URL.
Issue #17 dev helper: `memory` → :8391, `affect` → :8390. Returns the
Worldtree-visible base (e.g. `http://10.100.10.50:8391`), NOT the client's
loopback Worldtree must reach the provider over the network. `http://` is
deliberate: the HTTPS relaxation is allowlist-side (Worldtree's
BIFROST_CLIENT_ALLOWED_HOSTS), not a URL concern. A production HTTPS endpoint
is supplied directly, bypassing this helper.
Issue #17 dev helper: `memory` → :8391, `affect` → :8390, `combined` → :8392
(the #18 composite both-plane endpoint). Returns the Worldtree-visible base
(e.g. `http://10.100.10.50:8391`), NOT the client's loopback — Worldtree must
reach the provider over the network. `http://` is deliberate: the HTTPS
relaxation is allowlist-side (Worldtree's BIFROST_CLIENT_ALLOWED_HOSTS), not a
URL concern. A production HTTPS endpoint is supplied directly, bypassing this
helper.
"""
if plane not in ("memory", "affect"):
ports = {"memory": 8391, "affect": 8390, "combined": 8392}
if plane not in ports:
raise ValueError(
f"unknown plane: {plane!r} (expected 'memory' or 'affect')"
f"unknown plane: {plane!r} "
"(expected 'memory', 'affect', or 'combined')"
)
port = 8391 if plane == "memory" else 8390
return f"http://{base_host}:{port}"
return f"http://{base_host}:{ports[plane]}"
def _bifrost_error_from(resp: httpx.Response) -> str | None:
@@ -404,3 +426,229 @@ async def get_persona_state(
if resp.status_code == 403 and error_code == "auth_scope_denied":
raise AuthScopeDenied(scope="persona.read")
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_me(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /me — the authenticated principal's identity + key metadata (spec §GET /me).
Boot-time whoami: verify the key without agent-config side effects. Returns
the parsed dict verbatim (freeform per the frozen OpenAPI; the spec documents
`{user_id, scopes, tier, display_name?, key_id?, key_label?, ...}`, optional
fields omitted-not-null). 401 (bad/absent key when auth is enabled) like
every other non-200 surfaces as SessionApiFailed (get_persona_state
precedent). Read-only, rate-exempt, no audit emission.
"""
assert client is not None
resp = await client.get("/me")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def list_character_models(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /models/available-for-characters — character-capable model profiles (#161).
Requires `character.read`. Returns `{items: [{name, description, thinking}]}`.
Parsed dict verbatim; any non-200 SessionApiFailed.
"""
assert client is not None
resp = await client.get("/models/available-for-characters")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def create_character(
client: httpx.AsyncClient, character: dict[str, Any], *, state: dict[str, Any] | None = None
) -> dict[str, Any]:
"""POST /characters — create a transient character (#161). Requires `character.write`.
Body is `{character, state}` (state optional a CharacterStateSchema for
mid-conversation rehydration). Returns 201 `{character_id, ttl_expires_at}`;
any non-201 SessionApiFailed.
"""
assert client is not None
assert isinstance(character, dict) and character
resp = await client.post("/characters", json={"character": character, "state": state})
if resp.status_code == 201:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_character_state(client: httpx.AsyncClient, character_id: str) -> dict[str, Any]:
"""GET /characters/{character_id}/state — live runtime state (#161). Requires `character.read`.
Returns `{schema_version, pad, emotions_active, mood_drift, goal_signal_history}`;
refreshes the character's TTL. Any non-200 → SessionApiFailed.
"""
assert client is not None
assert character_id and isinstance(character_id, str)
resp = await client.get(f"/characters/{character_id}/state")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def delete_character(client: httpx.AsyncClient, character_id: str) -> None:
"""DELETE /characters/{character_id} — remove a transient character (#161).
Requires `character.write`. Bound sessions detach (next turn 410
character_not_found). 200/204 None; any other status SessionApiFailed.
"""
assert client is not None
assert character_id and isinstance(character_id, str)
resp = await client.delete(f"/characters/{character_id}")
if resp.status_code in (200, 204):
return None
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def set_persona_state(
client: httpx.AsyncClient, session_id: str, snapshot: dict[str, Any]
) -> None:
"""POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection).
The request body is FREEFORM on the wire (the OpenAPI declares no request
schema), but worldtree-dev's prose now pins the canonical shape (#317):
`{"pad": {"pleasure": p, "arousal": a, "dominance": d}}` a named-key dict
(each in [-1, 1]), NOT a bare list; PAD-only, session-scoped, pull-over-push
(#289). The caller supplies the snapshot. 204 No Content → None; any other
status SessionApiFailed.
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert isinstance(snapshot, dict)
resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot)
if resp.status_code == 204:
return None
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_session_bifrost(
client: httpx.AsyncClient, session_id: str, *, admin_key: str
) -> dict[str, Any]:
"""GET /admin/sessions/{session_id}/bifrost — admin-scoped Bifrost dispatch state (#176).
Returns the live Bifrost binding for a session: `{endpoint_url, consumer_id,
connected, capabilities_granted, tools: [{name, description}]}`. Requires the
`admin.sessions.read` scope (admin tier), so the request OVERRIDES the
Authorization header with `admin_key` (distinct from the client's default
consumer key). Read-only (audited server-side). Parsed dict verbatim; any
non-200 SessionApiFailed notably 403 `auth_scope_denied` (key lacks the
scope) and 404 `session_not_bifrost_bound` (session exists, no live client).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert admin_key and isinstance(admin_key, str)
resp = await client.get(
f"/admin/sessions/{session_id}/bifrost",
headers={"Authorization": f"Bearer {admin_key}"},
)
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_session_tools(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]:
"""GET /sessions/{session_id}/tools — owner-scoped tool inventory (spec #183).
Returns the merged tool list the LLM saw at turn-fire: `{agent_id,
builtin_tools: [...], bifrost_tools: [{name, description, parameters}, ...]}`.
Owner-scoped (`ctx.user_id == session.user_id`) reachable with the consumer
key, NO admin scope. Cross-owner access returns 404 `session_not_found`
(existence-hiding); a revoked session returns 401 `auth_revoked`. Parsed dict
verbatim; any non-200 SessionApiFailed (mirrors get_persona_state).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
resp = await client.get(f"/sessions/{session_id}/tools")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /capabilities — server capability discovery (spec §Ephemeral Templates).
Returns `{ephemeral_templates: {echo: {allowed_models, default_model,
system_prompt_max_bytes}}}` what the server offers before a client decides
to instantiate. Any authenticated caller may read it (no scope). Parsed dict
verbatim; any non-200 SessionApiFailed.
"""
assert client is not None
resp = await client.get("/capabilities")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def write_authored_history(
client: httpx.AsyncClient,
session_id: str,
*,
content: str,
idempotency_key: str,
author: str = "assistant",
effects: str | None = None,
claimed_original_at: str | None = None,
) -> dict[str, Any]:
"""POST /sessions/{session_id}/history — the #347 authored-history-write primitive.
Write one model-visible turn into the session's ledger AS the bound agent,
WITHOUT a generation and WITHOUT lived-turn side effects (the SillyTavern
"first message"). v1: `author="assistant"`, `effects` omitted (== "none"),
`idempotency_key` REQUIRED (per-session dedup). The server pins the body
(`AuthoredWriteRequest`, `extra="forbid"`), so `effects` /
`claimed_original_at` are sent only when non-None never as null keys.
Success is 201 (fresh) or 200 (idempotent replay, byte-identical body); both
return the `AuthoredTurnResponse` dict verbatim (`{author, content_chars,
injected_at, phase, seq, session_id, turn_id}` provenance is audit-only,
never on this body).
404 `AuthoredHistoryUnavailable` (hide-existence: feature-absent /
ungranted / session-absent are indistinguishable by design; the caller falls
back and NEVER capability-probes server INV-347-1). Any other non-2xx
`SessionApiFailed` (notably 409 `generation_active`, 422 `content_too_long` /
`validation_failed`).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert content and isinstance(content, str)
assert idempotency_key and isinstance(idempotency_key, str)
assert author and isinstance(author, str)
body: dict[str, Any] = {
"author": author,
"content": content,
"idempotency_key": idempotency_key,
}
if effects is not None:
body["effects"] = effects
if claimed_original_at is not None:
body["claimed_original_at"] = claimed_original_at
resp = await client.post(f"/sessions/{session_id}/history", json=body)
if resp.status_code in (200, 201):
return resp.json()
if resp.status_code == 404:
raise AuthoredHistoryUnavailable(session_id=session_id)
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_session_messages(
client: httpx.AsyncClient, session_id: str
) -> dict[str, Any]:
"""GET /sessions/{session_id}/messages — the session's message history.
Un-deferred as the #347 seed read-back: a seeded turn renders as a normal
`role=assistant` message (model-invisible provenance indistinguishable
from a lived turn on read). Returns `{session_id, items: [{seq, role,
content, ...}], next_cursor}` verbatim; owner-scoped; any non-200
`SessionApiFailed`. v1 reads the server default page (no pagination params
add limit/cursor when a caller needs scrollback).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
resp = await client.get(f"/sessions/{session_id}/messages")
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):
"""Raised when an SSE event's `id:` wire field is missing or non-composite."""
@@ -215,6 +232,59 @@ class SseConnectFailed(Exception):
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):
"""Raised when the HTTP/SSE connection dropped mid-stream."""
@@ -434,6 +504,19 @@ async def stream_turn(
f"/sessions/{session_id}/messages",
json={"content": content},
) 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:
event_source.response.raise_for_status()
except httpx.HTTPStatusError as exc:
@@ -483,6 +566,100 @@ async def reconnect_turn(
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:
"""Parse the SSE wire `id:` as composite `{turn_id}:{seq}`. See contract FN _parse_sse_id."""
assert isinstance(raw, str)
+361 -10
View File
@@ -33,6 +33,7 @@ from textual.widgets import (
)
from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.sessions import (
AgentInfo,
AgentNotAvailable,
@@ -42,11 +43,16 @@ from ratatoskr.sessions import (
BifrostHandshakeFailed,
PersonaNotConfigured,
SessionApiFailed,
SessionInfo,
create_session,
get_persona_state,
get_session_bifrost,
get_session_tools,
list_agents,
list_sessions,
)
from ratatoskr.sse_client import (
AdminEvent,
AffectUpdate,
AwaitingLlmFirstToken,
CancelAlreadyCompleted,
@@ -68,7 +74,8 @@ from ratatoskr.sse_client import (
TurnIdFlip,
WorkerPhase,
cancel_turn,
stream_turn,
stream_admin_events,
stream_turn_resilient,
)
# ---- Australis theme (https://github.com/lkraven/australis) ------------------
@@ -194,6 +201,46 @@ def _ts() -> str:
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:
"""One-line persona summary for the sticky header widget.
@@ -368,7 +415,7 @@ class TuiPresenterState:
self,
event: Event,
*,
transcript: "VerticalScroll",
transcript: VerticalScroll,
tools_log: RichLog,
debug_log: RichLog,
thinking_log: RichLog,
@@ -798,6 +845,128 @@ class AgentPickerApp(App[str | 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]):
"""Textual TUI shell — single chat pane."""
@@ -898,7 +1067,7 @@ class RatatoskrApp(App[int]):
/* v0.8.1: #current-text Static removed. Streaming text now coalesces
on `\n` and writes directly to #transcript (same pattern as v0.7.1
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;
padding: 0 1;
}
@@ -1063,6 +1232,24 @@ class RatatoskrApp(App[int]):
id="persona-log", wrap=True, markup=False,
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).
# pane-name widget displays current side-pane name.
yield Static("", id="identity")
@@ -1116,6 +1303,17 @@ class RatatoskrApp(App[int]):
# surface (PersonaNotConfigured) get a placeholder + empty header.
if self.agent_id is not None:
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:
"""Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state.
@@ -1127,7 +1325,6 @@ class RatatoskrApp(App[int]):
On 200: header populated, pane shows full detail, audit logged.
"""
assert self.client is not None and self.agent_id is not None
from rich.text import Text as RichText
try:
snapshot = await get_persona_state(self.client, self.agent_id)
self._update_persona_surfaces(snapshot)
@@ -1151,6 +1348,128 @@ class RatatoskrApp(App[int]):
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:
"""Update sticky header + Persona pane from a fresh snapshot.
@@ -1318,7 +1637,7 @@ class RatatoskrApp(App[int]):
pass
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:
self.active_turn_id = event.sse_id.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)
assert isinstance(args, ParsedArgs) and args.send_content is None
# PRE-002: Exactly one of session_id / new must be set (xor)
assert bool(args.session_id) != bool(args.new)
# PRE-002 (slice b2): --session and --new are mutually exclusive, but NEITHER
# 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))
@@ -1467,6 +1787,35 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
# agent_id (remote wins on conflict, since a server-listed agent
# is the authoritative source).
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:
try:
agents = await list_agents(client)
@@ -1550,9 +1899,11 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
)
session_id = info.session_id
agent_id: str | None = info.agent_id
# #347 authored first-message: seed the agent's preset opening (best-effort).
await seed_preset_first_message(client, session_id, chosen_agent_id)
else:
assert args.session_id is not None
session_id = args.session_id
assert resolved_session_id is not None
session_id = resolved_session_id
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)
exit_code = await app.run_async()
@@ -1565,7 +1916,7 @@ async def _cancel_via_sse(
turn_id: int,
*,
transcript: VerticalScroll,
audit: "Callable[[str], None] | None" = None,
audit: Callable[[str], None] | None = None,
) -> None:
"""Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009).
+5
View File
@@ -68,6 +68,10 @@ def main(argv: list[str] | None = None) -> int:
affect_read_url = os.environ.get(
"RATATOSKR_AFFECT_READ_URL", "http://127.0.0.1:8390"
)
# Admin observability panes (BifrostState + AdminEvents): the readonly-admin
# key stays SERVER-SIDE — the server proxies admin-scoped reads; the browser
# never receives the key, only the session-filtered result.
admin_key = os.environ.get("RATATOSKR_ADMIN_API_KEY")
# INV-001: lazy import. Users without [web] extras get a clean hint
# instead of a raw ImportError. Scoped narrowly to the OPTIONAL
@@ -108,6 +112,7 @@ def main(argv: list[str] | None = None) -> int:
bifrost_consumer_key=bifrost_consumer_key,
bifrost_visible_host=bifrost_visible_host,
affect_read_url=affect_read_url,
admin_key=admin_key,
)
# Boot banner to stderr (so stdout stays clean for piping).
+142 -5
View File
@@ -18,11 +18,17 @@ from importlib.metadata import version as _pkg_version
import httpx
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import FileResponse, JSONResponse, StreamingResponse
from starlette.responses import (
FileResponse,
JSONResponse,
Response,
StreamingResponse,
)
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from ratatoskr import local_agents as _local_agents
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.sessions import (
AgentNotAvailable,
AgentNotFound,
@@ -35,12 +41,16 @@ from ratatoskr.sessions import (
create_session,
endpoint_for_plane,
get_persona_state,
get_session_bifrost,
get_session_messages,
get_session_tools,
list_agents,
)
from ratatoskr.sse_client import (
AdminEvent,
CancelAlreadyCompleted,
Cancelled,
CancelFailed,
Cancelled,
CancelTurnNotFound,
Done,
Error,
@@ -50,7 +60,8 @@ from ratatoskr.sse_client import (
SseConnectionDropped,
TurnIdFlip,
cancel_turn,
stream_turn,
stream_admin_events,
stream_turn_resilient,
)
@@ -138,7 +149,7 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
consumer_key = request.app.state.bifrost_consumer_key
visible_host = request.app.state.bifrost_visible_host
if bifrost_plane:
if bifrost_plane not in ("memory", "affect"):
if bifrost_plane not in ("memory", "affect", "combined"):
return JSONResponse(
{"error_code": "invalid_bifrost_plane"}, status_code=400
)
@@ -159,6 +170,9 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
bifrost=bifrost,
consumer_key=consumer_key if bifrost else None,
)
# #347 authored first-message: seed the agent's preset opening
# (best-effort; never blocks create — see first_message INV-001).
await seed_preset_first_message(client, info.session_id, agent_id)
except AgentNotFound:
return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
except BifrostConsumerKeyMissing:
@@ -291,7 +305,7 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
try:
handle.status = "streaming"
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)
# turn_id from the first event so cancel paths target
# the real upstream turn, not our local counter.
@@ -416,6 +430,120 @@ async def _affect_state_endpoint(request: Request) -> JSONResponse:
return JSONResponse(r.json(), status_code=r.status_code)
async def _session_tools_endpoint(request: Request) -> JSONResponse:
"""GET /api/sessions/{session_id}/tools → owner-scoped tool inventory (spec #183).
Proxies get_session_tools with the client's CONSUMER bearer (no admin scope):
the merged {agent_id, builtin_tools, bifrost_tools} the LLM saw at turn-fire.
Any non-200 upstream surfaced as a status-preserving error envelope."""
session_id = request.path_params["session_id"]
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
info = await get_session_tools(client, session_id)
except SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_tools_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(info, status_code=200)
async def _session_messages_endpoint(request: Request) -> JSONResponse:
"""GET /api/sessions/{session_id}/messages → the session's message history.
Proxies get_session_messages so the SPA can render a session's EXISTING turns
on open notably a #347 authored first-message seeded at create-time (which
lives in the ledger, not the live turn stream). Any non-200 upstream a
status-preserving error envelope."""
session_id = request.path_params["session_id"]
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
data = await get_session_messages(client, session_id)
except SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_messages_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(data, status_code=200)
async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
"""GET /api/sessions/{session_id}/bifrost → admin-scoped Bifrost dispatch state (#176).
The admin key is SERVER-HELD (app.state.admin_key) and never reaches the
browser (INV-003 precedent upstream credentials stay server-side); the
wrapper overrides the Authorization header with it. Fail-visible when the
admin key isn't configured (never a silent empty pane)."""
session_id = request.path_params["session_id"]
admin_key = request.app.state.admin_key
if not admin_key: # PRE-001: fail-visible, never silent
return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
bstate = await get_session_bifrost(client, session_id, admin_key=admin_key)
except SessionApiFailed as exc:
return JSONResponse(
{"error_code": "bifrost_state_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(bstate, status_code=200)
def _admin_event_matches_web(ev: AdminEvent, session_id: str | None) -> bool:
"""AdminEvents filter (design-brief §6, mirrors the TUI): forward non-heartbeat
system.* (stream-integrity signals) + events for the active session; drop the
rest so the browser sees only session-relevant lifecycle, never the full
cross-session admin firehose."""
if ev.type == "system.heartbeat":
return False
if ev.type.startswith("system."):
return True
return session_id is not None and ev.data.get("session_id") == session_id
async def _admin_events_endpoint(request: Request) -> Response:
"""GET /api/admin/events?session_id=... → SSE proxy of GET /admin/events (#11).
The admin key is SERVER-HELD; the browser only ever receives the session-filtered
stream (never the key, never the cross-session firehose). Long-lived + best-effort:
a connect failure or mid-stream drop emits a labeled `stream_error` event and ends."""
admin_key = request.app.state.admin_key
if not admin_key: # PRE-001: fail-visible, never silent
return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
session_id = request.query_params.get("session_id")
client_factory = request.app.state.client_factory
async def gen() -> AsyncIterator[bytes]:
client = client_factory()
try:
async for ev in stream_admin_events(client, admin_key=admin_key):
if not _admin_event_matches_web(ev, session_id):
continue
# Fixed SSE event name so the browser renders EVERY admin type
# with one listener (no per-type enumeration → nothing silently
# dropped); the real dotted type rides in the payload.
yield _format_sse(
"admin_event",
{"id": ev.id, "type": ev.type, "timestamp": ev.timestamp,
"data": ev.data},
)
except (SseConnectFailed, SseConnectionDropped, MalformedSseId,
MalformedSseData) as exc:
yield _format_sse(
"stream_error",
{"exception": type(exc).__name__, "message": str(exc)},
)
except asyncio.CancelledError:
raise # browser disconnect — let the generator unwind
finally:
await client.aclose()
return StreamingResponse(gen(), media_type="text/event-stream")
def create_app(
client_factory: Callable[[], httpx.AsyncClient],
*,
@@ -423,6 +551,7 @@ def create_app(
bifrost_consumer_key: str | None = None,
bifrost_visible_host: str | None = None,
affect_read_url: str | None = None,
admin_key: str | None = None,
) -> Starlette:
"""Construct the Starlette app — wire routes + state per FN create_app.
@@ -483,6 +612,10 @@ def create_app(
Route("/api/sessions", _create_session_endpoint, methods=["POST"]),
Route("/api/agents/{agent_id}/persona_state", _persona_state_endpoint),
Route("/api/affect/{agent_id}", _affect_state_endpoint),
Route("/api/sessions/{session_id}/tools", _session_tools_endpoint),
Route("/api/sessions/{session_id}/messages", _session_messages_endpoint),
Route("/api/sessions/{session_id}/bifrost", _session_bifrost_endpoint),
Route("/api/admin/events", _admin_events_endpoint),
Route("/api/turns/{session_id}", _submit_turn_endpoint, methods=["POST"]),
Route("/api/turns/{session_id}/stream", _stream_turn_endpoint),
Route("/api/turns/{session_id}/cancel", _cancel_turn_endpoint, methods=["POST"]),
@@ -498,6 +631,10 @@ def create_app(
# Issue #18 (Deliverable 2): the provider affect-read base URL (server→provider hop,
# same dev box) — distinct from the WT-visible host used for binding.
app.state.affect_read_url = affect_read_url
# Admin observability panes (BifrostState + AdminEvents): the admin key is
# SERVER-HELD (RATATOSKR_ADMIN_API_KEY) and never reaches the browser — the
# server proxies admin-scoped reads and forwards only the session-filtered result.
app.state.admin_key = admin_key
# INV-002: turn registry is in-process memory, keyed (session_id, turn_id)
app.state.turn_registry = {}
return app
+401 -24
View File
@@ -231,6 +231,26 @@ body {
50% { content: "··"; } 75% { content: "···"; }
}
/* reasoning indicator — a UI affordance in the transcript, visually distinct
from the agent's response (.response, left-bordered). Italic + a ✦ glyph so
it reads as "the app telling you inference is happening", never as engine
output. Ephemeral: appears on reasoning tokens, gone the moment real text
begins or the turn ends. */
.thinking-note {
display: inline-flex; align-items: center; gap: 8px;
color: var(--blue); font-style: italic; font-size: 12px;
margin: 6px 0; padding-left: 18px; opacity: 0.9;
animation: rise 0.3s ease both;
}
.thinking-note::before {
content: "✦"; font-style: normal; color: var(--cyan);
text-shadow: 0 0 10px var(--glow-cyan);
}
.thinking-note::after {
content: ""; width: 16px; text-align: left;
animation: dots 1.4s steps(4, end) infinite;
}
/* terminal status chips */
.chip {
display: inline-flex; align-items: center; gap: 7px;
@@ -341,7 +361,35 @@ body {
/* persona pane structured render */
#pane-persona .pk { color: var(--fg-dim); }
#pane-persona .pv { color: var(--blue); }
#pane-persona .ph { color: var(--cyan); letter-spacing: 0.1em; text-transform: uppercase; font-size: 10px; }
#pane-persona .ph { color: var(--cyan); letter-spacing: 0.1em; text-transform: uppercase; font-size: 10px; margin-top: 4px; }
/* affect metric rows: label · value · Δ · sparkline · n · descriptor */
#pane-persona .mono-note { color: var(--fg-faint); font-size: 10px; margin: 2px 0 8px; }
#pane-persona .mrow {
display: flex; gap: 9px; align-items: baseline; padding: 1px 0;
font-size: 12px; white-space: nowrap;
}
#pane-persona .mrow .mk { color: var(--fg-dim); min-width: 118px; }
#pane-persona .mrow .mv { color: var(--blue); min-width: 46px; text-align: right; font-variant-numeric: tabular-nums; }
#pane-persona .mrow .md { min-width: 58px; font-size: 11px; color: var(--fg-faint); }
#pane-persona .mrow .md.up { color: var(--green); }
#pane-persona .mrow .md.dn { color: var(--red); }
#pane-persona .mrow .msp { color: var(--cyan); letter-spacing: 1px; min-width: 28px; }
#pane-persona .mrow .mn { color: var(--fg-faint); font-size: 10px; min-width: 34px; }
#pane-persona .mrow .mdesc { color: var(--fg-faint); font-style: italic; font-size: 10px; }
/* canonical NL (the literal text Worldtree injects into the agent's context) */
#pane-persona .nl-canon {
color: var(--blue); font-size: 11px; line-height: 1.5;
margin: 3px 0 8px; padding-left: 10px; border-left: 2px solid var(--line-2);
white-space: normal; word-break: break-word;
}
#pane-persona .nl-canon b { color: var(--cyan); font-weight: 600; }
#pane-persona .nl-directive { color: var(--fg-2); font-style: italic; }
#pane-persona .nl-canon .dh {
font-style: normal; color: var(--fg-faint); font-size: 10px;
letter-spacing: 0.06em; text-transform: uppercase; display: block; margin-bottom: 2px;
}
/* thinking-pane per-turn dividers */
.pane-turn {
@@ -487,6 +535,8 @@ body {
<button class="tab" data-pane="debug">debug <span class="kbd">⌃2</span><span class="badge">0</span></button>
<button class="tab" data-pane="thinking">think <span class="kbd">⌃3</span><span class="badge">0</span></button>
<button class="tab" data-pane="persona">persona <span class="kbd">⌃4</span></button>
<button class="tab" data-pane="bifrost">bifrost <span class="kbd">⌃5</span></button>
<button class="tab" data-pane="admin">admin <span class="kbd">⌃6</span><span class="badge">0</span></button>
</nav>
<div class="pane-head">
<span id="pane-name">tools</span>
@@ -497,6 +547,8 @@ body {
<div class="pane" id="pane-debug"><div class="empty">waiting for wire telemetry…</div></div>
<div class="pane" id="pane-thinking"><div class="empty">no chain-of-thought captured yet</div></div>
<div class="pane" id="pane-persona"><div class="empty">persona state loads on session open</div></div>
<div class="pane" id="pane-bifrost"><div class="empty">bifrost dispatch state loads on session open</div></div>
<div class="pane" id="pane-admin"><div class="empty">admin lifecycle events stream on session open</div></div>
</div>
</section>
</main>
@@ -513,6 +565,7 @@ body {
<label class="field-label" for="bifrost-plane">Bifrost binding (Tier-3 provider)</label>
<div class="select-wrap">
<select id="bifrost-plane">
<option value="combined" selected>combined (:8392) — PAD + memory in one session</option>
<option value="">none — observe only</option>
<option value="memory">memory (:8391) — durable recall</option>
<option value="affect">affect (:8390) — persona / PAD</option>
@@ -541,7 +594,7 @@ body {
"use strict";
const $ = (id) => document.getElementById(id);
const state = { sessionId: null, agentId: null, turnId: null, eventSource: null };
const state = { sessionId: null, agentId: null, turnId: null, eventSource: null, lastAffectAt: null, adminES: null };
function esc(s) {
const d = document.createElement("div");
@@ -602,8 +655,46 @@ function markdownSafe(raw) {
// per-turn live buffers (reset at turn open)
const LIVE = { resp: "", think: "" };
// ---- reasoning indicator (a UI affordance — NOT engine output) ----------
// When the model streams reasoning/chain-of-thought, show an ephemeral
// "<Agent> is pondering…" line in the transcript so the user knows inference
// is happening. Rotates phrasing for liveliness; removed the instant real text
// begins or the turn ends. agentDisplayName is rendered via textContent (never
// innerHTML) so an adversarial agent_id can't inject markup (INV-004).
const THINK_PHRASES = ["is thinking", "is pondering", "appears thoughtful",
"is reasoning", "is turning it over"];
let thinkRotator = null;
function agentDisplayName() {
if (!state.agentId) return "the agent";
const tail = String(state.agentId).split(":").pop() || "the agent";
return tail.charAt(0).toUpperCase() + tail.slice(1);
}
function showThinkingNote() {
// reasoning tokens ARE the first tokens — supersede the awaiting-first heartbeat
const aw = document.querySelector("#transcript .awaiting.live");
if (aw) aw.remove();
let el = document.querySelector("#transcript .thinking-note");
if (!el) {
el = document.createElement("div");
el.className = "thinking-note";
el.appendChild(document.createTextNode(""));
$("transcript").appendChild(el);
let i = 0;
const paint = () => { el.firstChild.textContent =
`${agentDisplayName()} ${THINK_PHRASES[i % THINK_PHRASES.length]}`; i++; };
paint();
thinkRotator = setInterval(paint, 2600);
}
$("transcript").scrollTop = $("transcript").scrollHeight;
}
function hideThinkingNote() {
if (thinkRotator) { clearInterval(thinkRotator); thinkRotator = null; }
const el = document.querySelector("#transcript .thinking-note");
if (el) el.remove();
}
// ---- pane helpers ----
const PANE_BADGE = { tools: 0, debug: 0, thinking: 0 };
const PANE_BADGE = { tools: 0, debug: 0, thinking: 0, admin: 0 };
function bumpBadge(pane) {
if (!(pane in PANE_BADGE)) return;
PANE_BADGE[pane] += 1;
@@ -744,25 +835,169 @@ async function loadPersona(agentId) {
// shape only — pad + per-entity valence + emitted_at; NO fabricated Tier-1 persona
// fields (dominant_emotion / mood_drift), which Tier-3 structurally lacks (INV-001).
// Labelled "affect", not "persona" (INV-005).
// ---- affect trend accumulation (client-side, session-lived) ----
// The affect pane refreshes on session-open + after each turn (the post-turn PAD poll
// fires ~4x/turn — deduped here by emitted_at so a turn contributes ONE sample). Each
// tracked value keeps a rolling, capped history so the pane can show a Δ + sparkline.
const AFFECT_HIST = { at: [], pad: {}, rel: {} }; // pad[axis]=[]; rel[target][metric]=[]
const HIST_CAP = 24;
const _relVal = (x) => (x && typeof x === "object" && "value" in x) ? x.value : x;
const _relN = (x) => (x && typeof x === "object" && "evidence_count" in x) ? x.evidence_count : undefined;
function pushAffectHistory(snap) {
const at = snap.emitted_at || "";
if (at && AFFECT_HIST.at[AFFECT_HIST.at.length - 1] === at) return; // same snapshot — skip
AFFECT_HIST.at.push(at);
if (AFFECT_HIST.at.length > HIST_CAP) AFFECT_HIST.at.shift();
const push = (bucket, key, v) => {
if (typeof v !== "number") return;
(bucket[key] = bucket[key] || []).push(v);
if (bucket[key].length > HIST_CAP) bucket[key].shift();
};
const pad = snap.pad || {};
for (const ax of ["pleasure", "arousal", "dominance"]) push(AFFECT_HIST.pad, ax, pad[ax]);
for (const rel of (snap.relations || snap.valence || [])) {
const tgt = rel.target_entity || rel.entity_id || "?";
const b = (AFFECT_HIST.rel[tgt] = AFFECT_HIST.rel[tgt] || {});
push(b, "trust_ability", _relVal(rel.trust_ability));
push(b, "trust_benevolence", _relVal(rel.trust_benevolence));
push(b, "trust_integrity", _relVal(rel.trust_integrity));
push(b, "warmth", _relVal(rel.warmth ?? rel.familiarity));
}
}
// unicode sparkline auto-scaled to the value's own observed range; flat when stable
// (don't amplify sub-0.01 noise into a fake trend).
const _SPARK = "▁▂▃▄▅▆▇█";
function sparkline(vals) {
if (!vals || vals.length < 2) return (vals && vals.length) ? "·" : "";
const lo = Math.min(...vals), hi = Math.max(...vals);
if (hi - lo < 0.01) return "".repeat(vals.length);
const span = hi - lo;
return vals.map((v) => _SPARK[Math.min(7, Math.floor(((v - lo) / span) * 7.999))]).join("");
}
function trendDelta(vals) {
if (!vals || vals.length < 2) return "";
const d = vals[vals.length - 1] - vals[vals.length - 2];
if (Math.abs(d) < 0.0005) return "";
return (d > 0 ? "▲+" : "▼") + d.toFixed(3);
}
// ---- canonical affect→NL (vendored from Worldtree's d2 render canons) ----
// Deterministic, NO LLM — mirrors Worldtree core/persona describe_pad +
// render_d2_canonical BYTE-EXACT (verified). Shows the LITERAL mood word +
// relationship directive Worldtree injects into the agent's own context, so the
// pane reads exactly what the agent was told about its state. Canon loaded from
// /static/persona_render_canon.json (regen: scripts/build_persona_canon.py).
let PERSONA_CANON = null;
const _relConf = (x) => (x && typeof x === "object" && "confidence" in x) ? x.confidence : 0;
async function loadPersonaCanon() {
try { PERSONA_CANON = await (await fetch("/static/persona_render_canon.json")).json(); }
catch (_) { /* canon absent → the canonical lines simply omit (fail-open) */ }
}
function _parseInterval(expr) {
expr = expr.trim();
if (expr.startsWith("<=")) return [null, false, parseFloat(expr.slice(2)), true];
if (expr.startsWith("<")) return [null, false, parseFloat(expr.slice(1)), false];
if (expr.startsWith(">=")) return [parseFloat(expr.slice(2)), true, null, false];
if (expr.startsWith(">")) return [parseFloat(expr.slice(1)), false, null, false];
const loI = expr[0] === "[", hiI = expr[expr.length - 1] === "]";
const [a, b] = expr.slice(1, -1).split(",").map((s) => parseFloat(s.trim()));
return [a, loI, b, hiI];
}
function _bandLabel(cuts, v) {
if (typeof v !== "number") return null;
for (const [expr, label] of cuts) {
const [lo, loI, hi, hiI] = _parseInterval(expr);
let ok = true;
if (lo !== null) ok = ok && (loI ? v >= lo : v > lo);
if (hi !== null) ok = ok && (hiI ? v <= hi : v < hi);
if (ok) return label;
}
return null;
}
function canonMood(pad) { // mirror describe_pad(p,a,d): valence×arousal grid + ±0.3 bands
if (!PERSONA_CANON || !pad) return null;
const p = pad.pleasure, a = pad.arousal, d = pad.dominance;
if ([p, a, d].some((x) => typeof x !== "number")) return null;
const g = PERSONA_CANON.mood_grid;
const valence = p > 0.3 ? "positive" : p < -0.3 ? "negative" : "neutral";
const band = a > 0.3 ? "high_a" : a < -0.3 ? "low_a" : "mid_a";
const control = d > 0.3 ? "confident" : d < -0.3 ? "uncertain" : null;
return control ? `${g[valence][band]}, ${control}` : g[valence][band];
}
function canonDirective(rel) { // mirror render_d2_canonical(edge, canon) byte-exact
if (!PERSONA_CANON) return null;
const R = PERSONA_CANON.relation;
const wb = _bandLabel(R.warmth_cuts, _relVal(rel.warmth));
const ab = _bandLabel(R.agency_cuts, _relVal(rel.agency));
const ta = _bandLabel(R.trust_cuts, _relVal(rel.trust_ability));
const ti = _bandLabel(R.trust_cuts, _relVal(rel.trust_integrity));
const tb = _bandLabel(R.trust_cuts, _relVal(rel.trust_benevolence));
if ([wb, ab, ta, ti, tb].some((x) => x == null)) return null;
const cl = _relConf(rel.warmth) >= R.high_conf_floor ? "high" : "low";
const tbeh = [ta, ti, tb].includes("limited") ? R.tbeh.low_trust
: R.cold_warmth_bands.includes(wb) ? R.tbeh.cold_warmth : R.tbeh.default;
return R.prefix + R.warmth_phrase[wb]
+ `; agency is ${R.agency_phrase[ab]}; ability trust is ${ta}; integrity trust is ${ti}; `
+ `intention trust is ${tb}; this stance rests on ${R.history[cl]}. In behavior, `
+ `${R.warmth_beh[wb]}; ${R.agency_beh[ab]}; ${tbeh}; avoid premature we-framing.`;
}
// Issue #18 D2 (relation_edge/1 rework): Worldtree's affect snapshot now carries
// `relations[]` (target + trust_ability/benevolence/integrity + warmth + agency +
// relation_context, each {value,confidence,evidence_count}) — NOT the old flat
// `valence[]`. Render mood (PAD) + the durable per-entity relational model, each with
// a Δ + sparkline from AFFECT_HIST. Falls back to `valence` for an older emitter.
// INV-001: no fabricated Tier-1 fields. INV-004: every dynamic value escaped (head()
// escapes its whole argument; metric() escapes each cell).
function renderAffectPane(snap) {
const row = (k, v) => `<div><span class="pk">${esc(k)}</span> <span class="pv">${esc(v)}</span></div>`;
const head = (t) => `<div class="ph">${esc(t)}</div>`;
const all = snap.valence || [];
const shown = all.slice(0, 8); // bounded render — valence[] is unbounded in principle
const valRows = shown.map((v) =>
row(v.entity_id || "?",
`familiarity ${JSON.stringify(v.familiarity)} · regard ${JSON.stringify(v.regard)}`
+ ` · n=${JSON.stringify(v.interaction_count)}`)
).join("");
$("pane-persona").innerHTML =
head("affect snapshot · " + (snap.agent_id || "?")) +
`<div> </div>` + head("pad") +
row("pleasure", JSON.stringify(snap.pad?.pleasure)) +
row("arousal", JSON.stringify(snap.pad?.arousal)) +
row("dominance", JSON.stringify(snap.pad?.dominance)) +
`<div> </div>` + head("valence (" + all.length + ")") +
(valRows || `<div class="empty">none</div>`) +
`<div> </div>` + row("emitted_at", snap.emitted_at || "?");
const num = (v) => (typeof v === "number") ? ((v >= 0 ? "+" : "") + v.toFixed(3)) : "—";
const metric = (label, val, hist, desc, n) => {
const d = trendDelta(hist);
const dcls = d.startsWith("▲") ? "up" : d.startsWith("▼") ? "dn" : "";
const nlab = (n !== undefined && n !== null) ? "n=" + esc(n) : "";
return `<div class="mrow"><span class="mk">${esc(label)}</span>`
+ `<span class="mv">${esc(num(val))}</span>`
+ `<span class="md ${dcls}">${esc(d)}</span>`
+ `<span class="msp">${esc(sparkline(hist))}</span>`
+ `<span class="mn">${nlab}</span>`
+ `<span class="mdesc">${esc(desc)}</span></div>`;
};
const pad = snap.pad || {}, H = AFFECT_HIST;
let html = head("affect · " + (snap.agent_id || "?"))
+ `<div class="mono-note">emitted ${esc((snap.emitted_at || "?").slice(11, 19))} · `
+ `${H.at.length} sample${H.at.length === 1 ? "" : "s"} this session</div>`
+ head("mood · PAD (transient, 1‥+1)")
+ (canonMood(pad) ? `<div class="nl-canon">Worldtree tells the agent it feels: <b>${esc(canonMood(pad))}</b></div>` : "")
+ metric("pleasure", pad.pleasure, H.pad.pleasure, "feeling")
+ metric("arousal", pad.arousal, H.pad.arousal, "activation")
+ metric("dominance", pad.dominance, H.pad.dominance, "control");
const rels = snap.relations || snap.valence || [];
if (!rels.length) {
html += head("relations") + `<div class="empty">no relations tracked yet — take a turn</div>`;
}
for (const rel of rels.slice(0, 8)) {
const tgt = rel.target_entity || rel.entity_id || "?";
const ctx = rel.relation_context ? " · stage: " + rel.relation_context : "";
const b = H.rel[tgt] || {};
html += head("relation → " + tgt + ctx)
+ metric("trust·ability", _relVal(rel.trust_ability), b.trust_ability, "is-competent", _relN(rel.trust_ability))
+ metric("trust·benevolence", _relVal(rel.trust_benevolence), b.trust_benevolence, "means-well", _relN(rel.trust_benevolence))
+ metric("trust·integrity", _relVal(rel.trust_integrity), b.trust_integrity, "is-honest", _relN(rel.trust_integrity))
+ metric("warmth", _relVal(rel.warmth ?? rel.familiarity), b.warmth, "affection", _relN(rel.warmth));
const ag = _relVal(rel.agency), agn = _relN(rel.agency);
if (typeof ag === "number" && agn) html += metric("agency", ag, null, "autonomy", agn);
if (rel.obligation_balance !== null && rel.obligation_balance !== undefined) {
html += `<div class="mrow"><span class="mk">obligation</span>`
+ `<span class="mv">${esc(JSON.stringify(rel.obligation_balance))}</span></div>`;
}
const dir = canonDirective(rel);
if (dir) {
html += `<div class="nl-canon nl-directive"><span class="dh">context directive `
+ `(what the agent is told about ${esc(tgt)}):</span> ${esc(dir)}</div>`;
}
}
$("pane-persona").innerHTML = html;
}
async function loadAffect(agentId) {
@@ -770,8 +1005,10 @@ async function loadAffect(agentId) {
const r = await fetch("/api/affect/" + encodeURIComponent(agentId));
if (r.status === 200) {
const snap = await r.json();
pushAffectHistory(snap); // accumulate the per-value trend BEFORE rendering
renderAffectPane(snap);
setPersonaStrip(snap); // pad bars are the live signal
state.lastAffectAt = snap.emitted_at || state.lastAffectAt; // post-turn poll stop-signal
} else {
let code = "";
try { code = (await r.json()).error_code || ""; } catch (_) {}
@@ -793,6 +1030,100 @@ async function loadAffect(agentId) {
}
}
// ---- tools inventory (#183): what the LLM HAS at turn-fire (static), rendered
// at the TOP of the tools pane; live tool_start/result events append below it. ---
function renderToolsInventory(inv) {
const row = (k, v) => `<div><span class="pk">${esc(k)}</span> <span class="pv">${esc(v)}</span></div>`;
const head = (t) => `<div class="ph">${esc(t)}</div>`;
const names = (arr) => (arr || []).map((t) => (typeof t === "string" ? t : (t && t.name) || "?"));
const builtin = inv.builtin_tools || [], bifrost = inv.bifrost_tools || [];
const html =
head("tool inventory · " + (inv.agent_id || "?")) +
row("builtin (" + builtin.length + ")", names(builtin).join(", ") || "none") +
row("bifrost (" + bifrost.length + ")", names(bifrost).join(", ") || "none") +
`<div class="rule">— live tool events —</div>`;
const pane = $("pane-tools");
const empty = pane.querySelector(".empty");
if (empty) empty.remove();
let block = pane.querySelector(".tools-inventory");
if (!block) {
block = document.createElement("div");
block.className = "tools-inventory";
pane.insertBefore(block, pane.firstChild);
}
block.innerHTML = html;
}
async function loadSessionTools(sessionId) {
try {
const r = await fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/tools");
if (r.status === 200) renderToolsInventory(await r.json());
// non-200 → best-effort hydrate; leave the live tool pane as-is (mirrors TUI)
} catch (_) {}
}
// ---- Bifrost dispatch state (#176): admin-scoped, server-proxied (admin key
// stays server-side; the browser only receives the state). ----
function renderBifrostState(b) {
const row = (k, v) => `<div><span class="pk">${esc(k)}</span> <span class="pv">${esc(v)}</span></div>`;
const head = (t) => `<div class="ph">${esc(t)}</div>`;
const tools = b.tools || [];
$("pane-bifrost").innerHTML =
head("bifrost dispatch state") +
row("endpoint", b.endpoint_url || "?") +
row("consumer", b.consumer_id || "?") +
row("connected", JSON.stringify(b.connected)) +
row("caps", (b.capabilities_granted || []).join(", ") || "none") +
`<div> </div>` + head("tools (" + tools.length + ")") +
(tools.map((t) => row("·", (t.name || "?") + (t.description ? " — " + t.description : ""))).join("")
|| `<div class="empty">none</div>`);
}
async function loadBifrostState(sessionId) {
try {
const r = await fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/bifrost");
if (r.status === 200) { renderBifrostState(await r.json()); return; }
let code = ""; try { code = (await r.json()).error_code || ""; } catch (_) {}
let msg;
if (code === "admin_key_not_configured") msg = "bifrost state needs the readonly-admin key (RATATOSKR_ADMIN_API_KEY) server-side.";
else if (r.status === 404) msg = "session is not Bifrost-bound (no live dispatch client).";
else if (r.status === 403) msg = "admin key lacks the admin.sessions.read scope.";
else msg = `bifrost state unavailable (HTTP ${esc(r.status)}${code ? " · " + esc(code) : ""}).`;
$("pane-bifrost").innerHTML = `<div class="empty">${msg}</div>`;
} catch (_) {
$("pane-bifrost").innerHTML = `<div class="empty">bifrost state fetch failed</div>`;
}
}
// ---- Admin lifecycle events (#11): admin-scoped SSE, session-filtered SERVER-side.
// One fixed "admin_event" listener renders every type; the dotted type is in data. ---
function openAdminEvents(sessionId) {
if (state.adminES) { state.adminES.close(); state.adminES = null; }
const es = new EventSource("/api/admin/events?session_id=" + encodeURIComponent(sessionId));
state.adminES = es;
es.addEventListener("admin_event", (e) => {
let d; try { d = JSON.parse(e.data); } catch (_) { return; }
appendTo("pane-admin",
`<div>[${ts()}] <span style="color:var(--blue)">${esc(d.type || "event")}</span> `
+ `${esc(JSON.stringify(d.data || {}))}</div>`);
});
es.addEventListener("stream_error", (e) => {
let d = {}; try { d = JSON.parse(e.data); } catch (_) {}
appendTo("pane-admin", `<div class="rule">— admin stream ended: ${esc(d.exception || "error")} —</div>`);
// Server signalled the stream is over — close so native EventSource does NOT auto-reconnect
// into a retry loop (INV-LIFECYCLE).
es.close(); state.adminES = null;
});
es.onerror = () => {
// EventSource auto-reconnects on a transient drop (readyState CONNECTING) — leave that be,
// the admin stream is long-lived. Only tear down on a PERMANENT failure (CLOSED — e.g. a
// 400/403 where no reconnect is coming) so we don't leak a dead handle.
if (es.readyState === EventSource.CLOSED) { state.adminES = null; }
const pane = $("pane-admin");
if (pane.querySelector(".empty")) {
pane.innerHTML = `<div class="empty">admin stream unavailable — needs the readonly-admin key + admin.events.read scope.</div>`;
}
};
}
// ---- session lifecycle ----
async function startSession() {
const agentId = $("agent-picker").value;
@@ -835,7 +1166,13 @@ async function startSession() {
setConn("idle", "connected");
$("setup").style.display = "none";
$("workspace").classList.add("live");
await loadTranscript(state.sessionId);
await loadPersona(agentId);
// Admin/debug surfaces — best-effort hydrate + live stream (all self-render on
// failure; the admin key is server-held, never sent from here).
loadSessionTools(state.sessionId);
loadBifrostState(state.sessionId);
openAdminEvents(state.sessionId);
$("prompt-input").focus();
} catch (e) {
$("setup-err").textContent = "network error opening session";
@@ -861,6 +1198,32 @@ function finalizeResponse() {
const live = document.querySelector("#transcript .response.live");
if (live) live.classList.remove("live");
}
// Render a session's EXISTING ledger on open — notably a #347 authored
// first-message seeded at create-time (it lives in history, not the live turn
// stream, so without this the transcript is blank until the user speaks).
// Best-effort: a failed/empty fetch just leaves the transcript empty. A seed
// renders byte-identical to a lived assistant turn (model-invisible provenance).
async function loadTranscript(sessionId) {
try {
const r = await fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/messages");
if (r.status !== 200) return;
const data = await r.json();
for (const m of (data && data.items) || []) {
if (m.role === "assistant") {
const b = document.createElement("div");
b.className = "response md-body";
b.innerHTML = markdownSafe(m.content || "");
$("transcript").appendChild(b);
} else if (m.role === "user") {
const e = document.createElement("div");
e.className = "prompt-echo";
e.textContent = m.content || "";
$("transcript").appendChild(e);
}
}
$("transcript").scrollTop = $("transcript").scrollHeight;
} catch (_) { /* best-effort — a blank transcript is acceptable */ }
}
// Thinking: same live-Markdown treatment into the current turn's block.
function appendThinking(text) {
LIVE.think += text;
@@ -942,11 +1305,13 @@ async function submitPrompt() {
es.addEventListener("thinking", (e) => {
const d = JSON.parse(e.data);
thinkingDeltas += 1;
showThinkingNote(); // ephemeral "<Agent> is pondering…" in the transcript
appendThinking(d.content);
});
es.addEventListener("text", (e) => {
const d = JSON.parse(e.data);
textDeltas += 1;
hideThinkingNote(); // real text begins — reasoning display is done
appendResponse(d.content);
});
es.addEventListener("text_boundary", (e) => {
@@ -989,6 +1354,7 @@ async function submitPrompt() {
function terminal(label, cls, e) {
const aw = document.querySelector("#transcript .awaiting.live");
if (aw) aw.remove();
hideThinkingNote();
finalizeResponse();
document.querySelectorAll("#pane-thinking .think-live").forEach((b) => b.classList.remove("think-live"));
let meta = "";
@@ -1005,9 +1371,18 @@ async function submitPrompt() {
$("composer").classList.remove("streaming");
setConn(cls === "error" ? "error" : "idle", cls === "error" ? "error" : "connected");
if (cls === "done" && state.agentId) {
// Tier-3 affect.emit is POST-TURN ASYNC — it lands in our store a couple seconds
// after [done]. Refresh the pane on a short delay to catch the new PAD (issue #18).
setTimeout(() => loadPersona(state.agentId), 2000);
// Tier-3 affect.emit is POST-TURN ASYNC and can land well after [done] — a single
// fixed refresh races it (issue #18 foot-gun). Poll a short window, stopping once
// the snapshot's emitted_at advances past the pre-turn value (or a new turn starts).
const beforeAt = state.lastAffectAt;
let settled = false;
for (const delay of [1500, 3500, 6500, 10500]) {
setTimeout(async () => {
if (settled || state.turnId) return;
await loadPersona(state.agentId);
if (state.lastAffectAt && state.lastAffectAt !== beforeAt) settled = true;
}, delay);
}
}
$("prompt-input").focus();
}
@@ -1016,6 +1391,7 @@ async function submitPrompt() {
es.addEventListener("cancelled", (e) => terminal("cancelled", "cancelled", e));
es.onerror = () => {
audit("sse_connection_dropped turn_id=" + turn_id);
hideThinkingNote(); // a raw drop mid-reasoning must not leave the note + rotator running
es.close();
state.eventSource = null; state.turnId = null;
$("composer").classList.remove("streaming");
@@ -1052,7 +1428,7 @@ document.querySelectorAll(".tab").forEach((t) =>
// ---- keyboard ----
document.addEventListener("keydown", (e) => {
if (e.ctrlKey && ["1", "2", "3", "4"].includes(e.key)) {
if (e.ctrlKey && ["1", "2", "3", "4", "5", "6"].includes(e.key)) {
const tabs = document.querySelectorAll(".tab");
const idx = parseInt(e.key, 10) - 1;
if (tabs[idx]) { activateTab(tabs[idx]); e.preventDefault(); }
@@ -1081,6 +1457,7 @@ $("prompt-input").addEventListener("keydown", (e) => {
loadAgents();
loadVersion();
loadPersonaCanon();
</script>
</body>
</html>
@@ -0,0 +1,178 @@
{
"_source": "vendored from Worldtree core/persona/canon/{d2-mood-render-canon-v1,d2-render-canon-v1}.json",
"_generated_by": "scripts/build_persona_canon.py (regen when .corviduo-canonicals.toml flags canon drift)",
"_render_path": "pure deterministic \u2014 no LLM; mirrors Worldtree describe_pad + render_d2_canonical byte-exact",
"mood_grid": {
"positive": {
"high_a": "positive and energized",
"mid_a": "positive",
"low_a": "positive and calm"
},
"neutral": {
"high_a": "alert",
"mid_a": "neutral",
"low_a": "quiet"
},
"negative": {
"high_a": "negative and agitated",
"mid_a": "negative",
"low_a": "negative and subdued"
}
},
"relation": {
"trust_cuts": [
[
"< 0.4",
"limited"
],
[
"[0.4, 0.6)",
"developing"
],
[
"[0.6, 0.8)",
"steady"
],
[
">= 0.8",
"strong"
]
],
"warmth_cuts": [
[
"<= -0.8",
"hostile"
],
[
"(-0.8, -0.6]",
"cold"
],
[
"(-0.6, -0.4]",
"distant"
],
[
"(-0.4, -0.2)",
"guarded"
],
[
"[-0.2, 0.2)",
"neutral"
],
[
"[0.2, 0.4)",
"reserved"
],
[
"[0.4, 0.6)",
"measured"
],
[
"[0.6, 0.8)",
"clear"
],
[
">= 0.8",
"deep"
]
],
"agency_cuts": [
[
"<= -0.8",
"submissive"
],
[
"(-0.8, -0.6]",
"deferential"
],
[
"(-0.6, -0.4]",
"yielding"
],
[
"(-0.4, -0.2)",
"modest"
],
[
"[-0.2, 0.2)",
"neutral"
],
[
"[0.2, 0.4)",
"light"
],
[
"[0.4, 0.6)",
"balanced"
],
[
"[0.6, 0.8)",
"substantial"
],
[
">= 0.8",
"commanding"
]
],
"warmth_phrase": {
"hostile": "strongly hostile regard",
"cold": "clearly cold regard",
"distant": "distant negative regard",
"guarded": "slightly guarded regard",
"neutral": "neutral warmth",
"reserved": "slightly reserved warmth",
"measured": "moderate measured warmth",
"clear": "clear warm regard",
"deep": "deep warm bond"
},
"warmth_beh": {
"hostile": "keep a firm emotional boundary",
"cold": "keep a firm emotional boundary",
"distant": "keep guarded distance",
"guarded": "keep guarded distance",
"neutral": "keep the tone even",
"reserved": "keep cordial distance",
"measured": "keep cordial distance",
"clear": "speak with direct warmth",
"deep": "speak with direct warmth"
},
"agency_phrase": {
"submissive": "strongly submissive standing",
"deferential": "clearly deferential standing",
"yielding": "yielding standing",
"modest": "slightly modest standing",
"neutral": "neutral standing",
"light": "lightly self-assertive standing",
"balanced": "self-assured standing",
"substantial": "strongly assertive standing",
"commanding": "commanding standing"
},
"agency_beh": {
"submissive": "avoid over-yielding while preserving basic respect",
"deferential": "avoid over-yielding while preserving basic respect",
"yielding": "keep self-advocacy light and deferential",
"modest": "keep self-advocacy light and deferential",
"neutral": "avoid unnecessary deference",
"light": "avoid unnecessary deference",
"balanced": "balance deference with independent judgment",
"substantial": "treat their position as weighty without yielding judgment",
"commanding": "treat their position as weighty without yielding judgment"
},
"history": {
"low": "a broad pattern of prior exchanges",
"high": "a broad pattern of prior exchanges"
},
"prefix": "Use this graded relationship state: toward target, warmth is ",
"tbeh": {
"low_trust": "verify important claims before relying on them",
"cold_warmth": "protect boundaries while staying useful",
"default": "work from ordinary good faith"
},
"cold_warmth_bands": [
"distant",
"cold",
"hostile"
],
"high_conf_floor": 0.55
}
}
+287 -6
View File
@@ -175,10 +175,29 @@ class TestParseArgs:
)
def test_usage_neither_session_nor_new(self) -> None:
"""usage_neither_session_nor_new: neither flag → UsageError('pass exactly one')."""
with pytest.raises(UsageError, match="pass exactly one"):
"""usage_neither_session_nor_new: --send with neither flag → UsageError.
--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"])
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:
"""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"])
assert rc == 0
def test_usage_error_no_send(
def test_empty_argv_fails_on_auth(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> 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] = []
async def fake_amain(args: ParsedArgs) -> int:
@@ -1331,8 +1355,8 @@ class TestMain:
monkeypatch.setattr(cli_mod, "_amain", fake_amain)
rc = main([])
assert rc == 10
assert "[usage_error]" in capsys.readouterr().err
assert rc == 11
assert "[auth_error]" in capsys.readouterr().err
assert amain_calls == []
def test_usage_error_both_session_and_new(
@@ -1540,3 +1564,260 @@ class TestBifrostBindCli:
)
rc = await _amain(args)
assert rc == 22
class TestWhoami:
"""--whoami one-shot probe (slice: capabilities+me): GET /me + GET /capabilities."""
def test_whoami_standalone_accepted(self) -> None:
"""whoami_standalone_accepted: --whoami alone → valid; whoami=True, no turn flags."""
args = _parse_args(["--whoami", "--api-key", "k"])
assert args.whoami is True
assert args.send_content is None
assert args.session_id is None
assert args.new is False
def test_whoami_with_send_rejected(self) -> None:
"""whoami_with_send_rejected [adversarial]: --whoami + --send → UsageError."""
with pytest.raises(UsageError, match="standalone probe"):
_parse_args(["--whoami", "--send", "hi", "--api-key", "k"])
def test_whoami_with_new_rejected(self) -> None:
"""whoami_with_new_rejected [adversarial]: --whoami + --new → UsageError."""
with pytest.raises(UsageError, match="standalone probe"):
_parse_args(["--whoami", "--new", "--agent", "m", "--api-key", "k"])
@respx.mock
def test_whoami_mode_prints_report(self, capsys: pytest.CaptureFixture[str]) -> None:
"""whoami_mode_prints_report [happy,tracer]: /me + /capabilities → stdout report; exit 0."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(
200,
json={
"user_id": "alice",
"scopes": ["conversations.read", "conversations.write"],
"tier": "user",
"key_id": "a1b2c3d4",
},
)
)
respx.get("https://w.example/capabilities").mock(
return_value=httpx.Response(
200,
json={
"ephemeral_templates": {
"echo": {
"allowed_models": ["glm5-turbo"],
"default_model": "glm5-turbo",
"system_prompt_max_bytes": 32768,
}
}
},
)
)
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
assert rc == 0
out = capsys.readouterr().out
assert "user_id: alice" in out
assert "tier: user" in out
assert "key_id: a1b2c3d4" in out
assert "ephemeral_template echo" in out
assert "glm5-turbo" in out
@respx.mock
def test_whoami_me_auth_failure_exits_20(self, capsys: pytest.CaptureFixture[str]) -> None:
"""whoami_me_auth_failure [error]: /me 401 → exit 20 [session_api_failed]."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(401, json={"detail": "auth_invalid"})
)
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
assert rc == 20
assert "[session_api_failed]" in capsys.readouterr().err
class TestTier2Probes:
"""--characters + --set-persona-pad one-shot probes (Tier-2: #161 + persona_state-write)."""
def test_characters_standalone_accepted(self) -> None:
"""characters_standalone: --characters alone → valid."""
args = _parse_args(["--characters", "--api-key", "k"])
assert args.characters is True
assert args.session_id is None
def test_set_persona_requires_session(self) -> None:
"""set_persona_requires_session [adversarial]: --set-persona-pad needs --session."""
with pytest.raises(UsageError, match="requires --session"):
_parse_args(["--set-persona-pad", "0.4,0.1,-0.2", "--api-key", "k"])
def test_probes_mutually_exclusive(self) -> None:
"""probes_mutually_exclusive [adversarial]: --whoami + --characters → UsageError."""
with pytest.raises(UsageError, match="mutually exclusive"):
_parse_args(["--whoami", "--characters", "--api-key", "k"])
@respx.mock
def test_characters_probe_lifecycle(self, capsys: pytest.CaptureFixture[str]) -> None:
"""characters_probe [happy,tracer]: models → create → state → delete; report to stdout."""
respx.get("https://w.example/models/available-for-characters").mock(
return_value=httpx.Response(200, json={"items": [{"name": "fast"}]})
)
respx.post("https://w.example/characters").mock(
return_value=httpx.Response(201, json={"character_id": "char_z", "ttl_expires_at": "t"})
)
respx.get("https://w.example/characters/char_z/state").mock(
return_value=httpx.Response(200, json={"schema_version": "1", "pad": [0.1, 0.2, 0.3]})
)
del_route = respx.delete("https://w.example/characters/char_z").mock(
return_value=httpx.Response(204)
)
rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"])
assert rc == 0
out = capsys.readouterr().out
assert "character models: fast" in out
assert "created: char_z" in out
assert "pad=[0.1, 0.2, 0.3]" in out
assert "deleted: char_z" in out
assert del_route.call_count == 1 # lifecycle cleaned up
@respx.mock
def test_set_persona_probe(self, capsys: pytest.CaptureFixture[str]) -> None:
"""set_persona_probe [happy,tracer]: POST pad to /sessions/{id}/persona_state; 204."""
import json as _json
route = respx.post("https://w.example/sessions/s1/persona_state").mock(
return_value=httpx.Response(204)
)
rc = main(
["--set-persona-pad", "0.4,0.1,-0.2", "--session", "s1",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 0
assert "persona_state set" in capsys.readouterr().out
# canonical POST /sessions/{id}/persona_state body: named-key dict, NOT a list
assert _json.loads(route.calls[0].request.content) == {
"pad": {"pleasure": 0.4, "arousal": 0.1, "dominance": -0.2}
}
def test_set_persona_wrong_count(self) -> None:
"""set_persona_wrong_count [adversarial]: not exactly 3 floats → exit 10, no HTTP."""
rc = main(
["--set-persona-pad", "0.4,0.1", "--session", "s1",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 10
class TestSeedFirstMessageProbe:
"""--seed-first-message one-shot (#347 authored-history-write reference-consumer probe)."""
def test_seed_requires_agent(self) -> None:
"""seed_requires_agent [adversarial]: --seed-first-message needs --agent."""
with pytest.raises(UsageError, match="requires --agent"):
_parse_args(["--seed-first-message", "hello", "--api-key", "k"])
def test_seed_forbids_session(self) -> None:
"""seed_forbids_session [adversarial]: manages its own session — no --session."""
with pytest.raises(UsageError, match="manages its own session"):
_parse_args(
["--seed-first-message", "hi", "--agent", "m", "--session", "s1", "--api-key", "k"]
)
def test_seed_mutually_exclusive(self) -> None:
"""seed_mutually_exclusive [adversarial]: --seed-first-message + --whoami → UsageError."""
with pytest.raises(UsageError, match="mutually exclusive"):
_parse_args(["--seed-first-message", "hi", "--whoami", "--api-key", "k"])
def test_seed_empty_rejected(self) -> None:
"""seed_empty_rejected [adversarial]: empty content → UsageError."""
with pytest.raises(UsageError, match="non-empty"):
_parse_args(["--seed-first-message", "", "--agent", "m", "--api-key", "k"])
def test_seed_accepted(self) -> None:
"""seed_accepted [happy]: --seed-first-message + --agent → parses."""
args = _parse_args(["--seed-first-message", "hi", "--agent", "mimir", "--api-key", "k"])
assert args.seed_first_message == "hi"
assert args.agent_id == "mimir"
assert args.session_id is None and args.new is False
@respx.mock
def test_seed_probe_happy(self, capsys: pytest.CaptureFixture[str]) -> None:
"""seed_probe [happy,tracer]: create session → seed → read-back; report to stdout."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s1",
"agent_id": "mimir",
"message_count": 0,
"created_at": "2026-07-06T12:00:00+00:00",
"last_active": "2026-07-06T12:00:00+00:00",
"metadata": {},
},
)
)
hist_route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(
201,
json={
"author": "assistant",
"content_chars": 5,
"injected_at": "2026-07-06T12:00:01+00:00",
"phase": "seeded",
"seq": 0,
"session_id": "s1",
"turn_id": "t1",
},
)
)
respx.get("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(
200,
json={
"session_id": "s1",
"items": [{"seq": 0, "role": "assistant", "content": "hello"}],
"next_cursor": None,
},
)
)
rc = main(
["--seed-first-message", "hello", "--agent", "mimir",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 0
out = capsys.readouterr().out
assert "session: s1" in out
assert "seeded: seq=0 phase=seeded" in out
assert "read-back: 1 message" in out
assert "role=assistant" in out
assert hist_route.call_count == 1
@respx.mock
def test_seed_probe_feature_absent(self, capsys: pytest.CaptureFixture[str]) -> None:
"""feature_absent [error-path]: 404 hide-existence → benign report, exit 0, no read-back."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s1",
"agent_id": "mimir",
"message_count": 0,
"created_at": "2026-07-06T12:00:00+00:00",
"last_active": "2026-07-06T12:00:00+00:00",
"metadata": {},
},
)
)
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
msgs_route = respx.get("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(
200, json={"session_id": "s1", "items": [], "next_cursor": None}
)
)
rc = main(
["--seed-first-message", "hello", "--agent", "mimir",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 0
assert "feature-absent" in capsys.readouterr().out
assert msgs_route.call_count == 0 # never capability-probes past the 404
+155
View File
@@ -0,0 +1,155 @@
"""Tests for ratatoskr.first_message per docs/contracts/first_message.contract.md."""
import asyncio
import hashlib
import json
import httpx
import pytest
import respx
from ratatoskr.first_message import (
FIRST_MESSAGE_PRESETS,
preset_for,
seed_preset_first_message,
)
class TestPresetFor:
"""first_message contract — preset_for (dict lookup)."""
def test_preset_hit(self) -> None:
"""preset_hit [happy,tracer]: sindra has a non-empty str preset."""
val = preset_for("ratatoskr:sindra")
assert isinstance(val, str) and val
def test_preset_miss(self) -> None:
"""preset_miss [happy]: an agent with no preset → None."""
assert preset_for("mimir") is None
def test_empty_agent_id(self) -> None:
"""empty_agent_id [adversarial]: "" → AssertionError."""
with pytest.raises(AssertionError):
preset_for("")
class TestSeedPresetFirstMessage:
"""first_message contract — seed_preset_first_message (best-effort #347 seed)."""
@respx.mock
async def test_seeds_preset(self) -> None:
"""seeds_preset [happy,tracer]: preset agent → one history POST, correct body."""
content = FIRST_MESSAGE_PRESETS["ratatoskr:sindra"]
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(
201,
json={
"author": "assistant",
"seq": 0,
"phase": "seeded",
"turn_id": "t1",
"session_id": "s1",
"content_chars": len(content),
"injected_at": "2026-07-06T00:00:00+00:00",
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
assert result == content
assert route.call_count == 1 # POST-002: exactly one history POST
assert json.loads(route.calls[0].request.content) == {
"author": "assistant",
"content": content,
"idempotency_key": key,
}
@respx.mock
async def test_no_preset_zero_http(self) -> None:
"""no_preset_zero_http [happy]: no-preset agent → None, ZERO HTTP (INV-002)."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "mimir")
assert result is None
assert not route.called
@respx.mock
async def test_feature_absent_swallowed(self) -> None:
"""feature_absent_swallowed [error]: 404 hide-existence → None, no raise (INV-001)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
assert result is None
@respx.mock
async def test_session_api_failed_swallowed(self) -> None:
"""session_api_failed_swallowed [error]: 409 → None, no raise (INV-001)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(409, json={"error_code": "generation_active"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
assert result is None
@respx.mock
async def test_transport_error_swallowed(self) -> None:
"""transport_error_swallowed [error]: httpx.ConnectError → None, no raise (INV-001)."""
respx.post("https://w.example/sessions/s1/history").mock(
side_effect=httpx.ConnectError("boom")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
assert result is None
@respx.mock
async def test_unexpected_exception_swallowed(self) -> None:
"""unexpected_exception [error]: write raises ValueError → None (broad never-raise)."""
respx.post("https://w.example/sessions/s1/history").mock(
side_effect=ValueError("unexpected")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
assert result is None
async def test_cancellation_propagates(self) -> None:
"""cancellation_propagates [error]: CancelledError from the write is RE-RAISED."""
import ratatoskr.first_message as fm
async def _cancel(*_a: object, **_k: object) -> None:
raise asyncio.CancelledError
orig = fm.write_authored_history
fm.write_authored_history = _cancel # type: ignore[assignment]
try:
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(asyncio.CancelledError):
await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
finally:
fm.write_authored_history = orig # type: ignore[assignment]
@respx.mock
async def test_malformed_agent_id_no_http(self) -> None:
"""malformed_agent_id [adversarial]: non-str or empty agent_id → None; no HTTP; no raise."""
route = respx.post(url__regex=r".*/history$").mock(
return_value=httpx.Response(201, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
assert await seed_preset_first_message(client, "s1", 123) is None # type: ignore[arg-type]
assert await seed_preset_first_message(client, "s1", "") is None
assert not route.called
@respx.mock
async def test_empty_session_id(self) -> None:
"""empty_session_id [adversarial]: "" → None (soft guard); no HTTP; no raise."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "", "ratatoskr:sindra")
assert result is None
assert not route.called
+489
View File
@@ -8,6 +8,7 @@ from ratatoskr.sessions import (
AgentInfo,
AgentNotAvailable,
AgentNotFound,
AuthoredHistoryUnavailable,
AuthScopeDenied,
BifrostBinding,
BifrostConsumerKeyMissing,
@@ -16,11 +17,22 @@ from ratatoskr.sessions import (
PersonaNotConfigured,
SessionApiFailed,
SessionPage,
create_character,
create_session,
delete_character,
endpoint_for_plane,
get_capabilities,
get_character_state,
get_me,
get_persona_state,
get_session_bifrost,
get_session_messages,
get_session_tools,
list_agents,
list_character_models,
list_sessions,
set_persona_state,
write_authored_history,
)
@@ -384,6 +396,13 @@ class TestEndpointForPlane:
== "http://10.100.10.50:8390"
)
def test_combined_plane_maps_to_8392(self) -> None:
"""combined [#18 composite]: 'combined' → http://<host>:8392 (POST-001)."""
assert (
endpoint_for_plane("combined", "10.100.10.50")
== "http://10.100.10.50:8392"
)
def test_unknown_plane_raises_value_error(self) -> None:
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
with pytest.raises(ValueError):
@@ -889,3 +908,473 @@ class TestGetPersonaState:
with pytest.raises(PersonaNotConfigured) as exc_info:
await get_persona_state(client, "domari")
assert exc_info.value.agent_id == "domari"
class TestGetMe:
"""docs/contracts/issues/2.contract.md FN get_me (slice: capabilities+me)."""
@respx.mock
async def test_happy_authenticated(self) -> None:
"""happy_authenticated [happy,tracer]: 200 → parsed identity dict verbatim."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(
200,
json={
"user_id": "alice",
"scopes": ["conversations.read", "conversations.write"],
"tier": "user",
"key_id": "a1b2c3d4",
"key_label": "alice phone",
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
me = await get_me(client)
assert me["user_id"] == "alice"
assert me["tier"] == "user"
assert me["key_id"] == "a1b2c3d4"
assert me["scopes"] == ["conversations.read", "conversations.write"]
@respx.mock
async def test_anonymous_dev_mode(self) -> None:
"""anonymous_dev_mode: 200 anonymous shape → dict with tier=anonymous."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(
200,
json={
"user_id": "anonymous",
"scopes": ["conversations.read"],
"tier": "anonymous",
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
me = await get_me(client)
assert me["tier"] == "anonymous"
assert "key_id" not in me # optional fields omitted, not null
@respx.mock
async def test_401_raises_session_api_failed(self) -> None:
"""401_raises [error]: bad/absent key → SessionApiFailed(status=401)."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(401, json={"detail": "auth_invalid"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_me(client)
assert exc.value.status == 401
class TestGetCapabilities:
"""docs/contracts/issues/2.contract.md FN get_capabilities (slice: capabilities+me)."""
@respx.mock
async def test_happy(self) -> None:
"""happy [happy]: 200 → ephemeral_templates dict verbatim."""
respx.get("https://w.example/capabilities").mock(
return_value=httpx.Response(
200,
json={
"ephemeral_templates": {
"echo": {
"allowed_models": ["glm5-turbo", "glm4.7"],
"default_model": "glm5-turbo",
"system_prompt_max_bytes": 32768,
}
}
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
caps = await get_capabilities(client)
echo = caps["ephemeral_templates"]["echo"]
assert echo["default_model"] == "glm5-turbo"
assert echo["system_prompt_max_bytes"] == 32768
@respx.mock
async def test_non_200_raises(self) -> None:
"""non_200_raises [error]: 500 → SessionApiFailed(status=500)."""
respx.get("https://w.example/capabilities").mock(
return_value=httpx.Response(500, content=b"boom")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_capabilities(client)
assert exc.value.status == 500
class TestGetSessionTools:
"""docs/contracts/issues/2.contract.md — get_session_tools (GET /sessions/{id}/tools, #183)."""
@respx.mock
async def test_happy(self) -> None:
"""happy [happy,tracer]: 200 → merged tool inventory dict verbatim."""
respx.get("https://w.example/sessions/s1/tools").mock(
return_value=httpx.Response(
200,
json={
"agent_id": "alice:wizard",
"builtin_tools": [],
"bifrost_tools": [
{"name": "bifrost.alice.set_field", "description": "d", "parameters": {}}
],
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
tools = await get_session_tools(client, "s1")
assert tools["agent_id"] == "alice:wizard"
assert tools["builtin_tools"] == []
assert tools["bifrost_tools"][0]["name"] == "bifrost.alice.set_field"
@respx.mock
async def test_cross_owner_404_raises(self) -> None:
"""cross_owner_404 [error]: 404 session_not_found → SessionApiFailed(404)."""
respx.get("https://w.example/sessions/s1/tools").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_session_tools(client, "s1")
assert exc.value.status == 404
@respx.mock
async def test_empty_session_id_asserts(self) -> None:
"""empty_session_id [adversarial]: '' → AssertionError; no HTTP issued."""
route = respx.get("https://w.example/sessions//tools").mock(
return_value=httpx.Response(200, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await get_session_tools(client, "")
assert route.call_count == 0
class TestGetSessionBifrost:
"""#2 contract — get_session_bifrost (GET /admin/sessions/{id}/bifrost, #176)."""
@respx.mock
async def test_happy_uses_admin_bearer(self) -> None:
"""happy [happy,tracer]: 200 → binding dict; request carries the ADMIN bearer (override)."""
route = respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
return_value=httpx.Response(
200,
json={
"endpoint_url": "https://bifrost.example/mcp",
"consumer_id": "alice",
"connected": True,
"capabilities_granted": ["tools:call", "tools:read"],
"tools": [{"name": "bifrost.alice.echo", "description": "echo"}],
},
)
)
async with httpx.AsyncClient(
base_url="https://w.example",
headers={"Authorization": "Bearer consumer-key"},
) as client:
state = await get_session_bifrost(client, "s1", admin_key="admin-xyz")
assert state["connected"] is True
assert state["tools"][0]["name"] == "bifrost.alice.echo"
# the request overrode the client's default consumer bearer with the admin key
assert route.calls[0].request.headers["Authorization"] == "Bearer admin-xyz"
@respx.mock
async def test_403_scope_denied(self) -> None:
"""403 [error]: admin key lacks admin.sessions.read → SessionApiFailed(403)."""
respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_session_bifrost(client, "s1", admin_key="k")
assert exc.value.status == 403
@respx.mock
async def test_404_not_bound(self) -> None:
"""404 [error]: session_not_bifrost_bound → SessionApiFailed(404)."""
respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_bifrost_bound"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_session_bifrost(client, "s1", admin_key="k")
assert exc.value.status == 404
@respx.mock
async def test_empty_admin_key_asserts(self) -> None:
"""empty_admin_key [adversarial]: '' → AssertionError; no HTTP issued."""
route = respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
return_value=httpx.Response(200, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await get_session_bifrost(client, "s1", admin_key="")
assert route.call_count == 0
class TestTransientCharacters:
"""docs/contracts/issues/2.contract.md — transient-character wrappers (#161)."""
@respx.mock
async def test_list_models(self) -> None:
"""list_models [happy,tracer]: 200 → {items:[...]} verbatim."""
respx.get("https://w.example/models/available-for-characters").mock(
return_value=httpx.Response(200, json={"items": [{"name": "fast", "thinking": False}]})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
models = await list_character_models(client)
assert models["items"][0]["name"] == "fast"
@respx.mock
async def test_create_body_and_response(self) -> None:
"""create [happy]: body is {character, state}; 201 → {character_id, ttl_expires_at}."""
import json as _json
route = respx.post("https://w.example/characters").mock(
return_value=httpx.Response(201, json={"character_id": "char_x", "ttl_expires_at": "t"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
out = await create_character(client, {"schema_version": "1", "name": "H"})
assert out["character_id"] == "char_x"
body = _json.loads(route.calls[0].request.content)
assert body == {"character": {"schema_version": "1", "name": "H"}, "state": None}
@respx.mock
async def test_get_state(self) -> None:
"""get_state [happy]: 200 → live PAD/emotions snapshot."""
respx.get("https://w.example/characters/char_x/state").mock(
return_value=httpx.Response(200, json={"schema_version": "1", "pad": [0.4, 0.1, -0.2]})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
state = await get_character_state(client, "char_x")
assert state["pad"] == [0.4, 0.1, -0.2]
@respx.mock
async def test_delete_204(self) -> None:
"""delete [happy]: 204 → None."""
respx.delete("https://w.example/characters/char_x").mock(
return_value=httpx.Response(204)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
assert await delete_character(client, "char_x") is None
@respx.mock
async def test_create_403_scope(self) -> None:
"""create_403 [error]: key lacks character.write → SessionApiFailed(403)."""
respx.post("https://w.example/characters").mock(
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await create_character(client, {"name": "H"})
assert exc.value.status == 403
class TestSetPersonaState:
"""#2 contract — set_persona_state (POST /sessions/{id}/persona_state)."""
@respx.mock
async def test_happy_204(self) -> None:
"""happy [happy,tracer]: freeform snapshot body; 204 → None."""
import json as _json
route = respx.post("https://w.example/sessions/s1/persona_state").mock(
return_value=httpx.Response(204)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await set_persona_state(
client, "s1", {"pad": {"pleasure": 0.4, "arousal": 0.1, "dominance": -0.2}}
)
assert result is None
assert _json.loads(route.calls[0].request.content) == {
"pad": {"pleasure": 0.4, "arousal": 0.1, "dominance": -0.2}
}
@respx.mock
async def test_non_204_raises(self) -> None:
"""non_204 [error]: 422 (bad snapshot shape) → SessionApiFailed(422)."""
respx.post("https://w.example/sessions/s1/persona_state").mock(
return_value=httpx.Response(422, json={"error_code": "validation_failed"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await set_persona_state(client, "s1", {"pad": [1, 2, 3]})
assert exc.value.status == 422
_AUTHORED_ACK = {
"author": "assistant",
"content_chars": 5,
"injected_at": "2026-07-06T12:00:00+00:00",
"phase": "seeded",
"seq": 0,
"session_id": "s1",
"turn_id": "t1",
}
class TestWriteAuthoredHistory:
"""write_authored_history — #347 POST /sessions/{id}/history (contract #2 amendment)."""
@respx.mock
async def test_happy_fresh_201(self) -> None:
"""happy_fresh_201 [happy,tracer]: 201 → ack verbatim; minimal body."""
import json as _json
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await write_authored_history(
client, "s1", content="hello", idempotency_key="k1"
)
assert result == _AUTHORED_ACK
assert _json.loads(route.calls[0].request.content) == {
"author": "assistant",
"content": "hello",
"idempotency_key": "k1",
}
@respx.mock
async def test_happy_replay_200(self) -> None:
"""happy_replay_200 [happy]: 200 replay (byte-identical body) → dict verbatim."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(200, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await write_authored_history(
client, "s1", content="hello", idempotency_key="k1"
)
assert result == _AUTHORED_ACK
@respx.mock
async def test_body_includes_effects(self) -> None:
"""body_includes_effects [trace]: effects + claimed_original_at appear iff non-None."""
import json as _json
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await write_authored_history(
client,
"s1",
content="hi",
idempotency_key="k1",
effects="none",
claimed_original_at="2020-01-01T00:00:00Z",
)
assert _json.loads(route.calls[0].request.content) == {
"author": "assistant",
"content": "hi",
"idempotency_key": "k1",
"effects": "none",
"claimed_original_at": "2020-01-01T00:00:00Z",
}
@respx.mock
async def test_hide_existence_404(self) -> None:
"""hide_existence_404 [error]: 404 → AuthoredHistoryUnavailable (NOT SessionApiFailed)."""
respx.post("https://w.example/sessions/s1/history").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(AuthoredHistoryUnavailable) as exc:
await write_authored_history(client, "s1", content="hi", idempotency_key="k1")
assert exc.value.session_id == "s1"
@respx.mock
async def test_generation_active_409(self) -> None:
"""generation_active_409 [error]: 409 → SessionApiFailed(409)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(409, json={"error_code": "generation_active"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await write_authored_history(client, "s1", content="hi", idempotency_key="k1")
assert exc.value.status == 409
@respx.mock
async def test_content_too_long_422(self) -> None:
"""content_too_long_422 [error]: 422 → SessionApiFailed(422)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(422, json={"error_code": "content_too_long"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await write_authored_history(client, "s1", content="x", idempotency_key="k1")
assert exc.value.status == 422
@respx.mock
async def test_empty_content(self) -> None:
"""empty_content [adversarial]: content="" → AssertionError; no HTTP issued."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await write_authored_history(client, "s1", content="", idempotency_key="k1")
assert not route.called
@respx.mock
async def test_empty_idempotency_key(self) -> None:
"""empty_idempotency_key [adversarial]: key="" → AssertionError; no HTTP issued."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await write_authored_history(client, "s1", content="hi", idempotency_key="")
assert not route.called
@respx.mock
async def test_empty_session_id(self) -> None:
"""empty_session_id [adversarial]: session_id="" → AssertionError; no HTTP issued."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await write_authored_history(client, "", content="hi", idempotency_key="k1")
assert not route.called
class TestGetSessionMessages:
"""#2 contract (amendment 2026-07-06) — get_session_messages (GET /sessions/{id}/messages)."""
@respx.mock
async def test_happy(self) -> None:
"""happy [happy,tracer]: 200 {session_id, items, next_cursor} → dict verbatim."""
payload = {
"session_id": "s1",
"items": [{"seq": 0, "role": "assistant", "content": "hello there"}],
"next_cursor": None,
}
respx.get("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(200, json=payload)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await get_session_messages(client, "s1")
assert result == payload
@respx.mock
async def test_not_found_404(self) -> None:
"""not_found_404 [error]: 404 → SessionApiFailed(404)."""
respx.get("https://w.example/sessions/s1/messages").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_messages(client, "s1")
assert exc.value.status == 404
@respx.mock
async def test_empty_session_id(self) -> None:
"""empty_session_id [adversarial]: "" → AssertionError; no HTTP issued."""
route = respx.get("https://w.example/sessions/s1/messages").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_messages(client, "")
assert not route.called
+294 -1
View File
@@ -5,7 +5,9 @@ import pytest
import respx
from ratatoskr.sse_client import (
AdminEvent,
AffectUpdate,
AgentNotAvailable,
AwaitingLlmFirstToken,
CancelAlreadyCompleted,
Cancelled,
@@ -18,13 +20,17 @@ from ratatoskr.sse_client import (
ResumeBufferExpired,
ResumeTurnFinished,
SseConnectFailed,
SseConnectionDropped,
SseId,
Text,
TurnIdFlip,
TurnLaunchUnavailable,
_parse_sse_id,
cancel_turn,
reconnect_turn,
stream_admin_events,
stream_turn,
stream_turn_resilient,
)
_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()
_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:
def test_happy_simple(self) -> None:
"""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:
"""ERROR_ROUTING: SseConnectFailed.body is truncated to <= 1024 bytes."""
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(
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:
with pytest.raises(SseConnectFailed) as exc_info:
@@ -424,6 +461,71 @@ class TestStreamTurn:
assert len(exc_info.value.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
async def test_no_text_aggregation(self) -> None:
"""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]
assert elapsed == sorted(elapsed) # monotonically increasing
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"]
+496
View File
@@ -2922,6 +2922,10 @@ class TestTuiBifrostBind:
},
)
)
# sindra is a preset agent → the TUI create path now auto-seeds a #347 first-message.
respx.post("https://w.example/sessions/s-bound/history").mock(
return_value=httpx.Response(201, json={})
)
async def fake_run_async(self) -> int:
return 0
@@ -2945,3 +2949,495 @@ class TestTuiBifrostBind:
err = capsys.readouterr().err
assert "bifrost: status=bound" 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
+250 -1
View File
@@ -175,6 +175,62 @@ class TestCreateSessionEndpoint:
resp = TestClient(app).post("/api/sessions", json={})
assert resp.status_code == 400
@respx.mock
def test_preset_agent_auto_seeds_first_message(self) -> None:
"""#347: a preset agent gets its opening seeded on create; a non-preset agent does not."""
respx.post("https://w.example/sessions").mock(return_value=httpx.Response(201, json=_CREATE_OK))
hist = respx.post("https://w.example/sessions/s-1/history").mock(
return_value=httpx.Response(
201,
json={
"author": "assistant", "seq": 0, "phase": "seeded", "turn_id": "t1",
"session_id": "s-1", "content_chars": 1, "injected_at": "t",
},
)
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
client = TestClient(app)
# preset agent → the endpoint seeds a first-message
assert client.post("/api/sessions", json={"agent_id": "ratatoskr:sindra"}).status_code == 201
assert hist.call_count == 1
# non-preset agent → no seed (count unchanged)
assert client.post("/api/sessions", json={"agent_id": "mimir"}).status_code == 201
assert hist.call_count == 1
class TestSessionMessagesEndpoint:
"""GET /api/sessions/{id}/messages — proxy session history (renders the #347 seed)."""
@respx.mock
def test_happy_returns_history(self) -> None:
"""happy [tracer]: proxies GET /sessions/{id}/messages → 200 with the items verbatim."""
payload = {
"session_id": "s-1",
"items": [{"seq": 0, "role": "assistant", "content": "Hey there."}],
"next_cursor": None,
}
respx.get("https://w.example/sessions/s-1/messages").mock(
return_value=httpx.Response(200, json=payload)
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/sessions/s-1/messages")
assert resp.status_code == 200
assert resp.json()["items"][0]["content"] == "Hey there."
@respx.mock
def test_non_200_status_preserved(self) -> None:
"""error: upstream 404 → status-preserving session_messages_unavailable envelope."""
respx.get("https://w.example/sessions/ghost/messages").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/sessions/ghost/messages")
assert resp.status_code == 404
assert resp.json()["error_code"] == "session_messages_unavailable"
_SNAPSHOT = {
"agent_id": "mimir",
@@ -625,6 +681,10 @@ class TestCreateAppShape:
"/", "/version", "/api/agents", "/api/sessions",
"/api/agents/{agent_id}/persona_state",
"/api/affect/{agent_id}",
# v0.19.2 debug-surface parity (create_app POST-002)
"/api/sessions/{session_id}/tools",
"/api/sessions/{session_id}/bifrost",
"/api/admin/events",
"/api/turns/{session_id}", "/api/turns/{session_id}/stream",
"/api/turns/{session_id}/cancel",
):
@@ -633,10 +693,14 @@ class TestCreateAppShape:
assert "/static" in paths
def test_state_attached(self) -> None:
"""state_attached [trace]: app.state.turn_registry is empty dict."""
"""state_attached [trace]: app.state.turn_registry is empty dict; admin_key stored."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
assert app.state.turn_registry == {}
# create_app POST-001: admin_key defaults None (admin routes fail-visible)
assert app.state.admin_key is None
app2 = create_app(_mock_client_factory(), admin_key="adm-key")
assert app2.state.admin_key == "adm-key"
def test_factory_stored(self) -> None:
"""factory_stored [trace]: app.state.client_factory is the same callable."""
@@ -813,6 +877,10 @@ class TestWebBifrostBind:
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK)
)
# sindra is a preset agent → the endpoint now auto-seeds a #347 first-message.
respx.post("https://w.example/sessions/s-1/history").mock(
return_value=httpx.Response(201, json={})
)
app = create_app(
_mock_client_factory(),
bifrost_consumer_key="server-ck",
@@ -837,6 +905,54 @@ class TestWebBifrostBind:
}
assert upstream.headers["Authorization"] == "Bearer server-ck"
@respx.mock
def test_combined_plane_binds_to_8392(self) -> None:
"""combined [#18 composite]: a 'combined' plane from the browser → the server
binds the :8392 both-plane endpoint; bound-state echoes plane='combined'."""
import json as _json
from ratatoskr.web.server import create_app
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK)
)
# sindra is a preset agent → the endpoint now auto-seeds a #347 first-message.
respx.post("https://w.example/sessions/s-1/history").mock(
return_value=httpx.Response(201, json={})
)
app = create_app(
_mock_client_factory(),
bifrost_consumer_key="server-ck",
bifrost_visible_host="10.100.10.50",
)
resp = TestClient(app).post(
"/api/sessions",
json={"agent_id": "ratatoskr:sindra", "bifrost_plane": "combined"},
)
assert resp.status_code == 201
assert resp.json()["bifrost"] == {
"plane": "combined",
"endpoint": "http://10.100.10.50:8392",
"status": "bound",
}
upstream = route.calls[0].request
body = _json.loads(upstream.content)
assert body["bifrost"] == {
"endpoint_url": "http://10.100.10.50:8392",
"scope": None,
}
def test_dropdown_offers_combined_as_default(self) -> None:
"""(a)+default: the SPA plane dropdown offers a 'combined' (:8392) option,
it is the DEFAULT-selected one, and single-plane memory/affect remain."""
from pathlib import Path
import ratatoskr.web as web_pkg
html = (Path(web_pkg.__file__).parent / "static" / "index.html").read_text()
assert '<option value="combined" selected>' in html
assert 'value="memory"' in html and 'value="affect"' in html
@respx.mock
def test_plane_without_server_config_is_400(self) -> None:
"""A plane requested but no server-held key/host → bifrost_not_configured."""
@@ -1018,3 +1134,136 @@ class TestAffectStateEndpoint:
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
assert resp.status_code == 400
assert resp.json()["error_code"] == "missing_end_user_id"
class TestSessionToolsEndpoint:
"""session_tools_endpoint — proxy owner-scoped GET /sessions/{id}/tools (#183)."""
@respx.mock
def test_happy_returns_inventory(self) -> None:
"""happy [tracer]: 200 inventory → 200 verbatim."""
respx.get("https://w.example/sessions/s-1/tools").mock(
return_value=httpx.Response(200, json={
"agent_id": "ratatoskr:sindra",
"builtin_tools": ["echo"],
"bifrost_tools": [{"name": "memory.search"}],
})
)
from ratatoskr.web.server import create_app
resp = TestClient(create_app(_mock_client_factory())).get("/api/sessions/s-1/tools")
assert resp.status_code == 200
assert resp.json()["agent_id"] == "ratatoskr:sindra"
@respx.mock
def test_upstream_404_status_preserving_envelope(self) -> None:
"""error: upstream 404 → 404 session_tools_unavailable envelope."""
respx.get("https://w.example/sessions/s-1/tools").mock(
return_value=httpx.Response(404, content=b"nope")
)
from ratatoskr.web.server import create_app
resp = TestClient(create_app(_mock_client_factory())).get("/api/sessions/s-1/tools")
assert resp.status_code == 404
assert resp.json()["error_code"] == "session_tools_unavailable"
class TestSessionBifrostEndpoint:
"""session_bifrost_endpoint — proxy admin-scoped GET /admin/sessions/{id}/bifrost (#176)."""
@respx.mock
def test_happy_overrides_with_admin_bearer(self) -> None:
"""happy [tracer]: 200 state → 200; request carries the ADMIN bearer, not consumer."""
route = respx.get("https://w.example/admin/sessions/s-1/bifrost").mock(
return_value=httpx.Response(200, json={
"endpoint_url": "http://x:8392", "connected": True,
"capabilities_granted": ["memory", "affect"], "tools": [],
})
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), admin_key="adm-key")
resp = TestClient(app).get("/api/sessions/s-1/bifrost")
assert resp.status_code == 200
assert resp.json()["connected"] is True
assert route.calls.last.request.headers["Authorization"] == "Bearer adm-key"
def test_no_admin_key_fails_visible_400(self) -> None:
"""error: no admin key configured → 400 admin_key_not_configured, no upstream call."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory()) # no admin_key
resp = TestClient(app).get("/api/sessions/s-1/bifrost")
assert resp.status_code == 400
assert resp.json()["error_code"] == "admin_key_not_configured"
@respx.mock
def test_upstream_404_status_preserving_envelope(self) -> None:
"""error: upstream 404 (not bound) → 404 bifrost_state_unavailable envelope."""
respx.get("https://w.example/admin/sessions/s-1/bifrost").mock(
return_value=httpx.Response(404, content=b"nope")
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), admin_key="adm-key")
resp = TestClient(app).get("/api/sessions/s-1/bifrost")
assert resp.status_code == 404
assert resp.json()["error_code"] == "bifrost_state_unavailable"
class TestAdminEventsEndpoint:
"""admin_events_endpoint — SSE proxy of GET /admin/events, session-filtered (#11)."""
def test_filter_semantics(self) -> None:
"""unit: heartbeats drop, system.* pass, else match on session_id."""
from ratatoskr.sse_client import AdminEvent
from ratatoskr.web.server import _admin_event_matches_web
def mk(t: str, sid: "str | None" = None) -> AdminEvent:
return AdminEvent(id=1, type=t, timestamp=None,
data={"session_id": sid} if sid else {})
assert _admin_event_matches_web(mk("system.heartbeat"), "s-1") is False
assert _admin_event_matches_web(mk("system.degraded"), "s-1") is True
assert _admin_event_matches_web(mk("session.created", "s-1"), "s-1") is True
assert _admin_event_matches_web(mk("session.created", "other"), "s-1") is False
assert _admin_event_matches_web(mk("session.created", "s-1"), None) is False
def test_no_admin_key_fails_visible_400(self) -> None:
"""error: no admin key → 400 admin_key_not_configured (no stream opened)."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/admin/events?session_id=s-1")
assert resp.status_code == 400
assert resp.json()["error_code"] == "admin_key_not_configured"
@respx.mock
def test_streams_filtered_events_fixed_name(self) -> None:
"""happy: SSE → only session-matching + system.* forwarded, as `admin_event`."""
stream = (
b'event: session.created\n'
b'data: {"type":"session.created","data":{"session_id":"s-1"}}\n\n'
b'event: system.heartbeat\n'
b'data: {"type":"system.heartbeat","data":{}}\n\n'
b'event: turn.started\n'
b'data: {"type":"turn.started","data":{"session_id":"other"}}\n\n'
b'event: system.degraded\n'
b'data: {"type":"system.degraded","data":{}}\n\n'
)
respx.get("https://w.example/admin/events").mock(return_value=_sse_resp(stream))
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), admin_key="adm-key")
body = TestClient(app).get("/api/admin/events?session_id=s-1").text
assert "event: admin_event" in body # fixed browser-facing name
assert '"type": "session.created"' in body # matches active session → forwarded
assert "system.degraded" in body # system.* → forwarded
assert "system.heartbeat" not in body # heartbeat → dropped
assert "turn.started" not in body # other session → dropped
@respx.mock
def test_stream_error_on_connect_failure(self) -> None:
"""error: upstream admin SSE non-200 -> ONE stream_error frame, stream ends (POST-003)."""
respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(500, content=b"boom")
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), admin_key="adm-key")
body = TestClient(app).get("/api/admin/events?session_id=s-1").text
assert "event: stream_error" in body
assert "SseConnectFailed" in body
assert body.count("event: stream_error") == 1 # exactly one, then ends
Generated
+5 -5
View File
@@ -190,14 +190,14 @@ wheels = [
[[package]]
name = "bifrost"
version = "0.10.0"
version = "1.0.0"
source = { registry = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" }
dependencies = [
{ name = "jsonschema" },
]
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.10.0/bifrost-0.10.0.tar.gz", hash = "sha256:aba1869dba68d921f2e0be8fb560277073da09ec2ad5f410e226cacd5e84fe1a" }
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/1.0.0/bifrost-1.0.0.tar.gz", hash = "sha256:93130d68dfd9868580a4514277996ba176837972b9e42129eda8bb03ad3b18b9" }
wheels = [
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.10.0/bifrost-0.10.0-py3-none-any.whl", hash = "sha256:88adbce23fa8840a14f9493e4f0cf6f9320f4950845f7a6080387defe574a5cc" },
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/1.0.0/bifrost-1.0.0-py3-none-any.whl", hash = "sha256:1a53baa2b0596b7c418e2d82e3eeee0f13054604d78b592609ee1aff90dccac2" },
]
[[package]]
@@ -1052,7 +1052,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.17.17"
version = "0.19.9"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
@@ -1086,7 +1086,7 @@ web = [
[package.metadata]
requires-dist = [
{ name = "bifrost", marker = "extra == 'provider'", specifier = ">=0.10.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
{ name = "bifrost", marker = "extra == 'provider'", specifier = "==1.0.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "httpx-sse", specifier = ">=0.4" },
{ name = "jsonschema", marker = "extra == 'provider'", specifier = ">=4" },