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:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user