diff --git a/docs/contracts/first_message.contract.md b/docs/contracts/first_message.contract.md index bdeaf04..ad03dbb 100644 --- a/docs/contracts/first_message.contract.md +++ b/docs/contracts/first_message.contract.md @@ -13,11 +13,12 @@ scope: > replacement for a system-prompt "startup" instruction. Two entry points: `preset_for` (lookup) and `seed_preset_first_message` (best-effort seed). Consumed by ratatoskr.cli (the `--new` session path) and ratatoskr.web.server - (the POST /api/sessions endpoint). Depends on ratatoskr.sessions - (write_authored_history + its exceptions); no core.* / worldtree.* imports. + (the POST /api/sessions endpoint). Depends on ratatoskr.wt + (`wt.write_authored_history` + AuthoredHistoryUnavailable) over a WorldtreeClient + (worldtree-sdk cutover slice-3, #20); no core.* / worldtree.* SOURCE imports. depends_on: - - "httpx" - - "ratatoskr.sessions" + - "worldtree_sdk" + - "ratatoskr.wt" used_by: - "ratatoskr.cli" - "ratatoskr.web.server" @@ -26,7 +27,7 @@ complexity: "low" estimated_loc: 60 confidence: 0.9 assumptions: - - "write_authored_history (contract #2 amendment 2026-07-06) is the seed primitive: 200/201 → ack dict, 404 → AuthoredHistoryUnavailable (hide-existence), other non-2xx → SessionApiFailed." + - "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." - "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 -**In:** a live `httpx.AsyncClient` (caller-owned, base_url + bearer set), a fresh -`session_id`, and the bound `agent_id`. +**In:** a `WorldtreeClient` (the wt-adapter client, built over ratatoskr's +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, 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 empty_agent_id [adversarial]: preset_for("") → AssertionError -FN seed_preset_first_message(client: httpx.AsyncClient, session_id: str, agent_id: str) -> str | None -BRIEF: Best-effort seed of an agent's preset opening as a #347 authored first-message on session_id. If agent_id has a preset, POST it via write_authored_history (author=assistant, per-content idempotency key, the await bounded by asyncio.wait_for(_SEED_TIMEOUT_S)) and return the seeded content; on no-preset, a malformed input, OR ANY exception except asyncio.CancelledError, return None WITHOUT raising. Never raises (except CancelledError, which propagates) and never blocks session creation — it is wired into three create paths. +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, 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-002 hard] session_id is a non-empty str -- soft-guarded: return None if violated PRE: [PRE-003 hard] agent_id is a non-empty str -- soft-guarded: return None if violated (also guards FIRST_MESSAGE_PRESETS.get against a non-hashable/non-str id) POST: [POST-001 return_value] preset agent + successful write → returns the preset text; no-preset, malformed input, OR any swallowed failure → None -POST: [POST-002 side_effect] a no-preset / malformed-input call issues ZERO HTTP; a preset agent issues exactly one POST /sessions/{session_id}/history with body author="assistant", content=preset, idempotency_key="ratatoskr-preset-"+sha256(preset)[:12], the await bounded by _SEED_TIMEOUT_S so a stalled response cannot block +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: asyncio.CancelledError: local_handling: RE-RAISE (cancellation is not a seed failure; never swallow it — and it is a BaseException, so `except Exception` would miss it anyway) flow_control: propagate state_recovery: n/a - any other Exception (hide-404 AuthoredHistoryUnavailable, SessionApiFailed 409/422/etc., httpx.HTTPError, TimeoutError from wait_for, any unexpected error): + 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 flow_control: continue (never blocks session create) 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) 3. [sequential, prescriptive] Soft-guard: IF client is None OR session_id is not a non-empty str: RETURN None 4. [sequential, prescriptive] key = "ratatoskr-preset-" + sha256(content utf-8)[:12] - 5. [sequential, prescriptive] TRY: await asyncio.wait_for(write_authored_history(client, session_id, content=content, idempotency_key=key), timeout=_SEED_TIMEOUT_S) + 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 } 6. [branch, prescriptive] EXCEPT asyncio.CancelledError: RAISE; EXCEPT Exception: RETURN None 7. [cleanup, prescriptive] RETURN content -TESTS: - seeds_preset [happy,tracer]: preset agent, mock 201 → returns the preset text; exactly one POST /sessions/{id}/history; body author="assistant" + content=preset + idempotency_key="ratatoskr-preset-"+sha256(preset)[:12] - no_preset_zero_http [happy]: agent "mimir" → returns None; NO HTTP issued - feature_absent_swallowed [error]: preset agent, mock 404 session_not_found → returns None, no raise - session_api_failed_swallowed [error]: preset agent, mock 409 → returns None, no raise - transport_error_swallowed [error]: preset agent, mock httpx.ConnectError → returns None, no raise +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, 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_write [happy]: agent "mimir" → returns None; ZERO write_history call + 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, fake raises ApiError(409) → wt.SessionApiFailed → 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) cancellation_propagates [error]: preset agent, write raises asyncio.CancelledError → RE-RAISED (never swallowed) - malformed_agent_id_no_http [adversarial]: agent_id=123 (non-str) OR "" → None; NO HTTP; no raise - empty_session_id [adversarial]: session_id="" (preset agent) → None (soft guard); NO HTTP; no raise + 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); ZERO write; no raise ``` diff --git a/docs/coverage-map.md b/docs/coverage-map.md index 4d6ecd2..01e9435 100644 --- a/docs/coverage-map.md +++ b/docs/coverage-map.md @@ -84,11 +84,11 @@ sub-gap). | 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/{id}/messages` (turn stream, SSE) | ✅ | `sse_client.py:484` `stream_turn` → cli/tui/web | the primary surface; 409→AgentNotAvailable, 503→TurnLaunchUnavailable (b2 #331) | -| `POST /sessions/{id}/history` (authored-history-write, #347) | ✅ | `sessions.py:583` `write_authored_history` → `cli.py:758` `--seed-first-message` | v1: author=assistant, effects=none, per-session idempotency; 404→AuthoredHistoryUnavailable (hide-existence: feature-absent, never probe); 409/422 mapped. **LIVE-PROVEN 2026-07-06** on personal :8081 (grant applied via a rule-based Heimdall allow, worldtree-dev): create mimir session → seed → **201** (seq=0, phase=seeded, turn_id=1798) → GET /messages reads it back as a plain role=assistant turn (model-invisible provenance confirmed). Hide-404 for ungranted is unit+probe covered | -| `GET /sessions/{id}/messages` (history) | ✅ | `sessions.py:635` `get_session_messages` → `cli.py:758` `--seed-first-message` read-back | un-deferred as the #347 seed read-back — confirms model-invisible provenance (a seed reads back as a normal `role=assistant` turn) | -| `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `sse_client.py:581` → cli/tui/web | two-stage Ctrl-C; 404/409 mapped | +| `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) | ✅ | `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) | ✅ | `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) | ✅ | `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` | ✅ | `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/{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 | @@ -103,7 +103,7 @@ sub-gap). | `POST /characters` | ✅ | `sessions.py` `create_character` → `cli.py` `--characters` | create transient character (#161) | | `GET /characters/{id}/state` | ✅ | `sessions.py` `get_character_state` → `cli.py` `--characters` | live character PAD/emotions (#161) | | `DELETE /characters/{id}` | ✅ | `sessions.py` `delete_character` → `cli.py` `--characters` | remove transient character (#161) | -| `POST /sessions/{id}/persona_state` | ✅ | `sessions.py` `set_persona_state` → `cli.py` `--set-persona-pad` | persona-state write / affect injection (freeform body — unpinned in the frozen surface) | +| `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 on the same path is an unwired frontier item — see frontier Tier 1): diff --git a/pyproject.toml b/pyproject.toml index b9fe3f5..2b4b6a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.21.10" +version = "0.21.11" description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/cli.py b/src/ratatoskr/cli.py index 88b85ae..10e9334 100644 --- a/src/ratatoskr/cli.py +++ b/src/ratatoskr/cli.py @@ -8,6 +8,7 @@ from __future__ import annotations import argparse import asyncio import hashlib +import math import os import signal import sys @@ -44,16 +45,12 @@ from ratatoskr.sessions import ( BifrostHandshakeFailed, SessionApiFailed, create_character, - create_session, delete_character, endpoint_for_plane, get_capabilities, get_character_state, get_me, - get_session_messages, list_character_models, - set_persona_state, - write_authored_history, ) # 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" ) # #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. - if await seed_preset_first_message(transport, session_id, args.agent_id): + # Routed through the wt adapter (slice-3); the seed never blocks create. + if await seed_preset_first_message(client, session_id, args.agent_id): sys.stderr.write( f". first_message: seeded preset opening for {args.agent_id}\n" ) @@ -882,14 +879,27 @@ async def _set_persona_probe(args: ParsedArgs) -> int: "(pleasure,arousal,dominance), e.g. '0.4,0.1,-0.2'\n" ) return 10 - # Canonical POST /sessions/{id}/persona_state body (#317): a named-key dict, - # NOT a bare list — {"pad": {"pleasure", "arousal", "dominance"}}. - snapshot = {"pad": {"pleasure": pad[0], "arousal": pad[1], "dominance": pad[2]}} - async with _probe_client(args) as client: + # The SDK rejects a non-finite axis pre-HTTP (a NaN/Infinity would serialize to + # null and corrupt the injection). Reject it here as a usage error so the probe + # surfaces a clean message instead of crashing on the SDK's ConfigurationError. + 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: - await set_persona_state(client, args.session_id, snapshot) - except SessionApiFailed as exc: - sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n") + # The SDK owns the canonical {"pad": {...}} wire body (#317); ratatoskr + # passes the three PAD axes and no longer hand-builds the snapshot. + 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 except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") @@ -914,19 +924,23 @@ async def _seed_first_message_probe(args: ParsedArgs) -> int: """ assert isinstance(args, ParsedArgs) assert args.agent_id is not None and args.seed_first_message is not None - async with _probe_client(args) as client: + async with _probe_client(args) as transport: + client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport) 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 ) - 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( args.seed_first_message.encode("utf-8") ).hexdigest()[:12] try: - ack = await write_authored_history( + ack = await wt.write_authored_history( client, - session.session_id, + session_id, content=args.seed_first_message, idempotency_key=key, ) @@ -941,15 +955,18 @@ async def _seed_first_message_probe(args: ParsedArgs) -> int: f"seeded: seq={ack.get('seq')} phase={ack.get('phase')} " f"turn_id={ack.get('turn_id')} content_chars={ack.get('content_chars')}\n" ) - history = await get_session_messages(client, session.session_id) + history = await wt.get_session_messages(client, session_id) items = history.get("items", []) sys.stdout.write(f"read-back: {len(items)} message(s)\n") for m in items: sys.stdout.write( f" seq={m.get('seq')} role={m.get('role')} content={m.get('content')!r}\n" ) - except SessionApiFailed as exc: - sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n") + 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 except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") diff --git a/src/ratatoskr/first_message.py b/src/ratatoskr/first_message.py index 2f58970..4ce5061 100644 --- a/src/ratatoskr/first_message.py +++ b/src/ratatoskr/first_message.py @@ -14,12 +14,12 @@ blocked (the session simply opens with no seeded greeting). See import asyncio 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 -# with NO read timeout (it streams SSE turns), so an accepted-but-never-answered +# Cap the best-effort seed write. The CLI create path reuses a transport with NO +# read timeout (it streams SSE turns), so an accepted-but-never-answered # POST /history would otherwise block session creation forever — violating INV-001's # "never block". asyncio.wait_for bounds the seed regardless of the client's timeout. _SEED_TIMEOUT_S = 10.0 @@ -39,7 +39,7 @@ def preset_for(agent_id: str) -> str | None: 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: """Best-effort: seed ``agent_id``'s preset opening as a #347 authored 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] try: await asyncio.wait_for( - write_authored_history( + wt.write_authored_history( client, session_id, content=content, idempotency_key=key ), timeout=_SEED_TIMEOUT_S, diff --git a/src/ratatoskr/sessions.py b/src/ratatoskr/sessions.py index 4adcfa3..66135ef 100644 --- a/src/ratatoskr/sessions.py +++ b/src/ratatoskr/sessions.py @@ -5,41 +5,12 @@ Implements docs/contracts/issues/2.contract.md. from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from typing import Any 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) class AgentInfo: """Worldtree agent envelope from GET /agents (issue #8). @@ -198,8 +169,6 @@ class AuthoredHistoryUnavailable(Exception): self.session_id = session_id - - def endpoint_for_plane(plane: str, base_host: str) -> str: """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} if plane not in ports: - raise ValueError( - f"unknown plane: {plane!r} " - "(expected 'memory', 'affect', or 'combined')" - ) + raise ValueError(f"unknown plane: {plane!r} (expected 'memory', 'affect', or 'combined')") 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]: """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( - client: httpx.AsyncClient, agent_id: str -) -> dict[str, Any]: +async def get_persona_state(client: httpx.AsyncClient, agent_id: str) -> dict[str, Any]: """GET /agents/{agent_id}/persona_state — fetch current persona snapshot. 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) -async def set_persona_state( - client: httpx.AsyncClient, session_id: str, snapshot: dict[str, Any] -) -> None: - """POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection). - - The request body is FREEFORM on the wire (the OpenAPI declares no request - schema), but worldtree-dev's prose now pins the canonical shape (#317): - `{"pad": {"pleasure": p, "arousal": a, "dominance": d}}` — a named-key dict - (each in [-1, 1]), NOT a bare list; PAD-only, session-scoped, pull-over-push - (#289). The caller supplies the snapshot. 204 No Content → None; any other - status → SessionApiFailed. - """ - assert client is not None - assert session_id and isinstance(session_id, str) - assert isinstance(snapshot, dict) - resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot) - if resp.status_code == 204: - return None - raise SessionApiFailed(status=resp.status_code, body=resp.content) - - async def get_session_bifrost( client: httpx.AsyncClient, session_id: str, *, admin_key: str ) -> dict[str, Any]: @@ -519,8 +362,6 @@ async def get_session_bifrost( raise SessionApiFailed(status=resp.status_code, body=resp.content) - - async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]: """GET /capabilities — server capability discovery (spec §Ephemeral Templates). @@ -534,75 +375,3 @@ async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]: if resp.status_code == 200: return resp.json() raise SessionApiFailed(status=resp.status_code, body=resp.content) - - -async def write_authored_history( - client: httpx.AsyncClient, - session_id: str, - *, - content: str, - idempotency_key: str, - author: str = "assistant", - effects: str | None = None, - claimed_original_at: str | None = None, -) -> dict[str, Any]: - """POST /sessions/{session_id}/history — the #347 authored-history-write primitive. - - Write one model-visible turn into the session's ledger AS the bound agent, - WITHOUT a generation and WITHOUT lived-turn side effects (the SillyTavern - "first message"). v1: `author="assistant"`, `effects` omitted (== "none"), - `idempotency_key` REQUIRED (per-session dedup). The server pins the body - (`AuthoredWriteRequest`, `extra="forbid"`), so `effects` / - `claimed_original_at` are sent only when non-None — never as null keys. - - Success is 201 (fresh) or 200 (idempotent replay, byte-identical body); both - return the `AuthoredTurnResponse` dict verbatim (`{author, content_chars, - injected_at, phase, seq, session_id, turn_id}` — provenance is audit-only, - never on this body). - - 404 → `AuthoredHistoryUnavailable` (hide-existence: feature-absent / - ungranted / session-absent are indistinguishable by design; the caller falls - back and NEVER capability-probes — server INV-347-1). Any other non-2xx → - `SessionApiFailed` (notably 409 `generation_active`, 422 `content_too_long` / - `validation_failed`). - """ - assert client is not None - assert session_id and isinstance(session_id, str) - assert content and isinstance(content, str) - assert idempotency_key and isinstance(idempotency_key, str) - assert author and isinstance(author, str) - body: dict[str, Any] = { - "author": author, - "content": content, - "idempotency_key": idempotency_key, - } - if effects is not None: - body["effects"] = effects - if claimed_original_at is not None: - body["claimed_original_at"] = claimed_original_at - resp = await client.post(f"/sessions/{session_id}/history", json=body) - if resp.status_code in (200, 201): - return resp.json() - if resp.status_code == 404: - raise AuthoredHistoryUnavailable(session_id=session_id) - raise SessionApiFailed(status=resp.status_code, body=resp.content) - - -async def get_session_messages( - client: httpx.AsyncClient, session_id: str -) -> dict[str, Any]: - """GET /sessions/{session_id}/messages — the session's message history. - - Un-deferred as the #347 seed read-back: a seeded turn renders as a normal - `role=assistant` message (model-invisible provenance — indistinguishable - from a lived turn on read). Returns `{session_id, items: [{seq, role, - content, ...}], next_cursor}` verbatim; owner-scoped; any non-200 → - `SessionApiFailed`. v1 reads the server default page (no pagination params — - add limit/cursor when a caller needs scrollback). - """ - assert client is not None - assert session_id and isinstance(session_id, str) - resp = await client.get(f"/sessions/{session_id}/messages") - if resp.status_code == 200: - return resp.json() - raise SessionApiFailed(status=resp.status_code, body=resp.content) diff --git a/src/ratatoskr/tier3.py b/src/ratatoskr/tier3.py index c375872..5da3f96 100644 --- a/src/ratatoskr/tier3.py +++ b/src/ratatoskr/tier3.py @@ -9,7 +9,7 @@ posture (same as ratatoskr.sessions). Exposes three lifecycle operations: Plus a frozen ``Tier3AgentInfo`` dataclass for the response shape. The picker 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). """ diff --git a/src/ratatoskr/web/server.py b/src/ratatoskr/web/server.py index 0d6581a..ff001a9 100644 --- a/src/ratatoskr/web/server.py +++ b/src/ratatoskr/web/server.py @@ -179,17 +179,17 @@ async def _create_session_endpoint(request: Request) -> JSONResponse: try: async with client_factory() as client: + wt_client = _wt_client(client) info = await wt.create_session( - _wt_client(client), + wt_client, agent_id, end_user_id=end_user_id, bifrost=bifrost, consumer_key=consumer_key if bifrost else None, ) # #347 authored first-message: seed the agent's preset opening (best-effort; - # never blocks create). first_message is a slice-3 hand-rolled path — it - # reuses the raw transport (its default bearer), not the adapter client. - await seed_preset_first_message(client, info["session_id"], agent_id) + # never blocks create). Routed through the wt adapter (slice-3). + await seed_preset_first_message(wt_client, info["session_id"], agent_id) except AgentNotFound: return JSONResponse({"error_code": "agent_not_found"}, status_code=404) except BifrostConsumerKeyMissing: diff --git a/src/ratatoskr/wt.py b/src/ratatoskr/wt.py index 8f09c4f..6481d85 100644 --- a/src/ratatoskr/wt.py +++ b/src/ratatoskr/wt.py @@ -33,14 +33,15 @@ from typing import Any import httpx 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 -# 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. from .sessions import ( AgentNotFound, + AuthoredHistoryUnavailable, BifrostBinding, BifrostConsumerKeyMissing, BifrostHandshakeFailed, @@ -342,3 +343,71 @@ async def cancel_turn( except ApiError as exc: # INV-CUT-2 default: an undiscriminated ApiError on this route → SessionApiFailed. 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. (A non-finite axis is the + caller's to reject; the SDK raises `ConfigurationError` pre-HTTP and the CLI + surface pre-validates finiteness before calling.) + """ + assert session_id and isinstance(session_id, str) + 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 diff --git a/tests/test_first_message.py b/tests/test_first_message.py index a21384f..3938243 100644 --- a/tests/test_first_message.py +++ b/tests/test_first_message.py @@ -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 hashlib -import json +from typing import Any, cast -import httpx import pytest -import respx +import worldtree_sdk as wtsdk +from worldtree_sdk import ApiError, WorldtreeClient from ratatoskr.first_message import ( 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: """first_message contract — preset_for (dict lookup).""" @@ -36,120 +72,70 @@ class TestPresetFor: class TestSeedPresetFirstMessage: """first_message contract — seed_preset_first_message (best-effort #347 seed).""" - @respx.mock async def test_seeds_preset(self) -> None: - """seeds_preset [happy,tracer]: preset agent → one history POST, correct body.""" + """seeds_preset [happy,tracer]: preset agent → one write_history, correct entry.""" content = FIRST_MESSAGE_PRESETS["ratatoskr:sindra"] key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12] - route = respx.post("https://w.example/sessions/s1/history").mock( - return_value=httpx.Response( - 201, - json={ - "author": "assistant", - "seq": 0, - "phase": "seeded", - "turn_id": "t1", - "session_id": "s1", - "content_chars": len(content), - "injected_at": "2026-07-06T00:00:00+00:00", - }, - ) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra") + fake = _FakeSessions(result={"seq": 0, "phase": "seeded"}) + result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra") assert result == content - assert route.call_count == 1 # POST-002: exactly one history POST - assert json.loads(route.calls[0].request.content) == { + assert len(fake.calls) == 1 # POST-002: exactly one history write + session_id, entry = fake.calls[0] + assert session_id == "s1" + assert entry == { "author": "assistant", "content": content, "idempotency_key": key, } - @respx.mock - async def test_no_preset_zero_http(self) -> None: - """no_preset_zero_http [happy]: no-preset agent → None, ZERO HTTP (INV-002).""" - route = respx.post("https://w.example/sessions/s1/history").mock( - return_value=httpx.Response(201, json={}) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - result = await seed_preset_first_message(client, "s1", "mimir") + async def test_no_preset_zero_write(self) -> None: + """no_preset_zero_write [happy]: no-preset agent → None, ZERO write (INV-002).""" + fake = _FakeSessions() + result = await seed_preset_first_message(_wt(fake), "s1", "mimir") assert result is None - assert not route.called + assert fake.calls == [] - @respx.mock async def test_feature_absent_swallowed(self) -> None: - """feature_absent_swallowed [error]: 404 hide-existence → None, no raise (INV-001).""" - respx.post("https://w.example/sessions/s1/history").mock( - return_value=httpx.Response(404, json={"error_code": "session_not_found"}) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra") + """feature_absent_swallowed [error]: 404 → AuthoredHistoryUnavailable → None (INV-001).""" + fake = _FakeSessions(error=ApiError("session_not_found", "no", status=404)) + result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra") 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: - """session_api_failed_swallowed [error]: 409 → None, no raise (INV-001).""" - respx.post("https://w.example/sessions/s1/history").mock( - return_value=httpx.Response(409, json={"error_code": "generation_active"}) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra") + """session_api_failed_swallowed [error]: 409 → SessionApiFailed → None (INV-001).""" + fake = _FakeSessions(error=ApiError("generation_active", "busy", status=409)) + result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra") assert result is None - @respx.mock async def test_transport_error_swallowed(self) -> None: - """transport_error_swallowed [error]: httpx.ConnectError → None, no raise (INV-001).""" - respx.post("https://w.example/sessions/s1/history").mock( - side_effect=httpx.ConnectError("boom") - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra") + """transport_error_swallowed [error]: SDK ConnectFailed → None, no raise (INV-001).""" + fake = _FakeSessions(error=wtsdk.ConnectFailed("connect_failed", "boom", status=0)) + result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra") assert result is None - @respx.mock async def test_unexpected_exception_swallowed(self) -> None: """unexpected_exception [error]: write raises ValueError → None (broad never-raise).""" - respx.post("https://w.example/sessions/s1/history").mock( - side_effect=ValueError("unexpected") - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra") + fake = _FakeSessions(error=ValueError("unexpected")) + result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra") assert result is None async def test_cancellation_propagates(self) -> None: """cancellation_propagates [error]: CancelledError from the write is RE-RAISED.""" - import ratatoskr.first_message as fm + fake = _FakeSessions(error=asyncio.CancelledError()) + with pytest.raises(asyncio.CancelledError): + await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra") - async def _cancel(*_a: object, **_k: object) -> None: - raise asyncio.CancelledError + async def test_malformed_agent_id_no_write(self) -> None: + """malformed_agent_id [adversarial]: non-str or empty agent_id → None; no write; no raise.""" + fake = _FakeSessions() + assert await seed_preset_first_message(_wt(fake), "s1", 123) is None # type: ignore[arg-type] + assert await seed_preset_first_message(_wt(fake), "s1", "") is None + assert fake.calls == [] - orig = fm.write_authored_history - fm.write_authored_history = _cancel # type: ignore[assignment] - try: - async with httpx.AsyncClient(base_url="https://w.example") as client: - with pytest.raises(asyncio.CancelledError): - await seed_preset_first_message(client, "s1", "ratatoskr:sindra") - finally: - fm.write_authored_history = orig # type: ignore[assignment] - - @respx.mock - async def test_malformed_agent_id_no_http(self) -> None: - """malformed_agent_id [adversarial]: non-str or empty agent_id → None; no HTTP; no raise.""" - route = respx.post(url__regex=r".*/history$").mock( - return_value=httpx.Response(201, json={}) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - assert await seed_preset_first_message(client, "s1", 123) is None # type: ignore[arg-type] - assert await seed_preset_first_message(client, "s1", "") is None - assert not route.called - - @respx.mock async def test_empty_session_id(self) -> None: - """empty_session_id [adversarial]: "" → None (soft guard); no HTTP; no raise.""" - route = respx.post("https://w.example/sessions/s1/history").mock( - return_value=httpx.Response(201, json={}) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - result = await seed_preset_first_message(client, "", "ratatoskr:sindra") + """empty_session_id [adversarial]: "" → None (soft guard); no write; no raise.""" + fake = _FakeSessions() + result = await seed_preset_first_message(_wt(fake), "", "ratatoskr:sindra") assert result is None - assert not route.called + assert fake.calls == [] diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 86e0cb2..aeebc27 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -7,16 +7,10 @@ import respx from ratatoskr.sessions import ( AgentInfo, AgentNotAvailable, - AgentNotFound, - AuthoredHistoryUnavailable, AuthScopeDenied, - BifrostBinding, - BifrostConsumerKeyMissing, - BifrostHandshakeFailed, PersonaNotConfigured, SessionApiFailed, create_character, - create_session, delete_character, endpoint_for_plane, get_capabilities, @@ -24,522 +18,25 @@ from ratatoskr.sessions import ( get_me, get_persona_state, get_session_bifrost, - get_session_messages, list_agents, 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": }.""" - 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=).""" - 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: """Issue #17 — endpoint_for_plane: plane name → Worldtree-visible base URL.""" def test_memory_plane_maps_to_8391(self) -> None: """memory [tracer]: 'memory' → http://:8391 (POST-001).""" - assert ( - endpoint_for_plane("memory", "10.100.10.50") - == "http://10.100.10.50:8391" - ) + assert endpoint_for_plane("memory", "10.100.10.50") == "http://10.100.10.50:8391" def test_affect_plane_maps_to_8390(self) -> None: """affect: 'affect' → http://:8390 (POST-001).""" - assert ( - endpoint_for_plane("affect", "10.100.10.50") - == "http://10.100.10.50:8390" - ) + assert endpoint_for_plane("affect", "10.100.10.50") == "http://10.100.10.50:8390" def test_combined_plane_maps_to_8392(self) -> None: """combined [#18 composite]: 'combined' → http://:8392 (POST-001).""" - assert ( - endpoint_for_plane("combined", "10.100.10.50") - == "http://10.100.10.50:8392" - ) + assert endpoint_for_plane("combined", "10.100.10.50") == "http://10.100.10.50:8392" def test_unknown_plane_raises_value_error(self) -> None: """unknown_plane [adversarial]: any other plane → ValueError (PRE-001).""" @@ -636,9 +133,7 @@ class TestListAgents: @respx.mock async def test_happy_empty(self) -> None: """happy_empty: 200 with [] returns empty list (no error).""" - respx.get("https://w.example/agents").mock( - return_value=httpx.Response(200, json=[]) - ) + respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[])) async with httpx.AsyncClient(base_url="https://w.example") as client: agents = await list_agents(client) assert agents == [] @@ -1026,9 +521,7 @@ class TestTransientCharacters: @respx.mock async def test_delete_204(self) -> None: """delete [happy]: 204 → None.""" - respx.delete("https://w.example/characters/char_x").mock( - return_value=httpx.Response(204) - ) + respx.delete("https://w.example/characters/char_x").mock(return_value=httpx.Response(204)) async with httpx.AsyncClient(base_url="https://w.example") as client: assert await delete_character(client, "char_x") is None @@ -1042,213 +535,3 @@ class TestTransientCharacters: with pytest.raises(SessionApiFailed) as exc: await create_character(client, {"name": "H"}) assert exc.value.status == 403 - - -class TestSetPersonaState: - """#2 contract — set_persona_state (POST /sessions/{id}/persona_state).""" - - @respx.mock - async def test_happy_204(self) -> None: - """happy [happy,tracer]: freeform snapshot body; 204 → None.""" - import json as _json - - route = respx.post("https://w.example/sessions/s1/persona_state").mock( - return_value=httpx.Response(204) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - result = await set_persona_state( - client, "s1", {"pad": {"pleasure": 0.4, "arousal": 0.1, "dominance": -0.2}} - ) - assert result is None - assert _json.loads(route.calls[0].request.content) == { - "pad": {"pleasure": 0.4, "arousal": 0.1, "dominance": -0.2} - } - - @respx.mock - async def test_non_204_raises(self) -> None: - """non_204 [error]: 422 (bad snapshot shape) → SessionApiFailed(422).""" - respx.post("https://w.example/sessions/s1/persona_state").mock( - return_value=httpx.Response(422, json={"error_code": "validation_failed"}) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - with pytest.raises(SessionApiFailed) as exc: - await set_persona_state(client, "s1", {"pad": [1, 2, 3]}) - assert exc.value.status == 422 - - -_AUTHORED_ACK = { - "author": "assistant", - "content_chars": 5, - "injected_at": "2026-07-06T12:00:00+00:00", - "phase": "seeded", - "seq": 0, - "session_id": "s1", - "turn_id": "t1", -} - - -class TestWriteAuthoredHistory: - """write_authored_history — #347 POST /sessions/{id}/history (contract #2 amendment).""" - - @respx.mock - async def test_happy_fresh_201(self) -> None: - """happy_fresh_201 [happy,tracer]: 201 → ack verbatim; minimal body.""" - import json as _json - - route = respx.post("https://w.example/sessions/s1/history").mock( - return_value=httpx.Response(201, json=_AUTHORED_ACK) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - result = await write_authored_history( - client, "s1", content="hello", idempotency_key="k1" - ) - assert result == _AUTHORED_ACK - assert _json.loads(route.calls[0].request.content) == { - "author": "assistant", - "content": "hello", - "idempotency_key": "k1", - } - - @respx.mock - async def test_happy_replay_200(self) -> None: - """happy_replay_200 [happy]: 200 replay (byte-identical body) → dict verbatim.""" - respx.post("https://w.example/sessions/s1/history").mock( - return_value=httpx.Response(200, json=_AUTHORED_ACK) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - result = await write_authored_history( - client, "s1", content="hello", idempotency_key="k1" - ) - assert result == _AUTHORED_ACK - - @respx.mock - async def test_body_includes_effects(self) -> None: - """body_includes_effects [trace]: effects + claimed_original_at appear iff non-None.""" - import json as _json - - route = respx.post("https://w.example/sessions/s1/history").mock( - return_value=httpx.Response(201, json=_AUTHORED_ACK) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - await write_authored_history( - client, - "s1", - content="hi", - idempotency_key="k1", - effects="none", - claimed_original_at="2020-01-01T00:00:00Z", - ) - assert _json.loads(route.calls[0].request.content) == { - "author": "assistant", - "content": "hi", - "idempotency_key": "k1", - "effects": "none", - "claimed_original_at": "2020-01-01T00:00:00Z", - } - - @respx.mock - async def test_hide_existence_404(self) -> None: - """hide_existence_404 [error]: 404 → AuthoredHistoryUnavailable (NOT SessionApiFailed).""" - respx.post("https://w.example/sessions/s1/history").mock( - return_value=httpx.Response(404, json={"error_code": "session_not_found"}) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - with pytest.raises(AuthoredHistoryUnavailable) as exc: - await write_authored_history(client, "s1", content="hi", idempotency_key="k1") - assert exc.value.session_id == "s1" - - @respx.mock - async def test_generation_active_409(self) -> None: - """generation_active_409 [error]: 409 → SessionApiFailed(409).""" - respx.post("https://w.example/sessions/s1/history").mock( - return_value=httpx.Response(409, json={"error_code": "generation_active"}) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - with pytest.raises(SessionApiFailed) as exc: - await write_authored_history(client, "s1", content="hi", idempotency_key="k1") - assert exc.value.status == 409 - - @respx.mock - async def test_content_too_long_422(self) -> None: - """content_too_long_422 [error]: 422 → SessionApiFailed(422).""" - respx.post("https://w.example/sessions/s1/history").mock( - return_value=httpx.Response(422, json={"error_code": "content_too_long"}) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - with pytest.raises(SessionApiFailed) as exc: - await write_authored_history(client, "s1", content="x", idempotency_key="k1") - assert exc.value.status == 422 - - @respx.mock - async def test_empty_content(self) -> None: - """empty_content [adversarial]: content="" → AssertionError; no HTTP issued.""" - route = respx.post("https://w.example/sessions/s1/history").mock( - return_value=httpx.Response(201, json=_AUTHORED_ACK) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - with pytest.raises(AssertionError): - await write_authored_history(client, "s1", content="", idempotency_key="k1") - assert not route.called - - @respx.mock - async def test_empty_idempotency_key(self) -> None: - """empty_idempotency_key [adversarial]: key="" → AssertionError; no HTTP issued.""" - route = respx.post("https://w.example/sessions/s1/history").mock( - return_value=httpx.Response(201, json=_AUTHORED_ACK) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - with pytest.raises(AssertionError): - await write_authored_history(client, "s1", content="hi", idempotency_key="") - assert not route.called - - @respx.mock - async def test_empty_session_id(self) -> None: - """empty_session_id [adversarial]: session_id="" → AssertionError; no HTTP issued.""" - route = respx.post("https://w.example/sessions/s1/history").mock( - return_value=httpx.Response(201, json=_AUTHORED_ACK) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - with pytest.raises(AssertionError): - await write_authored_history(client, "", content="hi", idempotency_key="k1") - assert not route.called - - -class TestGetSessionMessages: - """#2 contract (amendment 2026-07-06) — get_session_messages (GET /sessions/{id}/messages).""" - - @respx.mock - async def test_happy(self) -> None: - """happy [happy,tracer]: 200 {session_id, items, next_cursor} → dict verbatim.""" - payload = { - "session_id": "s1", - "items": [{"seq": 0, "role": "assistant", "content": "hello there"}], - "next_cursor": None, - } - respx.get("https://w.example/sessions/s1/messages").mock( - return_value=httpx.Response(200, json=payload) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - result = await get_session_messages(client, "s1") - assert result == payload - - @respx.mock - async def test_not_found_404(self) -> None: - """not_found_404 [error]: 404 → SessionApiFailed(404).""" - respx.get("https://w.example/sessions/s1/messages").mock( - return_value=httpx.Response(404, json={"error_code": "session_not_found"}) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - with pytest.raises(SessionApiFailed) as exc: - await get_session_messages(client, "s1") - assert exc.value.status == 404 - - @respx.mock - async def test_empty_session_id(self) -> None: - """empty_session_id [adversarial]: "" → AssertionError; no HTTP issued.""" - route = respx.get("https://w.example/sessions/s1/messages").mock( - return_value=httpx.Response(200, json={}) - ) - async with httpx.AsyncClient(base_url="https://w.example") as client: - with pytest.raises(AssertionError): - await get_session_messages(client, "") - assert not route.called diff --git a/tests/test_wt.py b/tests/test_wt.py index 2abe79b..ac24734 100644 --- a/tests/test_wt.py +++ b/tests/test_wt.py @@ -17,10 +17,11 @@ from typing import Any, cast import httpx import pytest import worldtree_sdk as wtsdk -from worldtree_sdk import ApiError, CancelResult, WorldtreeClient +from worldtree_sdk import ApiError, CancelResult, PadState, WorldtreeClient from ratatoskr.sessions import ( AgentNotFound, + AuthoredHistoryUnavailable, BifrostBinding, BifrostConsumerKeyMissing, BifrostHandshakeFailed, @@ -46,8 +47,10 @@ from ratatoskr.wt import ( get_session_messages, get_session_tools, list_sessions, + set_persona_state, stream_turn, translate_error, + write_authored_history, ) @@ -101,6 +104,12 @@ class _FakeSessions: async def cancel_turn(self, *args: Any, **kwargs: Any) -> Any: 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: def __init__(self, sessions: _FakeSessions) -> None: @@ -440,3 +449,72 @@ class TestCancelTurn: with pytest.raises(SessionApiFailed) as ei: await cancel_turn(_wt(fake), "s", 42) 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 + + +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 diff --git a/uv.lock b/uv.lock index e0895c2..d56d824 100644 --- a/uv.lock +++ b/uv.lock @@ -472,7 +472,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.21.10" +version = "0.21.11" source = { editable = "." } dependencies = [ { name = "httpx" },