Compare commits

..

3 Commits

Author SHA1 Message Date
vh fc256bbaa4 fix(#20): heid-bug-hunt fixups — probe ConnectFailed + adapter finite-PAD (slice-3)
The slice-3 heid-bug-hunt panel (3/3) caught a real regression the cutover
introduced, plus a chokepoint-invariant gap:

- ConnectFailed escaped both rewired CLI probes. When --set-persona-pad and
  --seed-first-message moved off raw httpx onto the wt adapter, transport failures
  changed class: the SDK normalizes any pre-response transport error to
  worldtree_sdk.ConnectFailed (request.py), a WorldtreeError (not ApiError), so it
  passed the adapter unmapped AND the probes' httpx-only except tuples → an uncaught
  traceback instead of the graceful [network_error] exit 21. _amain (slice-2) already
  handled it; the probes lagged. Fix: add ConnectFailed to both probe except tuples
  (mirrors _amain). Live-verified at a refused host → [network_error] exit 21.

- Finite-PAD enforced only at the CLI, not the adapter chokepoint. wt.set_persona_state
  delegated finiteness to the caller (documented), so a direct/non-CLI caller passing
  nan/inf got a raw SDK ConfigurationError. Fix: assert finiteness in the adapter
  precondition (consistent with its other precondition asserts) so the invariant holds
  at the chokepoint in ratatoskr's own terms; the CLI pre-check stays for the friendly
  usage error.

Triaged-and-declined (all correct per the panel + Heid's source-check): the deleted
sessions.py exports (intended no-shim cutover, zero un-migrated importers), the
session["session_id"] index (accept-known-risk, matches --new), and Regin's "web
indefinite block" (refuted — the seed is asyncio.wait_for-bounded). The concurrent
heid-code-review panel returned zero drift, no code change.

TDD: 3 RED tests (both probes' ConnectFailed → exit 21; adapter nan/inf/-inf →
AssertionError, never reaches the SDK) → GREEN. Suite 469; ruff clean; mypy no new errors.
2026-07-19 09:13:47 -07:00
vh ca9a339050 feat(#20): persona + authored-history + first-message onto the wt adapter (slice-3)
Slice-3 of the worldtree-sdk cutover: migrate the session persona-state write,
the #347 authored-history write, and get_session_messages onto ratatoskr.wt, and
route the first-message preset seed through the adapter. Retire the last
hand-rolled sessions.py paths the --seed-first-message probe kept alive
(create_session + SessionInfo, set_persona_state, write_authored_history,
get_session_messages, _bifrost_error_from).

- wt.set_persona_state (SDK PadState) — the CLI passes three finite PAD axes; the
  SDK owns the {"pad": {...}} wire (#317). No route-specific error row → the
  SessionApiFailed default.
- wt.write_authored_history (SDK write_history) — v1 author=assistant; 404 →
  AuthoredHistoryUnavailable (hide-existence; the route is the discriminator,
  never the body); every other ApiError → the default. Drops the unused
  author/effects/claimed_original_at params (no caller uses them).
- first_message.seed_preset_first_message now takes a WorldtreeClient and routes
  through wt.write_authored_history; the best-effort invariants (INV-001..004,
  never-raise/never-block/one-write/zero-worldtree-source-import) are unchanged.
  Tests drive a fake WorldtreeClient — the wire is the SDK's to prove.
- CLI --set-persona-pad / --seed-first-message + the _amain and web create-path
  first-message seeds rewired onto the adapter. --set-persona-pad pre-validates
  PAD finiteness (clean usage_error, never a crash on the SDK ConfigurationError).

LIVE-SMOKE on personal :8081 (b128, INV-CUT-5): --seed-first-message → 201
(seq=0, phase=seeded) → read-back verbatim; --set-persona-pad → 204; the --new
create-path preset seed observed routing through the adapter. All slice-3 route
families proven end-to-end through the ratatoskr surface.

docs/coverage-map.md + first_message.contract.md re-anchored onto the adapter;
the slice-2 create/stream/cancel rows re-anchored too (they still named the
deleted sse_client/sessions symbols).

Suite 466 green; mypy no new errors (baseline 22 → 20 in the touched modules);
ruff clean. INV-CUT-1..5 held. Bifrost provider planes untouched.
2026-07-19 08:53:45 -07:00
vh 0b23334c68 memory: /snapshot — worldtree-sdk cutover slice-1+2 complete + pushed (aba1730)
Slice-2 (sessions/turn) done end-to-end through the House Code Discipline; both heid
gates triaged+fixed. Captures the KEY ADAPTER FACTS foot-guns for slices 3-7 (open-world
dicts, body-derived turn_id, ConnectFailed(0) transport normalization, consumer_key
bound-only, nested-detail error_code parsing) + the two-lens gate value proof. Slice-3
(persona/authored-history) next.
2026-07-19 08:23:23 -07:00
16 changed files with 464 additions and 1146 deletions
+21 -20
View File
@@ -13,11 +13,12 @@ scope: >
replacement for a system-prompt "startup" instruction. Two entry points: replacement for a system-prompt "startup" instruction. Two entry points:
`preset_for` (lookup) and `seed_preset_first_message` (best-effort seed). `preset_for` (lookup) and `seed_preset_first_message` (best-effort seed).
Consumed by ratatoskr.cli (the `--new` session path) and ratatoskr.web.server Consumed by ratatoskr.cli (the `--new` session path) and ratatoskr.web.server
(the POST /api/sessions endpoint). Depends on ratatoskr.sessions (the POST /api/sessions endpoint). Depends on ratatoskr.wt
(write_authored_history + its exceptions); no core.* / worldtree.* imports. (`wt.write_authored_history` + AuthoredHistoryUnavailable) over a WorldtreeClient
(worldtree-sdk cutover slice-3, #20); no core.* / worldtree.* SOURCE imports.
depends_on: depends_on:
- "httpx" - "worldtree_sdk"
- "ratatoskr.sessions" - "ratatoskr.wt"
used_by: used_by:
- "ratatoskr.cli" - "ratatoskr.cli"
- "ratatoskr.web.server" - "ratatoskr.web.server"
@@ -26,7 +27,7 @@ complexity: "low"
estimated_loc: 60 estimated_loc: 60
confidence: 0.9 confidence: 0.9
assumptions: assumptions:
- "write_authored_history (contract #2 amendment 2026-07-06) is the seed primitive: 200/201 → ack dict, 404 → AuthoredHistoryUnavailable (hide-existence), other non-2xx → SessionApiFailed." - "wt.write_authored_history (the SDK-adapter seed primitive, #20) writes over the worldtree-sdk client: success → ack mapping, 404 → AuthoredHistoryUnavailable (hide-existence), other ApiError → wt.SessionApiFailed. Behavior/semantics unchanged from the retired hand-rolled path — only the transport moved to the SDK."
- "The preset registry is a static in-module dict keyed by agent_id; editing it is how an operator tunes an agent's opening. Seeded with ratatoskr:sindra only." - "The preset registry is a static in-module dict keyed by agent_id; editing it is how an operator tunes an agent's opening. Seeded with ratatoskr:sindra only."
- "Auto-seed is BEST-EFFORT and MUST NOT block session creation: an instance without the session.history.write grant returns the hide-404, which is swallowed (session opens with no seeded greeting)." - "Auto-seed is BEST-EFFORT and MUST NOT block session creation: an instance without the session.history.write grant returns the hide-404, which is swallowed (session opens with no seeded greeting)."
--- ---
@@ -47,8 +48,8 @@ every new session for a preset agent opens in-character regardless of surface.
## Data flow ## Data flow
**In:** a live `httpx.AsyncClient` (caller-owned, base_url + bearer set), a fresh **In:** a `WorldtreeClient` (the wt-adapter client, built over ratatoskr's
`session_id`, and the bound `agent_id`. caller-owned transport), a fresh `session_id`, and the bound `agent_id`.
**Out:** on a preset agent, one `POST /sessions/{session_id}/history` (author=assistant, **Out:** on a preset agent, one `POST /sessions/{session_id}/history` (author=assistant,
the preset text, per-content idempotency key). Returns the seeded content on the preset text, per-content idempotency key). Returns the seeded content on
@@ -104,19 +105,19 @@ TESTS:
preset_miss [happy]: preset_for("mimir") is None preset_miss [happy]: preset_for("mimir") is None
empty_agent_id [adversarial]: preset_for("") → AssertionError empty_agent_id [adversarial]: preset_for("") → AssertionError
FN seed_preset_first_message(client: httpx.AsyncClient, session_id: str, agent_id: str) -> str | None FN seed_preset_first_message(client: WorldtreeClient, session_id: str, agent_id: str) -> str | None
BRIEF: Best-effort seed of an agent's preset opening as a #347 authored first-message on session_id. If agent_id has a preset, POST it via write_authored_history (author=assistant, per-content idempotency key, the await bounded by asyncio.wait_for(_SEED_TIMEOUT_S)) and return the seeded content; on no-preset, a malformed input, OR ANY exception except asyncio.CancelledError, return None WITHOUT raising. Never raises (except CancelledError, which propagates) and never blocks session creation — it is wired into three create paths. BRIEF: Best-effort seed of an agent's preset opening as a #347 authored first-message on session_id. If agent_id has a preset, write it via wt.write_authored_history (author=assistant, per-content idempotency key, the await bounded by asyncio.wait_for(_SEED_TIMEOUT_S)) and return the seeded content; on no-preset, a malformed input, OR ANY exception except asyncio.CancelledError, return None WITHOUT raising. Never raises (except CancelledError, which propagates) and never blocks session creation — it is wired into the CLI + web create paths.
PRE: [PRE-001 hard] client is not None -- soft-guarded: return None (NOT assert) if violated, so a wiring bug can't crash the create path (INV-001) PRE: [PRE-001 hard] client is not None -- soft-guarded: return None (NOT assert) if violated, so a wiring bug can't crash the create path (INV-001)
PRE: [PRE-002 hard] session_id is a non-empty str -- soft-guarded: return None if violated PRE: [PRE-002 hard] session_id is a non-empty str -- soft-guarded: return None if violated
PRE: [PRE-003 hard] agent_id is a non-empty str -- soft-guarded: return None if violated (also guards FIRST_MESSAGE_PRESETS.get against a non-hashable/non-str id) PRE: [PRE-003 hard] agent_id is a non-empty str -- soft-guarded: return None if violated (also guards FIRST_MESSAGE_PRESETS.get against a non-hashable/non-str id)
POST: [POST-001 return_value] preset agent + successful write → returns the preset text; no-preset, malformed input, OR any swallowed failure → None POST: [POST-001 return_value] preset agent + successful write → returns the preset text; no-preset, malformed input, OR any swallowed failure → None
POST: [POST-002 side_effect] a no-preset / malformed-input call issues ZERO HTTP; a preset agent issues exactly one POST /sessions/{session_id}/history with body author="assistant", content=preset, idempotency_key="ratatoskr-preset-"+sha256(preset)[:12], the await bounded by _SEED_TIMEOUT_S so a stalled response cannot block POST: [POST-002 side_effect] a no-preset / malformed-input call issues ZERO writes; a preset agent issues exactly one authored-history write (POST /sessions/{session_id}/history via the SDK) with entry author="assistant", content=preset, idempotency_key="ratatoskr-preset-"+sha256(preset)[:12], the await bounded by _SEED_TIMEOUT_S so a stalled response cannot block
ERROR_ROUTING: ERROR_ROUTING:
asyncio.CancelledError: asyncio.CancelledError:
local_handling: RE-RAISE (cancellation is not a seed failure; never swallow it — and it is a BaseException, so `except Exception` would miss it anyway) local_handling: RE-RAISE (cancellation is not a seed failure; never swallow it — and it is a BaseException, so `except Exception` would miss it anyway)
flow_control: propagate flow_control: propagate
state_recovery: n/a state_recovery: n/a
any other Exception (hide-404 AuthoredHistoryUnavailable, SessionApiFailed 409/422/etc., httpx.HTTPError, TimeoutError from wait_for, any unexpected error): any other Exception (hide-404 AuthoredHistoryUnavailable, wt.SessionApiFailed 409/422/etc., SDK ConnectFailed, TimeoutError from wait_for, any unexpected error):
local_handling: swallow; return None local_handling: swallow; return None
flow_control: continue (never blocks session create) flow_control: continue (never blocks session create)
state_recovery: session opens with no seeded greeting state_recovery: session opens with no seeded greeting
@@ -125,18 +126,18 @@ STEPS:
2. [sequential, prescriptive] content = FIRST_MESSAGE_PRESETS.get(agent_id); IF content is None: RETURN None (INV-002 — zero HTTP) 2. [sequential, prescriptive] content = FIRST_MESSAGE_PRESETS.get(agent_id); IF content is None: RETURN None (INV-002 — zero HTTP)
3. [sequential, prescriptive] Soft-guard: IF client is None OR session_id is not a non-empty str: RETURN None 3. [sequential, prescriptive] Soft-guard: IF client is None OR session_id is not a non-empty str: RETURN None
4. [sequential, prescriptive] key = "ratatoskr-preset-" + sha256(content utf-8)[:12] 4. [sequential, prescriptive] key = "ratatoskr-preset-" + sha256(content utf-8)[:12]
5. [sequential, prescriptive] TRY: await asyncio.wait_for(write_authored_history(client, session_id, content=content, idempotency_key=key), timeout=_SEED_TIMEOUT_S) 5. [sequential, prescriptive] TRY: await asyncio.wait_for(wt.write_authored_history(client, session_id, content=content, idempotency_key=key), timeout=_SEED_TIMEOUT_S)
tool: { destructive: false, idempotent: true, read_only: false, open_world: false } tool: { destructive: false, idempotent: true, read_only: false, open_world: false }
6. [branch, prescriptive] EXCEPT asyncio.CancelledError: RAISE; EXCEPT Exception: RETURN None 6. [branch, prescriptive] EXCEPT asyncio.CancelledError: RAISE; EXCEPT Exception: RETURN None
7. [cleanup, prescriptive] RETURN content 7. [cleanup, prescriptive] RETURN content
TESTS: TESTS: (driven through a fake WorldtreeClient whose sessions.write_history returns/raises — the wire is the SDK's to prove via its parity corpus)
seeds_preset [happy,tracer]: preset agent, mock 201 → returns the preset text; exactly one POST /sessions/{id}/history; body author="assistant" + content=preset + idempotency_key="ratatoskr-preset-"+sha256(preset)[:12] seeds_preset [happy,tracer]: preset agent, fake write_history returns an ack → returns the preset text; exactly one write_history call; entry author="assistant" + content=preset + idempotency_key="ratatoskr-preset-"+sha256(preset)[:12]
no_preset_zero_http [happy]: agent "mimir" → returns None; NO HTTP issued no_preset_zero_write [happy]: agent "mimir" → returns None; ZERO write_history call
feature_absent_swallowed [error]: preset agent, mock 404 session_not_found → returns None, no raise feature_absent_swallowed [error]: preset agent, fake raises ApiError(404) → adapter maps to AuthoredHistoryUnavailable → returns None, no raise
session_api_failed_swallowed [error]: preset agent, mock 409 → returns None, no raise session_api_failed_swallowed [error]: preset agent, fake raises ApiError(409) → wt.SessionApiFailed → returns None, no raise
transport_error_swallowed [error]: preset agent, mock httpx.ConnectError → returns None, no raise transport_error_swallowed [error]: preset agent, fake raises SDK ConnectFailed → returns None, no raise
unexpected_exception_swallowed [error]: preset agent, write raises ValueError → returns None, no raise (INV-001 broad never-raise) unexpected_exception_swallowed [error]: preset agent, write raises ValueError → returns None, no raise (INV-001 broad never-raise)
cancellation_propagates [error]: preset agent, write raises asyncio.CancelledError → RE-RAISED (never swallowed) cancellation_propagates [error]: preset agent, write raises asyncio.CancelledError → RE-RAISED (never swallowed)
malformed_agent_id_no_http [adversarial]: agent_id=123 (non-str) OR "" → None; NO HTTP; no raise malformed_agent_id_no_write [adversarial]: agent_id=123 (non-str) OR "" → None; ZERO write; no raise
empty_session_id [adversarial]: session_id="" (preset agent) → None (soft guard); NO HTTP; no raise empty_session_id [adversarial]: session_id="" (preset agent) → None (soft guard); ZERO write; no raise
``` ```
+6 -6
View File
@@ -84,11 +84,11 @@ sub-gap).
| Endpoint | Status | Where consumed | Note | | Endpoint | Status | Where consumed | Note |
|---|---|---|---| |---|---|---|---|
| `POST /sessions` | ✅ | `sessions.py` `create_session` `cli.py`,`tui.py`,`web/server.py` | + `end_user_id`, `bifrost` binding; 404→AgentNotFound, 502→BifrostHandshakeFailed. **v0.21.2 (#19): ephemeral-template (Echo) create**`config` passthrough (`--system-prompt`), `role` not `model` (W-4), `kind`/`config` captured; 422 ephemeral_requires_config now reachable-and-handled. Depth enhancement to an already-covered route — count unchanged | | `POST /sessions` | ✅ | `wt.py` `create_session` (SDK `sessions.create`) → `cli.py`,`web/server.py` | **wt-adapter re-anchored (slice-2, #20)** — + `end_user_id`, `bifrost` binding (consumer-key via SDK per-request auth), `config` passthrough; 404→AgentNotFound, bound-502→BifrostHandshakeFailed. Ephemeral-template (Echo) create (#19) carried through the adapter. Depth enhancement to an already-covered route — count unchanged |
| `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}/messages` (turn stream, SSE) | ✅ | `wt.py` `stream_turn` (SDK resilient `sessions.stream_turn`, auto-resume) → cli/web | **wt-adapter re-anchored (slice-2, #20)** the primary surface; 409→AgentNotAvailable, 503→TurnLaunchUnavailable, drop→SseConnectionDropped, protocol→same-named; absorbs the old `reconnect_turn` |
| `POST /sessions/{id}/history` (authored-history-write, #347) | ✅ | `sessions.py:583` `write_authored_history``cli.py:758` `--seed-first-message` | v1: author=assistant, effects=none, per-session idempotency; 404→AuthoredHistoryUnavailable (hide-existence: feature-absent, never probe); 409/422 mapped. **LIVE-PROVEN 2026-07-06** on personal :8081 (grant applied via a rule-based Heimdall allow, worldtree-dev): create mimir session → seed → **201** (seq=0, phase=seeded, turn_id=1798) → GET /messages reads it back as a plain role=assistant turn (model-invisible provenance confirmed). Hide-404 for ungranted is unit+probe covered | | `POST /sessions/{id}/history` (authored-history-write, #347) | ✅ | `wt.py` `write_authored_history` (SDK `sessions.write_history`)`cli.py` `--seed-first-message`, `first_message.py` `seed_preset_first_message` (create-path seed) | **wt-adapter re-anchored (slice-3, #20)** — SDK owns the entry shape; v1 author=assistant; 404→AuthoredHistoryUnavailable (hide-existence, route is the discriminator, never probe); 409/422→SessionApiFailed default. **LIVE-SMOKE 2026-07-19** on personal :8081 (b128): `--seed-first-message` on a sindra session → **201** (seq=0, phase=seeded, turn_id=2294) → read-back verbatim; create-path preset seed observed via `--new`. (Prior 2026-07-06 hand-rolled proof superseded.) |
| `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) | | `GET /sessions/{id}/messages` (history) | ✅ | `wt.py` `get_session_messages` (SDK `sessions.messages`)`cli.py` `--seed-first-message` read-back, `web/server.py` messages proxy | **wt-adapter re-anchored (slice-3, #20)** — the #347 seed read-back; open-world passthrough. **LIVE-SMOKE 2026-07-19**: read-back rendered the seeded seq-0 turn as a plain role=assistant message (model-invisible provenance confirmed) |
| `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `sse_client.py:581` → cli/tui/web | two-stage Ctrl-C; 404/409 mapped | | `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `wt.py` `cancel_turn` (SDK `sessions.cancel_turn`) → cli/web | **wt-adapter re-anchored (slice-2, #20)** — two-stage Ctrl-C; 404→CancelTurnNotFound, 409→CancelAlreadyCompleted, late-cancel 200 (`cancelled=False`) is a benign result, not an error |
| `GET /agents` | ✅ | `sessions.py:341``tui.py:1472`,`web/server.py:100` | Tier-1 roster; merged with local index | | `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 | | `GET /agents/{id}/persona_state` | ✅ | `sessions.py:384``tui.py:1132`,`web/server.py:386` | persona hydrate; 404/403 mapped |
| `POST /agents/define` | ✅ | `tier3.py:175``_run_define` | Tier-3 create | | `POST /agents/define` | ✅ | `tier3.py:175``_run_define` | Tier-3 create |
@@ -103,7 +103,7 @@ sub-gap).
| `POST /characters` | ✅ | `sessions.py` `create_character``cli.py` `--characters` | create transient character (#161) | | `POST /characters` | ✅ | `sessions.py` `create_character``cli.py` `--characters` | create transient character (#161) |
| `GET /characters/{id}/state` | ✅ | `sessions.py` `get_character_state``cli.py` `--characters` | live character PAD/emotions (#161) | | `GET /characters/{id}/state` | ✅ | `sessions.py` `get_character_state``cli.py` `--characters` | live character PAD/emotions (#161) |
| `DELETE /characters/{id}` | ✅ | `sessions.py` `delete_character``cli.py` `--characters` | remove transient character (#161) | | `DELETE /characters/{id}` | ✅ | `sessions.py` `delete_character``cli.py` `--characters` | remove transient character (#161) |
| `POST /sessions/{id}/persona_state` | ✅ | `sessions.py` `set_persona_state``cli.py` `--set-persona-pad` | persona-state write / affect injection (freeform body — unpinned in the frozen surface) | | `POST /sessions/{id}/persona_state` | ✅ | `wt.py` `set_persona_state` (SDK `sessions.set_persona_state`, `PadState`)`cli.py` `--set-persona-pad` | **wt-adapter re-anchored (slice-3, #20)** — SDK owns the canonical `{"pad": {...}}` wire (#317); CLI passes the 3 PAD axes (finiteness pre-validated); 204→None, else SessionApiFailed default. **LIVE-SMOKE 2026-07-19** on personal :8081: `--set-persona-pad 0.4,0.1,-0.2`**204** |
**Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method **Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method
on the same path is an unwired frontier item — see frontier Tier 1): on the same path is an unwired frontier item — see frontier Tier 1):
@@ -0,0 +1,57 @@
`[2026-07-19]` **worldtree-sdk cutover — SLICE-1 + SLICE-2 COMPLETE + PUSHED (origin `aba1730`, v0.21.10).**
The consumer client layer's biggest, riskiest slice (sessions/turn) is fully migrated onto
`worldtree-sdk (Python) 1.0.0` behind the `ratatoskr.wt` adapter, run end-to-end through the House Code
Discipline. Pushed 2026-07-19 (12-commit arc `b1fbadd``aba1730`, tags v0.21.3.10). Suite **497 green**.
## The arc (all on origin/main)
| Part | Commit | What |
|---|---|---|
| Slice-1 foundation | `12cd864` (v0.21.3) | `build_client` + `translate_error` DEFAULT (ApiError→SessionApiFailed) + passthrough; INV-CUT-1 (injected transport, `_owns_client=False`) test-proven |
| Slice-2 adapter (read/create) | `bb158ae` (v0.21.4) | create/list/messages/tools over `client.sessions.*`, per-route error mapping |
| Slice-2 adapter (stream/cancel) | `b907a7b` (v0.21.5) | resilient `stream_turn` + `cancel_turn`; SDK stream errors re-wrapped → ratatoskr caller-semantic exceptions (DEC-2) |
| Slice-2 CLI rewire | `e3a10ad` (v0.21.6) | `_amain`/`_run_turn`/render onto SDK `TurnEvent`s + open dicts |
| Slice-2 web rewire | `5c595b8` (v0.21.7) | Starlette endpoints + SSE→JSON serialization; browser contract preserved |
| Slice-2 deletion | `59602fe` (v0.21.8) | sse_client 714→224, sessions 677→608; orphaned turn-stream family + Event model removed; **DEC-4 live-smoke PASSED first** |
| heid-code-review fixup | `74d41eb` (v0.21.9) | INV-CUT-2 completeness (cancel/stream ApiError default) + contract error-map amendments |
| heid-bug-hunt fixup | `aba1730` (v0.21.10) | 4 confirmed bugs + open-world render hardening |
## KEY ADAPTER FACTS (foot-guns for slices 3-7)
- **The SDK returns open-world dicts** (`Mapping`, NOT typed objects) for session reads → presenters read
mappings (`info["session_id"]`), never attributes. Ratatoskr's typed `SessionInfo`/`SessionPage` retired.
- **SDK `TurnEvent` shape:** `sse_id: str` (the composite `"{turn}:{seq}"`) + a **body-derived `turn_id`
that is ABSENT (None) on text/thinking frames.** The mid-stream cancel target must parse the turn from
`sse_id`, NOT read `event.turn_id`. (This was a live bug — sigint-cancel saw "no event".)
- **The SDK normalizes transport failures to `ConnectFailed(status=0)`** (not raw httpx errors) → presenters
catch `wtsdk.ConnectFailed` for network paths; a stream `ConnectFailed` maps → `SseConnectFailed`.
- **The adapter re-wraps SDK stream errors → ratatoskr's caller-semantic exceptions** (DEC-2 keeps ratatoskr's
typed exceptions; SessionRetired→SessionApiFailed, ConnectionDropped→SseConnectionDropped, Malformed*/
TurnIdFlip→same-named, ConnectFailed/terminal ResumeError→SseConnectFailed).
- **`consumer_key` is BOUND-create-only** — the adapter nulls it when `bifrost is None`, else the SDK's
credential precedence auths as the consumer instead of the default bearer.
- **The SDK's error-envelope parser PREFERS the nested `detail`** dict when present, so a top-level
`error_code` doesn't surface. That's why create's bound-502 is NOT gated on error_code (unlike list's
422+cursor_invalid, whose flat body surfaces the code) — INV-002 also makes the handshake the sole
bound-502 cause. This is a genuine cross-frontier triage win: 2/3 heid arms flagged the missing gate; it
was correctly REJECTED as category-5 wrong-grounding (the SDK parser wasn't in the arms' file set).
## The two heid gates (the value proof)
- **heid-code-review** (Gróa+Hulda substantive, Regin zero=weak): adopted the cancel/stream ApiError-default
completeness + error-map table amendments; correctly rejected the bound-502-gate finding (above).
- **heid-bug-hunt** (Gróa 8 / Hulda 6 / Regin 6; Heid source-checked, refuted 2 Regin FPs): caught **4 real
confirmed bugs the conformance lens structurally could not see** — SessionRetired(410) uncaught by both
presenters (crash/dropped-stream), cli consumer_key forwarded on unbound create (auth divergence), sse_id
None-crash, cancel never-raise gap — plus open-world render hardening. Rejected 4 as verified FPs (incl.
Hulda's "deleted funcs break callers" — grep-verified zero callers pre-deletion). Both surfaces re-smoked
live after fixup: create+stream+cancel round-trip clean on :8081 (b128).
## What's still hand-rolled (later slices)
`create_session`/`get_session_messages` (the `--seed-first-message` probe uses them — retire in slice-3);
`set_persona_state`/`write_authored_history`/`first_message` (slice-3); agents/tier3 incl. `model``role`
(slice-4, deploy live b128); characters/me/capabilities/models (slice-5); `stream_admin_events`/
`get_session_bifrost` (slice-6). Slice-7 teardown: retire contracts #2/#15, drop `httpx-sse`, minor bump
(DEC-6, operator approval). Full design → auto-memory `project_worldtree_sdk_cutover`.
+38 -34
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr # Persistent memory — ratatoskr
_Last updated: 2026-07-18_ _Last updated: 2026-07-19_
> **Always check for `/tmp/ratatoskr-dev-handoff.md`** — if it exists and its > **Always check for `/tmp/ratatoskr-dev-handoff.md`** — if it exists and its
> `Written:` stamp is under an hour old, read it (it carries the in-flight > `Written:` stamp is under an hour old, read it (it carries the in-flight
@@ -44,34 +44,38 @@ upstream API key stays server-side (INV-003).
## Current state / in-flight ## Current state / in-flight
_As of 2026-07-18 (evening):_ _As of 2026-07-19:_
**🔨 ACTIVE MIGRATION — worldtree-sdk cutover (issue #20), starting slice-1.** Operator ruled ADOPT **🔨 ACTIVE MIGRATION — worldtree-sdk cutover (issue #20): SLICE-1 + SLICE-2 COMPLETE + PUSHED, slice-3 next.**
(2026-07-18): ratatoskr cuts its CONSUMER client layer over to consume **worldtree-sdk (Python) 1.0.0** Operator ruled ADOPT (2026-07-18): ratatoskr cuts its CONSUMER client layer over to **worldtree-sdk (Python)
retire the hand-rolled httpx wrappers (`sessions`/`sse_client`/`tier3`) behind a thin `ratatoskr.wt` 1.0.0**, retiring the hand-rolled httpx wrappers behind a thin `ratatoskr.wt` adapter. Design locked (6 DECs,
adapter over the SDK. Both TS + Python SDK 1.0.0 are GA (**Python live + pip-installable on the gitea vor-cross'd, heid-panel-reviewed); contract `docs/contracts/worldtree_sdk_cutover.contract.md`. **SLICE-1
PyPI** — DEC-5 gate cleared). Ratatoskr's own **parity pass shaped the Python spine** (open-world reads, (adapter foundation) ✅ + SLICE-2 (sessions/turn) ✅ DONE + PUSHED** — origin at `aba1730` (12-commit arc
caller-injected transport, per-wire role/model). Design locked (6 DECs, vor-cross'd with worldtree-codex, `b1fbadd``aba1730`, tags v0.21.3.10, pushed 2026-07-19): adapter (`b907a7b`, all 6 route families
heid-panel-reviewed → error-map table added); contract `docs/contracts/worldtree_sdk_cutover.contract.md` create/list/messages/tools/stream/cancel + the § Error-map mapping) → CLI rewire (`e3a10ad`) → web rewire
(committed `e45640c`). **SLICE-1 IN PROGRESS:** ✅ dep integrated + DEC-5 verified + committed (`29c4fda`) — `worldtree-sdk==1.0.0` (`5c595b8`) → orphan deletion (`59602fe`, sse_client 714→224 + sessions 677→608; DEC-4 LIVE-SMOKE PASSED on
installs from the gitea registry (reuses bifrost's index auth, NO new token; core dep + `[tool.uv.sources]`), :8081 create+stream+cancel FIRST) → heid-code-review fixup (`74d41eb`) → heid-bug-hunt fixup (`aba1730`).
`WorldtreeClient` constructs with an injected transport (`_owns_client=False`, INV-CUT-1 confirmed live), Suite **497 green**. **KEY ADAPTER FACTS (foot-guns for slices 3-7):** the SDK returns **open-world dicts**
suite 534 green. **NEXT = the adapter** `src/ratatoskr/wt.py` (TDD): `build_client(base_url, *, api_key, (not typed objects) for session reads → presenters read mappings, `info["session_id"]`; SDK events
admin_key, transport)``WorldtreeClient(auth=, admin_auth=, transport=)` (DESIGN CARE: SDK does per-request (`TurnEvent`) carry `sse_id: str` + a **body-derived `turn_id` that's ABSENT on text/thinking frames** → parse
auth via the providers; our injected `httpx.AsyncClient` carries base_url/UA/timeout, NOT the Authorization the cancel target's turn from the composite `sse_id`, NOT `event.turn_id`; the SDK **normalizes transport
header — read the SDK `client.py` @ `~/development/worldtree-sdk` for the split, INV-CUT-1) + `translate_error` failures to `ConnectFailed(status=0)`** (not raw httpx) → presenters catch it; the adapter re-wraps SDK stream
DEFAULT (SDK `ApiError``SessionApiFailed`, discriminated `WorldtreeError` subclasses passthrough; route-specific errors → ratatoskr caller-semantic exceptions (DEC-2, keep ratatoskr's typed exceptions); `consumer_key` is
rows come in later slices). Unit-test; NO surface wiring (slice-2). Then slices 2-7 (route-family + deletions, BOUND-create-only (adapter nulls it when unbound, else the SDK auths as the consumer); the SDK's error-envelope
live-smoke per slice). Scope: consumer layer ONLY; parser **prefers the nested `detail`** so a top-level `error_code` doesn't surface (why create's bound-502 is
Bifrost provider planes untouched. Multi-session grind. Full design → auto-memory NOT gated on error_code, unlike list's 422). **NEXT = slice-3** (persona `set_persona_state` + authored-history
`project_worldtree_sdk_cutover`. This SUPERSEDES the #371 "repin rides the later Python milestone" framing `write_history` + first_message presets — retires the still-hand-rolled `create_session`/`get_session_messages`
below (that milestone shipped; we're adopting, not just repinning). that the `--seed-first-message` probe uses); then slice-4 (agents/tier3, FOLDS the `model``role` cutover),
slice-5 (characters/me/caps), slice-6 (admin — `stream_admin_events`+`get_session_bifrost` still hand-rolled),
slice-7 (teardown: retire contracts #2/#15, drop `httpx-sse`, minor bump per DEC-6 w/ operator approval).
Scope: consumer layer ONLY; Bifrost provider planes untouched. Full design → auto-memory
`project_worldtree_sdk_cutover`.
**⏸️ DEFERRED — tier3 agents `model``role` (scope B), on worldtree-dev's deploy flag.** WT renames the **⏸️ DEFERRED — tier3 agents `model``role` (scope B), folds into cutover slice-4.** WT renamed the
agents-RESPONSE selector `model``role` (spec 1.2, commit `387c67b`, NOT yet deployed). `_parse_tier3_agent_info` agents-RESPONSE selector `model``role` (spec 1.2). **DEPLOY NOW LIVE** on :8080/:8081 (v1.0.0b128,
reads `body["model"]` → KeyErrors post-deploy. Operator chose scope B (full tier3 `model``role` incl. worldtree-dev confirmed 2026-07-19 — was the deploy-flag gate; acked). Operator chose scope B (full tier3
contract #15 + CLI `--model``--role`). Implement ON the deploy flag, not before (breaks the current demo); `model``role` incl. contract #15 + CLI `--model``--role`); lands in cutover slice-4 where tier3.py routes
folds into cutover slice-4. Auto-memory `project_tier3_agents_model_to_role_pending`. through the SDK (doing it standalone now = throwaway). Auto-memory `project_tier3_agents_model_to_role_pending`.
**✅ RESOLVED — the "app product" workstreams leave Rata entirely (operator 2026-07-18).** **✅ RESOLVED — the "app product" workstreams leave Rata entirely (operator 2026-07-18).**
**No arbo fork, no SillyTavern-on-Rata** — a NEW repo (template-dev standing up) takes over BOTH **No arbo fork, no SillyTavern-on-Rata** — a NEW repo (template-dev standing up) takes over BOTH
@@ -115,13 +119,12 @@ Full record → `persistent-memory.d/2026-07-18-368-silo-test-passed.md`. Siblin
(2) R39 Phase-2 **matched-quartets rebuild** (confirmatory, "whenever"); (3) bifrost **snapshot-cursor (2) R39 Phase-2 **matched-quartets rebuild** (confirmatory, "whenever"); (3) bifrost **snapshot-cursor
adoption** (ruled normative, not blocking → `persistent-memory.d/2026-07-16-bifrost-cursor-conformance.md`). adoption** (ruled normative, not blocking → `persistent-memory.d/2026-07-16-bifrost-cursor-conformance.md`).
**Substrate / environment:** branch `main` at **v0.21.2**. **origin at `b1fbadd`** (pushed 2026-07-18: **Substrate / environment:** branch `main` at **v0.21.10**, **fully PUSHED to origin at `aba1730`** (slice-2
canonical syncs `5d06a27`/`80c8d58`, ephemeral-Echo `c7016f2` #19, coverage-map→SDK-surface `b1fbadd`). cutover arc `b1fbadd``aba1730` + tags v0.21.3.10 pushed 2026-07-19). origin
**UNPUSHED local commits** (cutover work — operator hasn't pushed): cutover contract `e45640c` #20, the `git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep: `worldtree-sdk==1.0.0`** (gitea PyPI,
`memory:` snapshot, dep-integration `29c4fda` (worldtree-sdk==1.0.0), + this snapshot — **push is the `[tool.uv.sources]`; `httpx-sse` retires at slice-7). bifrost **`==1.1.4`** / wire v0.7; WT openapi vendored
operator's call**. origin `git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep: `worldtree-sdk==1.0.0`** 2.3.0, **conversation-api-spec re-synced to v1.1** (`b4a278c`); **suite 497 green** (was 534 pre-cutover; net
(gitea PyPI, `[tool.uv.sources]`). bifrost **`==1.1.4`** / wire v0.7; WT openapi vendored 2.3.0, delta = adapter/rewire tests added, ~80 deleted hand-rolled turn-stream tests). Personal WT on **b128**
**conversation-api-spec re-synced to v1.1** (`b4a278c`); **suite 534 green**. Personal WT on **b127**
(`http://10.250.50.152:8081`; #368 silo + #364 promotion-hygiene live both instances). The combined (`http://10.250.50.152:8081`; #368 silo + #364 promotion-hygiene live both instances). The combined
**:8392** provider (memory+affect) + **:8765** web are THE surfaces, dev-box BACKGROUND SHELLS — **:8392** provider (memory+affect) + **:8765** web are THE surfaces, dev-box BACKGROUND SHELLS —
restart via `scratchpad/relaunch_by_pid.py <pid>` (pid via `ss -ltnp | grep <port>`). `env.sh` sets restart via `scratchpad/relaunch_by_pid.py <pid>` (pid via `ss -ltnp | grep <port>`). `env.sh` sets
@@ -271,6 +274,7 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-07-18]` **ephemeral-template (Echo) create SHIPPED (`v0.21.2`, `c7016f2`, #19)**`config` passthrough + `--system-prompt` + `SessionInfo.kind/config` + `--whoami` roles fix. Diagnosed from the ignored `session_api_failed` startup line; role/model drift resolved w/ worldtree-dev (spec re-synced v1.1). TDD + heid contract-review + bug-hunt. - `[2026-07-18]` **ephemeral-template (Echo) create SHIPPED (`v0.21.2`, `c7016f2`, #19)**`config` passthrough + `--system-prompt` + `SessionInfo.kind/config` + `--whoami` roles fix. Diagnosed from the ignored `session_api_failed` startup line; role/model drift resolved w/ worldtree-dev (spec re-synced v1.1). TDD + heid contract-review + bug-hunt.
- `[2026-07-18]` **Rata = THE reference consumer of the worldtree-sdk Python spine** — parity pass (12 grounded findings) shaped its contract BEFORE build (open-world reads, caller-injected transport, per-wire role/model — adopted); coverage-map re-anchored to the SDK's 41-op ratified surface (`b1fbadd`); 4 ergonomics items parked post-v1 with wtsdk-dev. - `[2026-07-18]` **Rata = THE reference consumer of the worldtree-sdk Python spine** — parity pass (12 grounded findings) shaped its contract BEFORE build (open-world reads, caller-injected transport, per-wire role/model — adopted); coverage-map re-anchored to the SDK's 41-op ratified surface (`b1fbadd`); 4 ergonomics items parked post-v1 with wtsdk-dev.
- `[2026-07-18]` **worldtree-sdk cutover DECIDED — adopt the Python SDK for the consumer client layer** (operator, overriding "stay hand-rolled"). Issue #20; contract `docs/contracts/worldtree_sdk_cutover.contract.md` (`e45640c`, vor-cross'd + heid-reviewed); auto-memory `project_worldtree_sdk_cutover`. Consumer layer only, Bifrost provider untouched; slice-1 foundation next. See in-flight. - `[2026-07-18]` **worldtree-sdk cutover DECIDED — adopt the Python SDK for the consumer client layer** (operator, overriding "stay hand-rolled"). Issue #20; contract `docs/contracts/worldtree_sdk_cutover.contract.md` (`e45640c`, vor-cross'd + heid-reviewed); auto-memory `project_worldtree_sdk_cutover`. Consumer layer only, Bifrost provider untouched; slice-1 foundation next. See in-flight.
- `[2026-07-19]` **worldtree-sdk cutover SLICE-1 + SLICE-2 COMPLETE + PUSHED (origin `aba1730`, v0.21.10, 497 green).** The biggest, riskiest cutover slice done end-to-end through the full House Code Discipline (adapter→cli→web→delete→live-smoke→both heid gates); the bug-hunt caught 4 real confirmed bugs the conformance lens couldn't, and a convergent code-review finding was correctly REJECTED as category-5 (SDK envelope-parser behavior). Slice-3 next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-1-2-complete.md`
_65 older entries (2026-05-* debug-TUI/web era + the 2026-06-14 → 06-18 Bifrost-provider build / #17+#18 / #295-296 era) archived to archival-memory.md._ _65 older entries (2026-05-* debug-TUI/web era + the 2026-06-14 → 06-18 Bifrost-provider build / #17+#18 / #295-296 era) archived to archival-memory.md._
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ratatoskr" name = "ratatoskr"
version = "0.21.10" version = "0.21.12"
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability" description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+50 -23
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import hashlib import hashlib
import math
import os import os
import signal import signal
import sys import sys
@@ -44,16 +45,12 @@ from ratatoskr.sessions import (
BifrostHandshakeFailed, BifrostHandshakeFailed,
SessionApiFailed, SessionApiFailed,
create_character, create_character,
create_session,
delete_character, delete_character,
endpoint_for_plane, endpoint_for_plane,
get_capabilities, get_capabilities,
get_character_state, get_character_state,
get_me, get_me,
get_session_messages,
list_character_models, list_character_models,
set_persona_state,
write_authored_history,
) )
# The turn path (create / stream / cancel) is served by the worldtree-sdk adapter # The turn path (create / stream / cancel) is served by the worldtree-sdk adapter
@@ -719,8 +716,8 @@ async def _amain(args: ParsedArgs) -> int:
f"agent_id={info['agent_id']}{kind_suffix}\n" f"agent_id={info['agent_id']}{kind_suffix}\n"
) )
# #347 authored first-message: seed the agent's preset opening (best-effort). # #347 authored first-message: seed the agent's preset opening (best-effort).
# Uses the transport directly — first_message is a slice-3 hand-rolled path. # Routed through the wt adapter (slice-3); the seed never blocks create.
if await seed_preset_first_message(transport, session_id, args.agent_id): if await seed_preset_first_message(client, session_id, args.agent_id):
sys.stderr.write( sys.stderr.write(
f". first_message: seeded preset opening for {args.agent_id}\n" f". first_message: seeded preset opening for {args.agent_id}\n"
) )
@@ -882,16 +879,34 @@ async def _set_persona_probe(args: ParsedArgs) -> int:
"(pleasure,arousal,dominance), e.g. '0.4,0.1,-0.2'\n" "(pleasure,arousal,dominance), e.g. '0.4,0.1,-0.2'\n"
) )
return 10 return 10
# Canonical POST /sessions/{id}/persona_state body (#317): a named-key dict, # The SDK rejects a non-finite axis pre-HTTP (a NaN/Infinity would serialize to
# NOT a bare list — {"pad": {"pleasure", "arousal", "dominance"}}. # null and corrupt the injection). Reject it here as a usage error so the probe
snapshot = {"pad": {"pleasure": pad[0], "arousal": pad[1], "dominance": pad[2]}} # surfaces a clean message instead of crashing on the SDK's ConfigurationError.
async with _probe_client(args) as client: if not all(math.isfinite(x) for x in pad):
sys.stderr.write(
"[usage_error] --set-persona-pad values must be finite floats (no nan/inf)\n"
)
return 10
async with _probe_client(args) as transport:
client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport)
try: try:
await set_persona_state(client, args.session_id, snapshot) # The SDK owns the canonical {"pad": {...}} wire body (#317); ratatoskr
except SessionApiFailed as exc: # passes the three PAD axes and no longer hand-builds the snapshot.
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n") await wt.set_persona_state(
client, args.session_id, pleasure=pad[0], arousal=pad[1], dominance=pad[2]
)
except wt.SessionApiFailed as exc:
sys.stderr.write(
f"[session_api_failed] status={exc.status} "
f"error_code={exc.error_code!r} body={exc.body!r}\n"
)
return 20 return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: except (
httpx.ConnectError,
httpx.ReadTimeout,
httpx.TransportError,
ConnectFailed, # SDK normalizes a pre-response transport failure here
) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21 return 21
sys.stdout.write( sys.stdout.write(
@@ -914,19 +929,23 @@ async def _seed_first_message_probe(args: ParsedArgs) -> int:
""" """
assert isinstance(args, ParsedArgs) assert isinstance(args, ParsedArgs)
assert args.agent_id is not None and args.seed_first_message is not None assert args.agent_id is not None and args.seed_first_message is not None
async with _probe_client(args) as client: async with _probe_client(args) as transport:
client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport)
try: try:
session = await create_session( # wt.create_session returns the SDK's open-world create dict; read as a
# mapping (no SessionInfo dataclass — the hand-rolled path is retired).
session = await wt.create_session(
client, args.agent_id, end_user_id=args.end_user_id client, args.agent_id, end_user_id=args.end_user_id
) )
sys.stdout.write(f"session: {session.session_id} (agent {session.agent_id})\n") session_id = session["session_id"]
sys.stdout.write(f"session: {session_id} (agent {session.get('agent_id')})\n")
key = "ratatoskr-first-message-" + hashlib.sha256( key = "ratatoskr-first-message-" + hashlib.sha256(
args.seed_first_message.encode("utf-8") args.seed_first_message.encode("utf-8")
).hexdigest()[:12] ).hexdigest()[:12]
try: try:
ack = await write_authored_history( ack = await wt.write_authored_history(
client, client,
session.session_id, session_id,
content=args.seed_first_message, content=args.seed_first_message,
idempotency_key=key, idempotency_key=key,
) )
@@ -941,17 +960,25 @@ async def _seed_first_message_probe(args: ParsedArgs) -> int:
f"seeded: seq={ack.get('seq')} phase={ack.get('phase')} " f"seeded: seq={ack.get('seq')} phase={ack.get('phase')} "
f"turn_id={ack.get('turn_id')} content_chars={ack.get('content_chars')}\n" f"turn_id={ack.get('turn_id')} content_chars={ack.get('content_chars')}\n"
) )
history = await get_session_messages(client, session.session_id) history = await wt.get_session_messages(client, session_id)
items = history.get("items", []) items = history.get("items", [])
sys.stdout.write(f"read-back: {len(items)} message(s)\n") sys.stdout.write(f"read-back: {len(items)} message(s)\n")
for m in items: for m in items:
sys.stdout.write( sys.stdout.write(
f" seq={m.get('seq')} role={m.get('role')} content={m.get('content')!r}\n" f" seq={m.get('seq')} role={m.get('role')} content={m.get('content')!r}\n"
) )
except SessionApiFailed as exc: except wt.SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n") sys.stderr.write(
f"[session_api_failed] status={exc.status} "
f"error_code={exc.error_code!r} body={exc.body!r}\n"
)
return 20 return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: except (
httpx.ConnectError,
httpx.ReadTimeout,
httpx.TransportError,
ConnectFailed, # SDK normalizes a pre-response transport failure here
) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21 return 21
return 0 return 0
+6 -6
View File
@@ -14,12 +14,12 @@ blocked (the session simply opens with no seeded greeting). See
import asyncio import asyncio
import hashlib import hashlib
import httpx from worldtree_sdk import WorldtreeClient
from ratatoskr.sessions import write_authored_history from ratatoskr import wt
# Cap the best-effort seed write. The CLI/TUI create paths reuse an httpx client # Cap the best-effort seed write. The CLI create path reuses a transport with NO
# with NO read timeout (it streams SSE turns), so an accepted-but-never-answered # read timeout (it streams SSE turns), so an accepted-but-never-answered
# POST /history would otherwise block session creation forever — violating INV-001's # POST /history would otherwise block session creation forever — violating INV-001's
# "never block". asyncio.wait_for bounds the seed regardless of the client's timeout. # "never block". asyncio.wait_for bounds the seed regardless of the client's timeout.
_SEED_TIMEOUT_S = 10.0 _SEED_TIMEOUT_S = 10.0
@@ -39,7 +39,7 @@ def preset_for(agent_id: str) -> str | None:
async def seed_preset_first_message( async def seed_preset_first_message(
client: httpx.AsyncClient, session_id: str, agent_id: str client: WorldtreeClient, session_id: str, agent_id: str
) -> str | None: ) -> str | None:
"""Best-effort: seed ``agent_id``'s preset opening as a #347 authored """Best-effort: seed ``agent_id``'s preset opening as a #347 authored
first-message on ``session_id``; return the seeded text, or None. first-message on ``session_id``; return the seeded text, or None.
@@ -64,7 +64,7 @@ async def seed_preset_first_message(
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12] key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
try: try:
await asyncio.wait_for( await asyncio.wait_for(
write_authored_history( wt.write_authored_history(
client, session_id, content=content, idempotency_key=key client, session_id, content=content, idempotency_key=key
), ),
timeout=_SEED_TIMEOUT_S, timeout=_SEED_TIMEOUT_S,
+2 -233
View File
@@ -5,41 +5,12 @@ Implements docs/contracts/issues/2.contract.md.
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
import httpx import httpx
@dataclass(frozen=True)
class SessionInfo:
"""Worldtree session envelope; shared shape for create + list responses.
INV-001 / INV-002: origin-conditional defaults — `create_session` sets fixed
`name=None`, `archived=False`, `tags=[]`; `list_sessions` populates from the
response item with the same absent/null defaults but `message_count=None`.
Amendment 2026-07-18 (issue #161): `kind` ("ephemeral" | "foundational") and
`config` (the frozen ephemeral config, create-origin only) captured defensively
via `.get()` — both `None` on a pre-cutover server that omits them.
"""
session_id: str
agent_id: str
created_at: str
last_active: str
metadata: dict[str, Any]
message_count: int | None
name: str | None
archived: bool
tags: list[str]
kind: str | None = None
config: dict[str, Any] | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
class AgentInfo: class AgentInfo:
"""Worldtree agent envelope from GET /agents (issue #8). """Worldtree agent envelope from GET /agents (issue #8).
@@ -198,8 +169,6 @@ class AuthoredHistoryUnavailable(Exception):
self.session_id = session_id self.session_id = session_id
def endpoint_for_plane(plane: str, base_host: str) -> str: def endpoint_for_plane(plane: str, base_host: str) -> str:
"""Map a provider plane name to its Worldtree-VISIBLE base URL. """Map a provider plane name to its Worldtree-VISIBLE base URL.
@@ -213,113 +182,10 @@ def endpoint_for_plane(plane: str, base_host: str) -> str:
""" """
ports = {"memory": 8391, "affect": 8390, "combined": 8392} ports = {"memory": 8391, "affect": 8390, "combined": 8392}
if plane not in ports: if plane not in ports:
raise ValueError( raise ValueError(f"unknown plane: {plane!r} (expected 'memory', 'affect', or 'combined')")
f"unknown plane: {plane!r} "
"(expected 'memory', 'affect', or 'combined')"
)
return f"http://{base_host}:{ports[plane]}" return f"http://{base_host}:{ports[plane]}"
def _bifrost_error_from(resp: httpx.Response) -> str | None:
"""Pull the spec-level `bifrost_error` from a 502 body.
Tolerates both the FastAPI-nested `{"detail": {"bifrost_error": …}}` shape
(the spec's documented form, §"Optional Bifrost binding") and a flat
top-level `bifrost_error`, per the both-shape unwrap precedent established for
persona_state errors (the real wire returns the detail-nested form).
"""
try:
err = resp.json()
except ValueError:
return None
if not isinstance(err, dict):
return None
bifrost_error = err.get("bifrost_error")
if bifrost_error is None and isinstance(err.get("detail"), dict):
bifrost_error = err["detail"].get("bifrost_error")
return bifrost_error
async def create_session(
client: httpx.AsyncClient,
agent_id: str,
*,
end_user_id: str | None = None,
bifrost: BifrostBinding | None = None,
consumer_key: str | None = None,
config: Mapping[str, Any] | None = None,
) -> SessionInfo:
"""POST /sessions to create a new session. See contract FN create_session.
Per issue #5: pass `end_user_id` for per-end-user agents (lofn etc.).
When None (default), the body shape matches the pre-#5 baseline
`{"agent_id": agent_id}` so existing callers (mimir smoke) are unaffected.
Empty-string `end_user_id` is rejected before HTTP (PRE-003).
Per issue #17: when `bifrost` is set the request carries the binding and
authenticates with `consumer_key` (NOT the client's default canary bearer);
Worldtree handshakes synchronously to our provider before 201.
"""
assert client is not None
assert agent_id and isinstance(agent_id, str)
assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id)
# PRE-004 (issue #161): config is None or a Mapping.
assert config is None or isinstance(config, Mapping)
# PRE-005 (issue #161): ephemeral config + Bifrost binding are mutually
# exclusive (server would 422 ephemeral_does_not_accept_bifrost). The CLI
# guards this at arg-parse; this assert is defense-in-depth.
assert not (config is not None and bifrost is not None)
# PRE-001 (INV-001): a bifrost binding REQUIRES a non-empty consumer key,
# enforced before any HTTP so a bound create never falls back to the canary.
if bifrost is not None and not (isinstance(consumer_key, str) and consumer_key):
raise BifrostConsumerKeyMissing()
body: dict[str, Any] = {"agent_id": agent_id}
if end_user_id is not None:
body["end_user_id"] = end_user_id
# Issue #161: ephemeral-template config passthrough — verbatim, role/model-
# agnostic. The caller (CLI) builds {"system_prompt": ...}; the wrapper never
# injects a selector. Absent when None (foundational baseline unchanged).
if config is not None:
body["config"] = dict(config)
headers: dict[str, str] = {}
if bifrost is not None:
body["bifrost"] = {
"endpoint_url": bifrost.endpoint_url,
"scope": bifrost.scope,
}
# INV-001: the bound create authenticates with the consumer key,
# overriding the httpx client's default canary bearer per-request.
headers["Authorization"] = f"Bearer {consumer_key}"
resp = await client.post("/sessions", json=body, headers=headers)
if resp.status_code == 404:
raise AgentNotFound(agent_id=agent_id)
# POST-002 (INV-002): a 502 on a BOUND create is the synchronous Bifrost
# handshake failing. Gated on `bifrost is not None` — an unbound create's
# 502 is a generic upstream fault and stays SessionApiFailed.
if bifrost is not None and resp.status_code == 502:
raise BifrostHandshakeFailed(
bifrost_error=_bifrost_error_from(resp), body=resp.content
)
if resp.status_code != 201:
raise SessionApiFailed(status=resp.status_code, body=resp.content)
body = resp.json()
return SessionInfo(
session_id=body["session_id"],
agent_id=body["agent_id"],
created_at=body["created_at"],
last_active=body["last_active"],
metadata=body.get("metadata", {}),
message_count=body["message_count"],
name=None,
archived=False,
tags=[],
kind=body.get("kind"), # INV-001 amendment (#161): "ephemeral"|"foundational"|None
config=body.get("config"), # frozen ephemeral config; None for foundational
)
async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]: async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
"""GET /agents — list available agents. See contract FN list_agents (issue #8). """GET /agents — list available agents. See contract FN list_agents (issue #8).
@@ -347,9 +213,7 @@ async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
] ]
async def get_persona_state( async def get_persona_state(client: httpx.AsyncClient, agent_id: str) -> dict[str, Any]:
client: httpx.AsyncClient, agent_id: str
) -> dict[str, Any]:
"""GET /agents/{agent_id}/persona_state — fetch current persona snapshot. """GET /agents/{agent_id}/persona_state — fetch current persona snapshot.
Worldtree #204 / v0.28.0. Returns the same `snapshot` dict shape as the Worldtree #204 / v0.28.0. Returns the same `snapshot` dict shape as the
@@ -473,27 +337,6 @@ async def delete_character(client: httpx.AsyncClient, character_id: str) -> None
raise SessionApiFailed(status=resp.status_code, body=resp.content) raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def set_persona_state(
client: httpx.AsyncClient, session_id: str, snapshot: dict[str, Any]
) -> None:
"""POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection).
The request body is FREEFORM on the wire (the OpenAPI declares no request
schema), but worldtree-dev's prose now pins the canonical shape (#317):
`{"pad": {"pleasure": p, "arousal": a, "dominance": d}}` — a named-key dict
(each in [-1, 1]), NOT a bare list; PAD-only, session-scoped, pull-over-push
(#289). The caller supplies the snapshot. 204 No Content → None; any other
status → SessionApiFailed.
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert isinstance(snapshot, dict)
resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot)
if resp.status_code == 204:
return None
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_session_bifrost( async def get_session_bifrost(
client: httpx.AsyncClient, session_id: str, *, admin_key: str client: httpx.AsyncClient, session_id: str, *, admin_key: str
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -519,8 +362,6 @@ async def get_session_bifrost(
raise SessionApiFailed(status=resp.status_code, body=resp.content) raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]: async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /capabilities — server capability discovery (spec §Ephemeral Templates). """GET /capabilities — server capability discovery (spec §Ephemeral Templates).
@@ -534,75 +375,3 @@ async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]:
if resp.status_code == 200: if resp.status_code == 200:
return resp.json() return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content) 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)
+1 -1
View File
@@ -9,7 +9,7 @@ posture (same as ratatoskr.sessions). Exposes three lifecycle operations:
Plus a frozen ``Tier3AgentInfo`` dataclass for the response shape. The picker Plus a frozen ``Tier3AgentInfo`` dataclass for the response shape. The picker
already handles colon-containing agent_ids generically (issue #8); session already handles colon-containing agent_ids generically (issue #8); session
creation works unchanged via ``ratatoskr.sessions.create_session``. creation works unchanged via ``ratatoskr.wt.create_session`` (worldtree-sdk cutover).
Spec reference: ``docs/conversation-api-spec.md`` §2576-2750 (Phase 2.0). Spec reference: ``docs/conversation-api-spec.md`` §2576-2750 (Phase 2.0).
""" """
+4 -4
View File
@@ -179,17 +179,17 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
try: try:
async with client_factory() as client: async with client_factory() as client:
wt_client = _wt_client(client)
info = await wt.create_session( info = await wt.create_session(
_wt_client(client), wt_client,
agent_id, agent_id,
end_user_id=end_user_id, end_user_id=end_user_id,
bifrost=bifrost, bifrost=bifrost,
consumer_key=consumer_key if bifrost else None, consumer_key=consumer_key if bifrost else None,
) )
# #347 authored first-message: seed the agent's preset opening (best-effort; # #347 authored first-message: seed the agent's preset opening (best-effort;
# never blocks create). first_message is a slice-3 hand-rolled path — it # never blocks create). Routed through the wt adapter (slice-3).
# reuses the raw transport (its default bearer), not the adapter client. await seed_preset_first_message(wt_client, info["session_id"], agent_id)
await seed_preset_first_message(client, info["session_id"], agent_id)
except AgentNotFound: except AgentNotFound:
return JSONResponse({"error_code": "agent_not_found"}, status_code=404) return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
except BifrostConsumerKeyMissing: except BifrostConsumerKeyMissing:
+78 -3
View File
@@ -28,19 +28,21 @@ carries the SDK's parsed `error_code`).
from __future__ import annotations from __future__ import annotations
import json import json
import math
from collections.abc import AsyncGenerator, Mapping from collections.abc import AsyncGenerator, Mapping
from typing import Any from typing import Any
import httpx import httpx
import worldtree_sdk as wtsdk import worldtree_sdk as wtsdk
from worldtree_sdk import ApiError, AuthProvider, CancelResult, WorldtreeClient from worldtree_sdk import ApiError, AuthProvider, CancelResult, PadState, WorldtreeClient
# Transitional (slice-2): the caller-semantic exceptions + the BifrostBinding input # Transitional (slice-2/3): the caller-semantic exceptions + the BifrostBinding input
# type still live in the retiring `sessions` / `sse_client` modules; they relocate # type still live in the retiring `sessions` / `sse_client` modules; they relocate
# into this adapter as their call-sites are rewired in later slice-2 commits. wt → # into this adapter as their call-sites are rewired in later slices. wt →
# sessions / sse_client is one-way (neither imports wt), so there is no cycle. # sessions / sse_client is one-way (neither imports wt), so there is no cycle.
from .sessions import ( from .sessions import (
AgentNotFound, AgentNotFound,
AuthoredHistoryUnavailable,
BifrostBinding, BifrostBinding,
BifrostConsumerKeyMissing, BifrostConsumerKeyMissing,
BifrostHandshakeFailed, BifrostHandshakeFailed,
@@ -342,3 +344,76 @@ async def cancel_turn(
except ApiError as exc: except ApiError as exc:
# INV-CUT-2 default: an undiscriminated ApiError on this route → SessionApiFailed. # INV-CUT-2 default: an undiscriminated ApiError on this route → SessionApiFailed.
raise translate_error(exc) from exc raise translate_error(exc) from exc
# ── slice-3: persona + authored-history adapter routes ───────────────────────
# The session-scoped affect write (set_persona_state) and the #347 authored-history
# write (write_authored_history). The SDK owns the wire shapes — the canonical
# `{"pad": {...}}` persona body via `PadState`, and the authored-write entry — so
# ratatoskr no longer hand-builds either. Error map (INV-CUT-2): persona has no row
# beyond the default; authored-history's 404 is the sole hide-existence route
# (AuthoredHistoryUnavailable), everything else the SessionApiFailed default.
async def set_persona_state(
client: WorldtreeClient,
session_id: str,
*,
pleasure: float,
arousal: float,
dominance: float,
) -> None:
"""Set a session's PAD persona state (POST /sessions/{id}/persona_state, W-7).
The adapter builds the canonical `PadState`; the SDK owns the wire wrapper
(`{"pad": {pleasure, arousal, dominance}}`, prose-pinned #317) — ratatoskr no
longer hand-assembles it. Resolves on 204 (→ None). Error map (INV-CUT-2): no
route-specific row → the `SessionApiFailed` default.
Finiteness is enforced HERE at the chokepoint (not only at the CLI): a
non-finite axis would serialize to `null` and corrupt the injection, and the SDK
raises `ConfigurationError` pre-HTTP — the precondition asserts it so any caller
gets a clean ratatoskr-side rejection, never a leaked SDK error. (The CLI surface
additionally pre-validates for a friendly usage error.)
"""
assert session_id and isinstance(session_id, str)
assert all(math.isfinite(v) for v in (pleasure, arousal, dominance))
try:
await client.sessions.set_persona_state(
session_id, PadState(pleasure=pleasure, arousal=arousal, dominance=dominance)
)
except ApiError as exc:
raise translate_error(exc) from exc
async def write_authored_history(
client: WorldtreeClient,
session_id: str,
*,
content: str,
idempotency_key: str,
) -> Mapping[str, Any]:
"""Write one authored assistant turn into the session ledger (POST
/sessions/{id}/history, #347) — the durable first-message primitive.
v1 accepts only `author="assistant"` (INV-347-4), so the adapter fixes it; the
caller supplies `content` + the per-content `idempotency_key` (REQUIRED, never
SDK-generated — a replay with the same (session, key) is an idempotent 200).
Returns the open-world `AuthoredTurn` ack verbatim. Error map (INV-CUT-2): a 404
→ `AuthoredHistoryUnavailable` (the ROUTE is the discriminator — hide-existence,
never body-sniffed: feature-absent / ungranted / session-absent are one 404 by
design, server INV-347-1); every other `ApiError` (notably 409 generation_active,
422 validation) → the `SessionApiFailed` default.
"""
assert session_id and isinstance(session_id, str)
assert content and isinstance(content, str)
assert idempotency_key and isinstance(idempotency_key, str)
try:
return await client.sessions.write_history(
session_id,
{"author": "assistant", "content": content, "idempotency_key": idempotency_key},
)
except ApiError as exc:
if exc.status == 404:
raise AuthoredHistoryUnavailable(session_id=session_id) from exc
raise translate_error(exc) from exc
+28
View File
@@ -1950,6 +1950,20 @@ class TestTier2Probes:
) )
assert rc == 10 assert rc == 10
@respx.mock
def test_set_persona_probe_connect_failed(self, capsys: pytest.CaptureFixture[str]) -> None:
"""connect_failed [error-path]: a transport failure the SDK normalizes to
ConnectFailed → graceful [network_error], exit 21 (not an uncaught crash)."""
respx.post("https://w.example/sessions/s1/persona_state").mock(
side_effect=httpx.ConnectError("refused")
)
rc = main(
["--set-persona-pad", "0.4,0.1,-0.2", "--session", "s1",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 21
assert "[network_error]" in capsys.readouterr().err
class TestSeedFirstMessageProbe: class TestSeedFirstMessageProbe:
"""--seed-first-message one-shot (#347 authored-history-write reference-consumer probe).""" """--seed-first-message one-shot (#347 authored-history-write reference-consumer probe)."""
@@ -2066,3 +2080,17 @@ class TestSeedFirstMessageProbe:
assert rc == 0 assert rc == 0
assert "feature-absent" in capsys.readouterr().out assert "feature-absent" in capsys.readouterr().out
assert msgs_route.call_count == 0 # never capability-probes past the 404 assert msgs_route.call_count == 0 # never capability-probes past the 404
@respx.mock
def test_seed_probe_connect_failed(self, capsys: pytest.CaptureFixture[str]) -> None:
"""connect_failed [error-path]: a transport failure on create that the SDK
normalizes to ConnectFailed → graceful [network_error], exit 21."""
respx.post("https://w.example/sessions").mock(
side_effect=httpx.ConnectError("refused")
)
rc = main(
["--seed-first-message", "hello", "--agent", "mimir",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 21
assert "[network_error]" in capsys.readouterr().err
+76 -90
View File
@@ -1,12 +1,21 @@
"""Tests for ratatoskr.first_message per docs/contracts/first_message.contract.md.""" """Tests for ratatoskr.first_message per docs/contracts/first_message.contract.md.
Slice-3 (worldtree-sdk cutover): `seed_preset_first_message` routes through the
`ratatoskr.wt` adapter over a `WorldtreeClient`, no longer the hand-rolled httpx
wrapper. These tests drive it through a fake client whose `sessions.write_history`
returns or raises the SDK's real types — exercising the adapter's error mapping AND
first_message's best-effort swallow in one pass. The wire format itself is the SDK's
to prove (the parity corpus); first_message's contract is behavioral: never block,
never raise (except CancelledError), and exactly one write on a preset hit.
"""
import asyncio import asyncio
import hashlib import hashlib
import json from typing import Any, cast
import httpx
import pytest import pytest
import respx import worldtree_sdk as wtsdk
from worldtree_sdk import ApiError, WorldtreeClient
from ratatoskr.first_message import ( from ratatoskr.first_message import (
FIRST_MESSAGE_PRESETS, FIRST_MESSAGE_PRESETS,
@@ -15,6 +24,33 @@ from ratatoskr.first_message import (
) )
class _FakeSessions:
"""Stand-in for `WorldtreeClient.sessions` — records each `write_history` call
and returns a canned ack or raises a canned error (the SDK's real exceptions)."""
def __init__(self, *, result: Any = None, error: BaseException | None = None) -> None:
self._result = result if result is not None else {}
self._error = error
self.calls: list[tuple[str, Any]] = []
async def write_history(self, session_id: str, entry: Any) -> Any:
self.calls.append((session_id, entry))
if self._error is not None:
raise self._error
return self._result
class _FakeClient:
def __init__(self, sessions: _FakeSessions) -> None:
self.sessions = sessions
def _wt(sessions: _FakeSessions) -> WorldtreeClient:
"""Cast the structural fake to the nominal client type (no network; the seed
path only touches `client.sessions.write_history`, which the fake provides)."""
return cast(WorldtreeClient, _FakeClient(sessions))
class TestPresetFor: class TestPresetFor:
"""first_message contract — preset_for (dict lookup).""" """first_message contract — preset_for (dict lookup)."""
@@ -36,120 +72,70 @@ class TestPresetFor:
class TestSeedPresetFirstMessage: class TestSeedPresetFirstMessage:
"""first_message contract — seed_preset_first_message (best-effort #347 seed).""" """first_message contract — seed_preset_first_message (best-effort #347 seed)."""
@respx.mock
async def test_seeds_preset(self) -> None: async def test_seeds_preset(self) -> None:
"""seeds_preset [happy,tracer]: preset agent → one history POST, correct body.""" """seeds_preset [happy,tracer]: preset agent → one write_history, correct entry."""
content = FIRST_MESSAGE_PRESETS["ratatoskr:sindra"] content = FIRST_MESSAGE_PRESETS["ratatoskr:sindra"]
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12] key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
route = respx.post("https://w.example/sessions/s1/history").mock( fake = _FakeSessions(result={"seq": 0, "phase": "seeded"})
return_value=httpx.Response( result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
201,
json={
"author": "assistant",
"seq": 0,
"phase": "seeded",
"turn_id": "t1",
"session_id": "s1",
"content_chars": len(content),
"injected_at": "2026-07-06T00:00:00+00:00",
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
assert result == content assert result == content
assert route.call_count == 1 # POST-002: exactly one history POST assert len(fake.calls) == 1 # POST-002: exactly one history write
assert json.loads(route.calls[0].request.content) == { session_id, entry = fake.calls[0]
assert session_id == "s1"
assert entry == {
"author": "assistant", "author": "assistant",
"content": content, "content": content,
"idempotency_key": key, "idempotency_key": key,
} }
@respx.mock async def test_no_preset_zero_write(self) -> None:
async def test_no_preset_zero_http(self) -> None: """no_preset_zero_write [happy]: no-preset agent → None, ZERO write (INV-002)."""
"""no_preset_zero_http [happy]: no-preset agent → None, ZERO HTTP (INV-002).""" fake = _FakeSessions()
route = respx.post("https://w.example/sessions/s1/history").mock( result = await seed_preset_first_message(_wt(fake), "s1", "mimir")
return_value=httpx.Response(201, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "mimir")
assert result is None assert result is None
assert not route.called assert fake.calls == []
@respx.mock
async def test_feature_absent_swallowed(self) -> None: async def test_feature_absent_swallowed(self) -> None:
"""feature_absent_swallowed [error]: 404 hide-existence → None, no raise (INV-001).""" """feature_absent_swallowed [error]: 404 → AuthoredHistoryUnavailable → None (INV-001)."""
respx.post("https://w.example/sessions/s1/history").mock( fake = _FakeSessions(error=ApiError("session_not_found", "no", status=404))
return_value=httpx.Response(404, json={"error_code": "session_not_found"}) result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
assert result is None assert result is None
assert len(fake.calls) == 1 # the write was attempted, then swallowed
@respx.mock
async def test_session_api_failed_swallowed(self) -> None: async def test_session_api_failed_swallowed(self) -> None:
"""session_api_failed_swallowed [error]: 409 → None, no raise (INV-001).""" """session_api_failed_swallowed [error]: 409 → SessionApiFailed → None (INV-001)."""
respx.post("https://w.example/sessions/s1/history").mock( fake = _FakeSessions(error=ApiError("generation_active", "busy", status=409))
return_value=httpx.Response(409, json={"error_code": "generation_active"}) result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
assert result is None assert result is None
@respx.mock
async def test_transport_error_swallowed(self) -> None: async def test_transport_error_swallowed(self) -> None:
"""transport_error_swallowed [error]: httpx.ConnectError → None, no raise (INV-001).""" """transport_error_swallowed [error]: SDK ConnectFailed → None, no raise (INV-001)."""
respx.post("https://w.example/sessions/s1/history").mock( fake = _FakeSessions(error=wtsdk.ConnectFailed("connect_failed", "boom", status=0))
side_effect=httpx.ConnectError("boom") result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
assert result is None assert result is None
@respx.mock
async def test_unexpected_exception_swallowed(self) -> None: async def test_unexpected_exception_swallowed(self) -> None:
"""unexpected_exception [error]: write raises ValueError → None (broad never-raise).""" """unexpected_exception [error]: write raises ValueError → None (broad never-raise)."""
respx.post("https://w.example/sessions/s1/history").mock( fake = _FakeSessions(error=ValueError("unexpected"))
side_effect=ValueError("unexpected") result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
assert result is None assert result is None
async def test_cancellation_propagates(self) -> None: async def test_cancellation_propagates(self) -> None:
"""cancellation_propagates [error]: CancelledError from the write is RE-RAISED.""" """cancellation_propagates [error]: CancelledError from the write is RE-RAISED."""
import ratatoskr.first_message as fm fake = _FakeSessions(error=asyncio.CancelledError())
async def _cancel(*_a: object, **_k: object) -> None:
raise asyncio.CancelledError
orig = fm.write_authored_history
fm.write_authored_history = _cancel # type: ignore[assignment]
try:
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(asyncio.CancelledError): with pytest.raises(asyncio.CancelledError):
await seed_preset_first_message(client, "s1", "ratatoskr:sindra") await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
finally:
fm.write_authored_history = orig # type: ignore[assignment]
@respx.mock async def test_malformed_agent_id_no_write(self) -> None:
async def test_malformed_agent_id_no_http(self) -> None: """malformed_agent_id [adversarial]: non-str or empty agent_id → None; no write; no raise."""
"""malformed_agent_id [adversarial]: non-str or empty agent_id → None; no HTTP; no raise.""" fake = _FakeSessions()
route = respx.post(url__regex=r".*/history$").mock( assert await seed_preset_first_message(_wt(fake), "s1", 123) is None # type: ignore[arg-type]
return_value=httpx.Response(201, json={}) assert await seed_preset_first_message(_wt(fake), "s1", "") is None
) assert fake.calls == []
async with httpx.AsyncClient(base_url="https://w.example") as client:
assert await seed_preset_first_message(client, "s1", 123) is None # type: ignore[arg-type]
assert await seed_preset_first_message(client, "s1", "") is None
assert not route.called
@respx.mock
async def test_empty_session_id(self) -> None: async def test_empty_session_id(self) -> None:
"""empty_session_id [adversarial]: "" → None (soft guard); no HTTP; no raise.""" """empty_session_id [adversarial]: "" → None (soft guard); no write; no raise."""
route = respx.post("https://w.example/sessions/s1/history").mock( fake = _FakeSessions()
return_value=httpx.Response(201, json={}) result = await seed_preset_first_message(_wt(fake), "", "ratatoskr:sindra")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "", "ratatoskr:sindra")
assert result is None assert result is None
assert not route.called assert fake.calls == []
+5 -722
View File
@@ -7,16 +7,10 @@ import respx
from ratatoskr.sessions import ( from ratatoskr.sessions import (
AgentInfo, AgentInfo,
AgentNotAvailable, AgentNotAvailable,
AgentNotFound,
AuthoredHistoryUnavailable,
AuthScopeDenied, AuthScopeDenied,
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
PersonaNotConfigured, PersonaNotConfigured,
SessionApiFailed, SessionApiFailed,
create_character, create_character,
create_session,
delete_character, delete_character,
endpoint_for_plane, endpoint_for_plane,
get_capabilities, get_capabilities,
@@ -24,522 +18,25 @@ from ratatoskr.sessions import (
get_me, get_me,
get_persona_state, get_persona_state,
get_session_bifrost, get_session_bifrost,
get_session_messages,
list_agents, list_agents,
list_character_models, list_character_models,
set_persona_state,
write_authored_history,
) )
class TestCreateSession:
@respx.mock
async def test_happy_create(self) -> None:
"""happy_create [happy,tracer]: full 201 body -> SessionInfo with create-origin defaults."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"agent_id": "mimir",
"message_count": 0,
"created_at": "2026-04-15T12:00:00+00:00",
"last_active": "2026-04-15T12:00:00+00:00",
"metadata": {},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await create_session(client, "mimir")
assert info.session_id == "550e8400-e29b-41d4-a716-446655440000"
assert info.agent_id == "mimir"
assert info.created_at == "2026-04-15T12:00:00+00:00"
assert info.last_active == "2026-04-15T12:00:00+00:00"
assert info.metadata == {}
assert info.message_count == 0
# INV-001 create-origin fixed defaults
assert info.name is None
assert info.archived is False
assert info.tags == []
@respx.mock
async def test_happy_create_with_metadata(self) -> None:
"""happy_create_with_metadata: response carries metadata -> SessionInfo.metadata matches."""
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-04-15T12:00:00+00:00",
"last_active": "2026-04-15T12:00:00+00:00",
"metadata": {"model": "glm5-turbo"},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await create_session(client, "mimir")
assert info.metadata == {"model": "glm5-turbo"}
@respx.mock
async def test_request_body_shape(self) -> None:
"""request_body_shape [trace]: outbound JSON is exactly {"agent_id": <arg>}."""
import json as _json
route = 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-04-15T12:00:00+00:00",
"last_active": "2026-04-15T12:00:00+00:00",
"metadata": {},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await create_session(client, "mimir")
body = _json.loads(route.calls[0].request.content)
assert body == {"agent_id": "mimir"}
@respx.mock
async def test_unknown_agent_id(self) -> None:
"""unknown_agent_id: 404 -> AgentNotFound(agent_id=<arg>)."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(404, json={"error": "unknown_agent_id"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AgentNotFound) as exc_info:
await create_session(client, "mimir")
assert exc_info.value.agent_id == "mimir"
@respx.mock
async def test_validation_failed(self) -> None:
"""validation_failed: 422 -> SessionApiFailed(status=422); body truncated."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
422,
json={"error_code": "validation_failed", "message": "missing agent_id"},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await create_session(client, "mimir")
assert exc_info.value.status == 422
assert len(exc_info.value.body) <= 1024
@respx.mock
async def test_unexpected_status_truncates(self) -> None:
"""unexpected_status_truncates: 500 + 5000-byte body -> SessionApiFailed; body == 1024."""
big = b"x" * 5000
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(500, content=big)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await create_session(client, "mimir")
assert exc_info.value.status == 500
assert exc_info.value.body == big[:1024]
@respx.mock
async def test_empty_agent_id(self) -> None:
"""empty_agent_id [adversarial]: '' -> AssertionError; no HTTP issued."""
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, content=b"{}")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await create_session(client, "")
assert route.call_count == 0
@respx.mock
async def test_happy_create_with_end_user_id(self) -> None:
"""happy_create_with_end_user_id [happy]: body carries both keys (issue #5)."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s-new",
"agent_id": "lofn",
"message_count": 0,
"created_at": "2026-05-22T12:00:00+00:00",
"last_active": "2026-05-22T12:00:00+00:00",
"metadata": {},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await create_session(client, "lofn", end_user_id="alice")
body = _json.loads(route.calls[0].request.content)
# INV: body MUST be exactly {"agent_id": ..., "end_user_id": ...} — byte-for-byte
assert body == {"agent_id": "lofn", "end_user_id": "alice"}
assert info.session_id == "s-new"
assert info.agent_id == "lofn"
@respx.mock
async def test_default_omits_end_user_id(self) -> None:
"""default_omits_end_user_id [trace]: omit kwarg → body has no end_user_id (INV-002)."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s-new",
"agent_id": "mimir",
"message_count": 0,
"created_at": "2026-05-22T12:00:00+00:00",
"last_active": "2026-05-22T12:00:00+00:00",
"metadata": {},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await create_session(client, "mimir")
body = _json.loads(route.calls[0].request.content)
# Exact equality — no end_user_id key in the body when the kwarg is omitted
assert body == {"agent_id": "mimir"}
assert "end_user_id" not in body
@respx.mock
async def test_empty_end_user_id(self) -> None:
"""empty_end_user_id [adversarial]: '' → AssertionError before HTTP (PRE-003)."""
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, content=b"{}")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await create_session(client, "mimir", end_user_id="")
assert route.call_count == 0
class TestCreateSessionEphemeral:
"""Amendment 2026-07-18 — ephemeral-template (Echo) session creation."""
@respx.mock
async def test_happy_ephemeral_create(self) -> None:
"""happy_ephemeral_create [happy,tracer]: config threaded; kind/config captured."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "eph-1",
"agent_id": "echo",
"kind": "ephemeral",
"message_count": 0,
"created_at": "2026-07-18T09:30:13+00:00",
"last_active": "2026-07-18T09:30:13+00:00",
"metadata": {},
"config": {"system_prompt": "You are X.", "role": "echo"},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await create_session(
client, "echo", config={"system_prompt": "You are X."}
)
body = _json.loads(route.calls[0].request.content)
assert body == {"agent_id": "echo", "config": {"system_prompt": "You are X."}}
assert info.kind == "ephemeral"
assert info.config == {"system_prompt": "You are X.", "role": "echo"}
@respx.mock
async def test_foundational_omits_config(self) -> None:
"""foundational_omits_config [trace]: no config key; kind/config None when absent."""
import json as _json
route = 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-04-15T12:00:00+00:00",
"last_active": "2026-04-15T12:00:00+00:00",
"metadata": {},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await create_session(client, "mimir")
body = _json.loads(route.calls[0].request.content)
assert body == {"agent_id": "mimir"}
assert info.kind is None
assert info.config is None
@respx.mock
async def test_foundational_captures_kind(self) -> None:
"""foundational_captures_kind [happy]: kind='foundational' captured; config None."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s1",
"agent_id": "mimir",
"kind": "foundational",
"message_count": 0,
"created_at": "2026-04-15T12:00:00+00:00",
"last_active": "2026-04-15T12:00:00+00:00",
"metadata": {},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await create_session(client, "mimir")
assert info.kind == "foundational"
assert info.config is None
@respx.mock
async def test_ephemeral_requires_config_422(self) -> None:
"""ephemeral_requires_config_422 [error]: 422 → SessionApiFailed carrying the code."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
422,
json={"detail": {"error_code": "ephemeral_requires_config"}},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await create_session(client, "echo")
assert exc.value.status == 422
assert b"ephemeral_requires_config" in exc.value.body
@respx.mock
async def test_model_not_allowed_422(self) -> None:
"""model_not_allowed_422 [error]: config.model → 422 (wrapper passes config verbatim)."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
422, json={"detail": {"error_code": "model_not_allowed"}}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await create_session(
client, "echo", config={"system_prompt": "x", "model": "glm5-turbo"}
)
body = _json.loads(route.calls[0].request.content)
assert body["config"] == {"system_prompt": "x", "model": "glm5-turbo"}
assert exc.value.status == 422
@respx.mock
async def test_config_and_bifrost_conflict(self) -> None:
"""config_and_bifrost_conflict [adversarial]: both → AssertionError (PRE-005); no HTTP."""
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, content=b"{}")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await create_session(
client,
"echo",
config={"system_prompt": "x"},
bifrost=BifrostBinding(endpoint_url="https://p.example"),
consumer_key="ck",
)
assert route.call_count == 0
@respx.mock
async def test_config_not_a_mapping(self) -> None:
"""config_not_a_mapping [adversarial]: non-Mapping → AssertionError (PRE-004); no HTTP."""
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, content=b"{}")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await create_session(client, "echo", config="not-a-dict") # type: ignore[arg-type]
assert route.call_count == 0
class TestCreateSessionBifrostBind:
"""Issue #17 slice 1 — the create_session Bifrost-bind primitive."""
@respx.mock
async def test_bind_happy_consumer_key_and_body(self) -> None:
"""bind_happy [tracer]: a bifrost binding makes the body carry the
`bifrost` field AND overrides the bearer to the consumer key (NOT the
client's canary default), 201 → SessionInfo. Proves the bind path
end-to-end (FN create_session STEPS 1-2, POST-001, INV-001)."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s-bound",
"agent_id": "ratatoskr:sindra",
"message_count": 0,
"created_at": "2026-06-18T12:00:00+00:00",
"last_active": "2026-06-18T12:00:00+00:00",
"metadata": {},
},
)
)
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
async with httpx.AsyncClient(
base_url="https://w.example",
headers={"Authorization": "Bearer canary-key"},
) as client:
info = await create_session(
client,
"ratatoskr:sindra",
end_user_id="smoke-user",
bifrost=binding,
consumer_key="consumer-key",
)
req = route.calls[0].request
body = _json.loads(req.content)
# body carries the bifrost field alongside agent_id/end_user_id
assert body == {
"agent_id": "ratatoskr:sindra",
"end_user_id": "smoke-user",
"bifrost": {
"endpoint_url": "http://10.100.10.50:8391",
"scope": None,
},
}
# bearer overridden to the consumer key (INV-001: never the canary default)
assert req.headers["Authorization"] == "Bearer consumer-key"
assert info.session_id == "s-bound"
assert info.agent_id == "ratatoskr:sindra"
@respx.mock
async def test_bind_without_consumer_key_raises_before_http(self) -> None:
"""missing_key [adversarial]: bifrost set but consumer_key None →
BifrostConsumerKeyMissing BEFORE any HTTP (PRE-001, INV-001: never fall
back to the canary key)."""
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, content=b"{}")
)
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(BifrostConsumerKeyMissing):
await create_session(client, "ratatoskr:sindra", bifrost=binding)
assert route.call_count == 0
@respx.mock
async def test_bind_with_empty_consumer_key_raises_before_http(self) -> None:
"""empty_key [adversarial]: empty-string consumer_key is also rejected
before HTTP (PRE-001 requires a NON-EMPTY str)."""
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, content=b"{}")
)
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(BifrostConsumerKeyMissing):
await create_session(
client, "ratatoskr:sindra", bifrost=binding, consumer_key=""
)
assert route.call_count == 0
@respx.mock
async def test_bind_handshake_failure_maps_to_502(self) -> None:
"""handshake_502 [adversarial]: a bound create that 502s with
detail.bifrost_error → BifrostHandshakeFailed carrying the bifrost_error
+ raw body (POST-002, INV-002 bind-time failure). 'bifrost.auth_rejected'
is the canary-key-instead-of-consumer-key tell."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
502,
json={
"error_code": "bifrost_handshake_failed",
"detail": {"bifrost_error": "bifrost.auth_rejected"},
},
)
)
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(BifrostHandshakeFailed) as exc_info:
await create_session(
client, "ratatoskr:sindra", bifrost=binding, consumer_key="ck"
)
assert exc_info.value.bifrost_error == "bifrost.auth_rejected"
# the raw 502 body is carried for debugging
assert exc_info.value.body
@respx.mock
async def test_bind_ephemeral_rejection_is_session_api_failed(self) -> None:
"""ephemeral_422 [boundary]: 422 ephemeral_does_not_accept_bifrost is a
generic create failure → SessionApiFailed, NOT a distinct exception
(POST-003 — deliberate, an operator config error)."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
422, json={"error_code": "ephemeral_does_not_accept_bifrost"}
)
)
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await create_session(
client, "echo", bifrost=binding, consumer_key="ck"
)
assert exc_info.value.status == 422
@respx.mock
async def test_unbound_create_unchanged_no_auth_override(self) -> None:
"""unbound_unchanged [regression]: with no bifrost, the body is the
pre-#17 shape AND create_session sends NO per-request Authorization
override — the client's default canary bearer governs (INV-001: the two
call sites never cross)."""
import json as _json
route = 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-04-15T12:00:00+00:00",
"last_active": "2026-04-15T12:00:00+00:00",
"metadata": {},
},
)
)
async with httpx.AsyncClient(
base_url="https://w.example",
headers={"Authorization": "Bearer canary-key"},
) as client:
await create_session(client, "mimir")
req = route.calls[0].request
body = _json.loads(req.content)
assert body == {"agent_id": "mimir"}
# the client default bearer is used unchanged — no consumer-key override
assert req.headers["Authorization"] == "Bearer canary-key"
class TestEndpointForPlane: class TestEndpointForPlane:
"""Issue #17 — endpoint_for_plane: plane name → Worldtree-visible base URL.""" """Issue #17 — endpoint_for_plane: plane name → Worldtree-visible base URL."""
def test_memory_plane_maps_to_8391(self) -> None: def test_memory_plane_maps_to_8391(self) -> None:
"""memory [tracer]: 'memory' → http://<host>:8391 (POST-001).""" """memory [tracer]: 'memory' → http://<host>:8391 (POST-001)."""
assert ( assert endpoint_for_plane("memory", "10.100.10.50") == "http://10.100.10.50:8391"
endpoint_for_plane("memory", "10.100.10.50")
== "http://10.100.10.50:8391"
)
def test_affect_plane_maps_to_8390(self) -> None: def test_affect_plane_maps_to_8390(self) -> None:
"""affect: 'affect' → http://<host>:8390 (POST-001).""" """affect: 'affect' → http://<host>:8390 (POST-001)."""
assert ( assert endpoint_for_plane("affect", "10.100.10.50") == "http://10.100.10.50:8390"
endpoint_for_plane("affect", "10.100.10.50")
== "http://10.100.10.50:8390"
)
def test_combined_plane_maps_to_8392(self) -> None: def test_combined_plane_maps_to_8392(self) -> None:
"""combined [#18 composite]: 'combined' → http://<host>:8392 (POST-001).""" """combined [#18 composite]: 'combined' → http://<host>:8392 (POST-001)."""
assert ( assert endpoint_for_plane("combined", "10.100.10.50") == "http://10.100.10.50:8392"
endpoint_for_plane("combined", "10.100.10.50")
== "http://10.100.10.50:8392"
)
def test_unknown_plane_raises_value_error(self) -> None: def test_unknown_plane_raises_value_error(self) -> None:
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001).""" """unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
@@ -636,9 +133,7 @@ class TestListAgents:
@respx.mock @respx.mock
async def test_happy_empty(self) -> None: async def test_happy_empty(self) -> None:
"""happy_empty: 200 with [] returns empty list (no error).""" """happy_empty: 200 with [] returns empty list (no error)."""
respx.get("https://w.example/agents").mock( respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[]))
return_value=httpx.Response(200, json=[])
)
async with httpx.AsyncClient(base_url="https://w.example") as client: async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client) agents = await list_agents(client)
assert agents == [] assert agents == []
@@ -1026,9 +521,7 @@ class TestTransientCharacters:
@respx.mock @respx.mock
async def test_delete_204(self) -> None: async def test_delete_204(self) -> None:
"""delete [happy]: 204 → None.""" """delete [happy]: 204 → None."""
respx.delete("https://w.example/characters/char_x").mock( respx.delete("https://w.example/characters/char_x").mock(return_value=httpx.Response(204))
return_value=httpx.Response(204)
)
async with httpx.AsyncClient(base_url="https://w.example") as client: async with httpx.AsyncClient(base_url="https://w.example") as client:
assert await delete_character(client, "char_x") is None assert await delete_character(client, "char_x") is None
@@ -1042,213 +535,3 @@ class TestTransientCharacters:
with pytest.raises(SessionApiFailed) as exc: with pytest.raises(SessionApiFailed) as exc:
await create_character(client, {"name": "H"}) await create_character(client, {"name": "H"})
assert exc.value.status == 403 assert exc.value.status == 403
class TestSetPersonaState:
"""#2 contract — set_persona_state (POST /sessions/{id}/persona_state)."""
@respx.mock
async def test_happy_204(self) -> None:
"""happy [happy,tracer]: freeform snapshot body; 204 → None."""
import json as _json
route = respx.post("https://w.example/sessions/s1/persona_state").mock(
return_value=httpx.Response(204)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await set_persona_state(
client, "s1", {"pad": {"pleasure": 0.4, "arousal": 0.1, "dominance": -0.2}}
)
assert result is None
assert _json.loads(route.calls[0].request.content) == {
"pad": {"pleasure": 0.4, "arousal": 0.1, "dominance": -0.2}
}
@respx.mock
async def test_non_204_raises(self) -> None:
"""non_204 [error]: 422 (bad snapshot shape) → SessionApiFailed(422)."""
respx.post("https://w.example/sessions/s1/persona_state").mock(
return_value=httpx.Response(422, json={"error_code": "validation_failed"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await set_persona_state(client, "s1", {"pad": [1, 2, 3]})
assert exc.value.status == 422
_AUTHORED_ACK = {
"author": "assistant",
"content_chars": 5,
"injected_at": "2026-07-06T12:00:00+00:00",
"phase": "seeded",
"seq": 0,
"session_id": "s1",
"turn_id": "t1",
}
class TestWriteAuthoredHistory:
"""write_authored_history — #347 POST /sessions/{id}/history (contract #2 amendment)."""
@respx.mock
async def test_happy_fresh_201(self) -> None:
"""happy_fresh_201 [happy,tracer]: 201 → ack verbatim; minimal body."""
import json as _json
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await write_authored_history(
client, "s1", content="hello", idempotency_key="k1"
)
assert result == _AUTHORED_ACK
assert _json.loads(route.calls[0].request.content) == {
"author": "assistant",
"content": "hello",
"idempotency_key": "k1",
}
@respx.mock
async def test_happy_replay_200(self) -> None:
"""happy_replay_200 [happy]: 200 replay (byte-identical body) → dict verbatim."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(200, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await write_authored_history(
client, "s1", content="hello", idempotency_key="k1"
)
assert result == _AUTHORED_ACK
@respx.mock
async def test_body_includes_effects(self) -> None:
"""body_includes_effects [trace]: effects + claimed_original_at appear iff non-None."""
import json as _json
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await write_authored_history(
client,
"s1",
content="hi",
idempotency_key="k1",
effects="none",
claimed_original_at="2020-01-01T00:00:00Z",
)
assert _json.loads(route.calls[0].request.content) == {
"author": "assistant",
"content": "hi",
"idempotency_key": "k1",
"effects": "none",
"claimed_original_at": "2020-01-01T00:00:00Z",
}
@respx.mock
async def test_hide_existence_404(self) -> None:
"""hide_existence_404 [error]: 404 → AuthoredHistoryUnavailable (NOT SessionApiFailed)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AuthoredHistoryUnavailable) as exc:
await write_authored_history(client, "s1", content="hi", idempotency_key="k1")
assert exc.value.session_id == "s1"
@respx.mock
async def test_generation_active_409(self) -> None:
"""generation_active_409 [error]: 409 → SessionApiFailed(409)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(409, json={"error_code": "generation_active"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await write_authored_history(client, "s1", content="hi", idempotency_key="k1")
assert exc.value.status == 409
@respx.mock
async def test_content_too_long_422(self) -> None:
"""content_too_long_422 [error]: 422 → SessionApiFailed(422)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(422, json={"error_code": "content_too_long"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await write_authored_history(client, "s1", content="x", idempotency_key="k1")
assert exc.value.status == 422
@respx.mock
async def test_empty_content(self) -> None:
"""empty_content [adversarial]: content="" → AssertionError; no HTTP issued."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await write_authored_history(client, "s1", content="", idempotency_key="k1")
assert not route.called
@respx.mock
async def test_empty_idempotency_key(self) -> None:
"""empty_idempotency_key [adversarial]: key="" → AssertionError; no HTTP issued."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await write_authored_history(client, "s1", content="hi", idempotency_key="")
assert not route.called
@respx.mock
async def test_empty_session_id(self) -> None:
"""empty_session_id [adversarial]: session_id="" → AssertionError; no HTTP issued."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await write_authored_history(client, "", content="hi", idempotency_key="k1")
assert not route.called
class TestGetSessionMessages:
"""#2 contract (amendment 2026-07-06) — get_session_messages (GET /sessions/{id}/messages)."""
@respx.mock
async def test_happy(self) -> None:
"""happy [happy,tracer]: 200 {session_id, items, next_cursor} → dict verbatim."""
payload = {
"session_id": "s1",
"items": [{"seq": 0, "role": "assistant", "content": "hello there"}],
"next_cursor": None,
}
respx.get("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(200, json=payload)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await get_session_messages(client, "s1")
assert result == payload
@respx.mock
async def test_not_found_404(self) -> None:
"""not_found_404 [error]: 404 → SessionApiFailed(404)."""
respx.get("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_session_messages(client, "s1")
assert exc.value.status == 404
@respx.mock
async def test_empty_session_id(self) -> None:
"""empty_session_id [adversarial]: "" → AssertionError; no HTTP issued."""
route = respx.get("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(200, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await get_session_messages(client, "")
assert not route.called
+89 -1
View File
@@ -17,10 +17,11 @@ from typing import Any, cast
import httpx import httpx
import pytest import pytest
import worldtree_sdk as wtsdk import worldtree_sdk as wtsdk
from worldtree_sdk import ApiError, CancelResult, WorldtreeClient from worldtree_sdk import ApiError, CancelResult, PadState, WorldtreeClient
from ratatoskr.sessions import ( from ratatoskr.sessions import (
AgentNotFound, AgentNotFound,
AuthoredHistoryUnavailable,
BifrostBinding, BifrostBinding,
BifrostConsumerKeyMissing, BifrostConsumerKeyMissing,
BifrostHandshakeFailed, BifrostHandshakeFailed,
@@ -46,8 +47,10 @@ from ratatoskr.wt import (
get_session_messages, get_session_messages,
get_session_tools, get_session_tools,
list_sessions, list_sessions,
set_persona_state,
stream_turn, stream_turn,
translate_error, translate_error,
write_authored_history,
) )
@@ -101,6 +104,12 @@ class _FakeSessions:
async def cancel_turn(self, *args: Any, **kwargs: Any) -> Any: async def cancel_turn(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("cancel_turn", *args, **kwargs) return await self._dispatch("cancel_turn", *args, **kwargs)
async def set_persona_state(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("set_persona_state", *args, **kwargs)
async def write_history(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("write_history", *args, **kwargs)
class _FakeClient: class _FakeClient:
def __init__(self, sessions: _FakeSessions) -> None: def __init__(self, sessions: _FakeSessions) -> None:
@@ -440,3 +449,82 @@ class TestCancelTurn:
with pytest.raises(SessionApiFailed) as ei: with pytest.raises(SessionApiFailed) as ei:
await cancel_turn(_wt(fake), "s", 42) await cancel_turn(_wt(fake), "s", 42)
assert ei.value.status == 500 assert ei.value.status == 500
class TestSetPersonaState:
"""slice-3: set_persona_state → SDK sessions.set_persona_state(PadState). The
adapter builds the canonical PadState (the SDK owns the {"pad": {...}} wire
shape); no error row beyond the § Error map default (SessionApiFailed)."""
async def test_happy_builds_padstate_and_returns_none(self) -> None:
fake = _FakeSessions(result=None) # SDK resolves the 204 to None
out = await set_persona_state(
_wt(fake), "s-1", pleasure=0.4, arousal=0.1, dominance=-0.2
)
assert out is None
name, args, _kwargs = fake.calls[-1]
assert name == "set_persona_state"
assert args[0] == "s-1"
pad = args[1]
assert isinstance(pad, PadState)
assert (pad.pleasure, pad.arousal, pad.dominance) == (0.4, 0.1, -0.2)
async def test_falsy_zero_pad_preserved(self) -> None:
# A 0.0 axis must survive verbatim (not be dropped as falsy).
fake = _FakeSessions(result=None)
await set_persona_state(_wt(fake), "s", pleasure=0.0, arousal=0.0, dominance=0.0)
pad = fake.calls[-1][1][1]
assert (pad.pleasure, pad.arousal, pad.dominance) == (0.0, 0.0, 0.0)
async def test_error_maps_to_session_api_failed(self) -> None:
fake = _FakeSessions(error=ApiError("upstream", "boom", status=500, body="x"))
with pytest.raises(SessionApiFailed) as ei:
await set_persona_state(_wt(fake), "s", pleasure=0.0, arousal=0.0, dominance=0.0)
assert ei.value.status == 500
async def test_non_finite_pad_rejected_at_the_chokepoint(self) -> None:
# The finite-PAD invariant is enforced at the adapter (not only the CLI): a
# non-finite axis would serialize to null and corrupt the injection, so a
# direct caller is rejected pre-SDK — never a leaked SDK ConfigurationError.
fake = _FakeSessions(result=None)
for bad in (float("nan"), float("inf"), float("-inf")):
with pytest.raises(AssertionError):
await set_persona_state(_wt(fake), "s", pleasure=bad, arousal=0.0, dominance=0.0)
assert fake.calls == [] # never reached the SDK
class TestWriteAuthoredHistory:
"""slice-3: write_authored_history → SDK sessions.write_history. Builds the
v1 authored-write entry (author="assistant", the only accepted author);
404 → AuthoredHistoryUnavailable (hide-existence); else the default."""
async def test_happy_builds_entry_and_returns_dict(self) -> None:
ack = {"seq": 0, "phase": "seeded", "turn_id": "t1", "content_chars": 3}
fake = _FakeSessions(result=ack)
out = await write_authored_history(
_wt(fake), "s-1", content="hi!", idempotency_key="k1"
)
assert out is ack # open-world passthrough
name, args, _kwargs = fake.calls[-1]
assert name == "write_history"
assert args[0] == "s-1"
assert args[1] == {
"author": "assistant",
"content": "hi!",
"idempotency_key": "k1",
}
async def test_404_maps_to_authored_history_unavailable(self) -> None:
# Hide-existence: the ROUTE is the discriminator (never the body) — any 404
# on write_history → AuthoredHistoryUnavailable, no capability-probe.
fake = _FakeSessions(error=ApiError("session_not_found", "no", status=404))
with pytest.raises(AuthoredHistoryUnavailable) as ei:
await write_authored_history(_wt(fake), "s-1", content="hi", idempotency_key="k")
assert ei.value.session_id == "s-1"
async def test_other_error_maps_to_session_api_failed(self) -> None:
# 409 generation_active (retryable) is NOT a hide-existence 404 → default.
fake = _FakeSessions(error=ApiError("generation_active", "busy", status=409))
with pytest.raises(SessionApiFailed) as ei:
await write_authored_history(_wt(fake), "s", content="hi", idempotency_key="k")
assert ei.value.status == 409
Generated
+1 -1
View File
@@ -472,7 +472,7 @@ wheels = [
[[package]] [[package]]
name = "ratatoskr" name = "ratatoskr"
version = "0.21.10" version = "0.21.12"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },