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).
This commit is contained in:
2026-07-06 09:40:42 -07:00
parent 75da6767d3
commit 6bf2a84ccd
9 changed files with 595 additions and 16 deletions
+96
View File
@@ -372,3 +372,99 @@ TESTS:
happy [happy]: 204 → None; body == {"pad":[...]} verbatim
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
```
+17 -11
View File
@@ -19,7 +19,7 @@ anchors against the frozen machine-readable artifacts, NOT the prose markdown:
| Worldtree v1 surface | Frozen anchor | Ratatoskr role |
|---|---|---|
| Conversation REST API | OpenAPI `info.version` **2.2.0** (`Worldtree/docs/conversation-api-openapi.json`, sha `dbdf4e24…`) — **40 path×method groups** | **client** (debug TUI / web) |
| Conversation 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) |
@@ -48,7 +48,7 @@ resolved (§ Surface 1, scope-resolution table).
| Surface | Points | ✅ covered-live | ⬜ gap (in-scope) | 🚫 excluded-by-design |
|---|---|---|---|---|
| REST (OpenAPI 2.2.0, path groups) | 40 | 17 | 0 | 23 |
| 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) |
@@ -61,7 +61,7 @@ sub-gap).
---
## Surface 1 — Conversation REST API (OpenAPI 2.2.0)
## Surface 1 — Conversation REST API (OpenAPI 2.3.0)
### Covered — client path (ratatoskr's core identity)
@@ -69,6 +69,8 @@ sub-gap).
|---|---|---|---|
| `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-proof pending** the `session.history.write` grant (requested infra-ops 2026-07-06) — ungranted returns the hide-404, so the probe exercises the feature-absent fallback until granted (Tier-2 precedent: ✅ code-complete + graceful-degrade) |
| `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 |
@@ -96,16 +98,21 @@ on the same path is an unwired frontier item — see frontier Tier 1):
- `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 (zero remaining, 2026-07-01)
### In-scope gaps — CONVERGED (re-closed 2026-07-06 after the #347 re-open)
**Every in-scope REST I/O point is now covered.** The frontier that opened this
audit (the design-brief §5 observability panes + the presenter-wiring sub-gaps +
the Tier-2 tail) is fully closed:
**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
@@ -116,7 +123,6 @@ path-group gap. Everything else is covered or excluded-by-design below.
| Endpoint(s) | Status | Rationale (design-brief / memory) |
|---|---|---|
| `PATCH /sessions/{id}` · `DELETE /sessions/{id}` | 🚫 | §4: rename/delete happen outside the tool (`sessions_cli.py`) |
| `GET /sessions/{id}/messages` (history) | 🚫 | §6: single-session live transcript, no history fetch |
| `GET /sessions/{id}` | 🚫 | session detail — identity is footer-visible, no detail view |
| `GET /sessions/{id}/tool-events` | 🚫 | §5: tool calls observed **inline from SSE** `tool_start`/`tool_result`; persisted-events endpoint is opt-in only |
| `GET /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) |
@@ -204,10 +210,10 @@ starts exercising them.
---
## Convergence frontier (the v1 to-do) — CLOSED 2026-07-01
## 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 17/40
with **zero in-scope gaps** (the other 23 REST path-groups are excluded-by-design),
**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:
+6 -1
View File
@@ -41,7 +41,9 @@ upstream API key stays server-side (INV-003).
_As of 2026-07-06:_
**Two big cross-project arcs landed this session (2026-07-04 -> 07-06), both with worldtree-dev. NO ratatoskr production-code change (a tooling script + proposal docs only; code tip stays `v0.19.5`, HEAD `0a8784c`).**
**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-PROOF PENDING** the `session.history.write` grant (requested infra-ops `01KWW3KQEY`, monitor armed) -- ungranted the route returns the hide-404, so the probe exercises the feature-absent fallback until granted. **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).
**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):**
**(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.
@@ -154,6 +156,9 @@ decision. Captures rationale that won't be obvious from code alone.
- `[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).
_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._
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.19.5"
version = "0.19.6"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
+84 -2
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import argparse
import asyncio
import hashlib
import os
import signal
import sys
@@ -18,6 +19,7 @@ import httpx
from ratatoskr.sessions import (
AgentNotFound,
AuthoredHistoryUnavailable,
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
@@ -29,8 +31,10 @@ from ratatoskr.sessions import (
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,
@@ -116,6 +120,9 @@ class ParsedArgs:
# 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):
@@ -144,6 +151,7 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
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.
@@ -164,8 +172,11 @@ 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 sum([ns.whoami, ns.characters, bool(ns.set_persona_pad)]) > 1:
raise UsageError("--whoami / --characters / --set-persona-pad are mutually exclusive")
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:
@@ -181,6 +192,17 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
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")
@@ -256,6 +278,7 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
admin_key=admin_key,
characters=ns.characters,
set_persona_pad=ns.set_persona_pad,
seed_first_message=ns.seed_first_message,
)
@@ -732,6 +755,63 @@ async def _set_persona_probe(args: ParsedArgs) -> int:
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)
@@ -753,6 +833,8 @@ def main(argv: list[str] | None = None) -> int:
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
+92
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,
*,
@@ -558,3 +578,75 @@ async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]:
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)
+117
View File
@@ -1693,3 +1693,120 @@ class TestTier2Probes:
assert rc == 0
assert "persona_state set" in capsys.readouterr().out
assert _json.loads(route.calls[0].request.content) == {"pad": [0.4, 0.1, -0.2]}
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
+181
View File
@@ -8,6 +8,7 @@ from ratatoskr.sessions import (
AgentInfo,
AgentNotAvailable,
AgentNotFound,
AuthoredHistoryUnavailable,
AuthScopeDenied,
BifrostBinding,
BifrostConsumerKeyMissing,
@@ -25,11 +26,13 @@ from ratatoskr.sessions import (
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,
)
@@ -1193,3 +1196,181 @@ class TestSetPersonaState:
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
Generated
+1 -1
View File
@@ -1052,7 +1052,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.19.5"
version = "0.19.6"
source = { editable = "." }
dependencies = [
{ name = "httpx" },