From 6bf2a84ccd089f812e1d60e2fbcab10987b6dd39 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Mon, 6 Jul 2026 09:40:42 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20authored-history-write=20consumer=20sid?= =?UTF-8?q?e=20(Worldtree=20#347)=20=E2=80=94=20v0.19.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/contracts/issues/2.contract.md | 96 +++++++++++++++ docs/coverage-map.md | 28 +++-- persistent-memory.md | 7 +- pyproject.toml | 2 +- src/ratatoskr/cli.py | 86 ++++++++++++- src/ratatoskr/sessions.py | 92 ++++++++++++++ tests/test_cli.py | 117 ++++++++++++++++++ tests/test_sessions.py | 181 ++++++++++++++++++++++++++++ uv.lock | 2 +- 9 files changed, 595 insertions(+), 16 deletions(-) diff --git a/docs/contracts/issues/2.contract.md b/docs/contracts/issues/2.contract.md index 81528e7..0031f17 100644 --- a/docs/contracts/issues/2.contract.md +++ b/docs/contracts/issues/2.contract.md @@ -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":,"idempotency_key":} 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=), 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 +``` diff --git a/docs/coverage-map.md b/docs/coverage-map.md index a912b3e..f549c93 100644 --- a/docs/coverage-map.md +++ b/docs/coverage-map.md @@ -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/:` 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: diff --git a/persistent-memory.md b/persistent-memory.md index 958ca3d..8906436 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -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 "" --agent ` 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._ diff --git a/pyproject.toml b/pyproject.toml index 019087a..88adc58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/ratatoskr/cli.py b/src/ratatoskr/cli.py index 36a76e0..233c2d2 100644 --- a/src/ratatoskr/cli.py +++ b/src/ratatoskr/cli.py @@ -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 ") 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 ") + 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 diff --git a/src/ratatoskr/sessions.py b/src/ratatoskr/sessions.py index e1f6cd8..b9e3c82 100644 --- a/src/ratatoskr/sessions.py +++ b/src/ratatoskr/sessions.py @@ -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) diff --git a/tests/test_cli.py b/tests/test_cli.py index 188ca0b..2204c10 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_sessions.py b/tests/test_sessions.py index ef9e30a..4352c9a 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -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 diff --git a/uv.lock b/uv.lock index 4d390ce..624ef6a 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,7 +1052,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.19.5" +version = "0.19.6" source = { editable = "." } dependencies = [ { name = "httpx" },