From af07a2329ad9d0035cdc61ea7246b84fed565170 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Tue, 30 Jun 2026 23:57:09 -0700 Subject: [PATCH] =?UTF-8?q?feat(#2):=20Tier-2=20=E2=80=94=20transient=20ch?= =?UTF-8?q?aracters=20+=20persona-state=20write;=20audit=20converges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 coverage-audit: the last in-scope client I/O points. The audit now CONVERGES — REST 17/40 covered with zero in-scope gaps (23 excluded-by- design), SSE 11/11, Bifrost planes 8/8. - sessions.py: list_character_models / create_character / get_character_state / delete_character (#161, character.read/write) + set_persona_state (POST /sessions/{id}/persona_state — freeform body, unpinned in the frozen surface). 200/201 -> dict (or None on 204), off-status -> SessionApiFailed. - cli.py: two one-shot probes (mirror --whoami): --characters (CRUD lifecycle report) + --set-persona-pad "p,a,d" (requires --session). New ParsedArgs.characters/set_persona_pad + probe mutual-exclusion. - Contract #2 amended (5 FNs) + validated. TDD: 7 wrapper + 5 cli tests. Suite 573 green; touched code ruff-clean. - Char read side live-proven (GET /models/available-for-characters -> 200). Coverage-map: convergence frontier CLOSED — scope-A "done" (every frozen I/O point classified) is met; ratatoskr cuts v1 when Worldtree tags 1.0. --- docs/contracts/issues/2.contract.md | 59 +++++++++++++++ docs/coverage-map.md | 44 +++++++---- persistent-memory.md | 2 + pyproject.toml | 2 +- src/ratatoskr/cli.py | 111 +++++++++++++++++++++++++++- src/ratatoskr/sessions.py | 77 +++++++++++++++++++ tests/test_cli.py | 60 +++++++++++++++ tests/test_sessions.py | 91 +++++++++++++++++++++++ uv.lock | 2 +- 9 files changed, 427 insertions(+), 21 deletions(-) diff --git a/docs/contracts/issues/2.contract.md b/docs/contracts/issues/2.contract.md index 7d2f066..81528e7 100644 --- a/docs/contracts/issues/2.contract.md +++ b/docs/contracts/issues/2.contract.md @@ -313,3 +313,62 @@ TESTS: not_bound_404 [error]: 404 session_not_bifrost_bound → SessionApiFailed(status=404) empty_admin_key [adversarial]: admin_key="" → AssertionError; no HTTP issued ``` + +## Amendment 2026-07-01 — Tier-2: transient characters + persona-state write (v1 coverage-audit) + +The last in-scope client I/O points. Transient-character CRUD (#161) surfaced +via a `--characters` one-shot lifecycle probe; persona-state write surfaced via +`--set-persona-pad "p,a,d"` (requires `--session`). All mirror the existing +wrappers: parsed dict verbatim (or None on 204), any off-status → SessionApiFailed. +**Note:** `set_persona_state`'s request body is FREEFORM — the frozen OpenAPI 2.2.0 +declares no request schema and the prose spec documents only the GET counterpart, +so the caller supplies the snapshot shape (`--set-persona-pad` sends `{pad:[…]}`). + +```contract +FN list_character_models(client) -> dict[str, Any] +BRIEF: GET /models/available-for-characters (character.read). Returns {items:[{name, description, thinking}]}. Non-200 → SessionApiFailed. +PRE: [PRE-001 hard] client is not None +POST: [POST-001 return_value] on 200 returns resp.json() unmodified +STEPS: + 1. [sequential, prescriptive] resp = await client.get("/models/available-for-characters"); IF 200 RETURN resp.json(); ELSE RAISE SessionApiFailed +TESTS: + list_models [happy,tracer]: 200 {items:[{name:"fast"}]} → dict verbatim + +FN create_character(client, character: dict, *, state: dict | None = None) -> dict[str, Any] +BRIEF: POST /characters (character.write). Body {character, state}. Returns 201 {character_id, ttl_expires_at}; non-201 → SessionApiFailed. +PRE: [PRE-001 hard] client is not None; [PRE-002 hard] character is a non-empty dict +POST: [POST-001 return_value] on 201 returns resp.json(); [POST-002 side_effect] outbound body == {"character": , "state": } +STEPS: + 1. [sequential, prescriptive] resp = await client.post("/characters", json={"character": character, "state": state}); IF 201 RETURN resp.json(); ELSE RAISE SessionApiFailed +TESTS: + create [happy]: 201 → {character_id}; body is {character, state:null} + create_403 [error]: 403 auth_scope_denied → SessionApiFailed(403) + +FN get_character_state(client, character_id: str) -> dict[str, Any] +BRIEF: GET /characters/{id}/state (character.read). Live PAD/emotions snapshot; refreshes TTL. Non-200 → SessionApiFailed. +PRE: [PRE-001 hard] client not None; [PRE-002 hard] character_id non-empty str +POST: [POST-001 return_value] on 200 returns resp.json() +STEPS: + 1. [sequential, prescriptive] resp = await client.get(f"/characters/{character_id}/state"); IF 200 RETURN resp.json(); ELSE RAISE SessionApiFailed +TESTS: + get_state [happy]: 200 {pad:[...]} → dict verbatim + +FN delete_character(client, character_id: str) -> None +BRIEF: DELETE /characters/{id} (character.write). 200/204 → None; other → SessionApiFailed. +PRE: [PRE-001 hard] client not None; [PRE-002 hard] character_id non-empty str +POST: [POST-001 return_value] on 200/204 returns None +STEPS: + 1. [sequential, prescriptive] resp = await client.delete(f"/characters/{character_id}"); IF status in (200,204) RETURN None; ELSE RAISE SessionApiFailed +TESTS: + delete [happy]: 204 → None + +FN set_persona_state(client, session_id: str, snapshot: dict) -> None +BRIEF: POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection). Request body is the FREEFORM snapshot (caller-supplied; unpinned in the frozen surface). 204 → None; other → SessionApiFailed. +PRE: [PRE-001 hard] client not None; [PRE-002 hard] session_id non-empty str; [PRE-003 hard] snapshot is a dict +POST: [POST-001 return_value] on 204 returns None; [POST-002 side_effect] outbound body == snapshot verbatim +STEPS: + 1. [sequential, prescriptive] resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot); IF 204 RETURN None; ELSE RAISE SessionApiFailed +TESTS: + happy [happy]: 204 → None; body == {"pad":[...]} verbatim + non_204 [error]: 422 → SessionApiFailed(422) +``` diff --git a/docs/coverage-map.md b/docs/coverage-map.md index 98ae587..a912b3e 100644 --- a/docs/coverage-map.md +++ b/docs/coverage-map.md @@ -48,7 +48,7 @@ resolved (§ Surface 1, scope-resolution table). | Surface | Points | ✅ covered-live | ⬜ gap (in-scope) | 🚫 excluded-by-design | |---|---|---|---|---| -| REST (OpenAPI 2.2.0, path groups) | 40 | 12 | 6 | 22 | +| REST (OpenAPI 2.2.0, path groups) | 40 | 17 | 0 | 23 | | SSE events | 11 | 11 | 0 | 0 | | Bifrost provider planes | 8 verbs | 8 | 0 | (10 gated verbs deferred) | @@ -80,6 +80,11 @@ sub-gap). | `GET /sessions/{id}/tools` | ✅ | `sessions.py:411` `get_session_tools` → `tui.py` `_hydrate_session_tools` | owner-scoped tool inventory in the TUI Tools pane (#183) | | `GET /admin/sessions/{id}/bifrost` | ✅ | `sessions.py:428` `get_session_bifrost` → `tui.py` `_hydrate_bifrost_state` | admin-scoped BifrostState pane (#176); admin key (`RATATOSKR_ADMIN_API_KEY`); live-auth-proven | | `GET /admin/events` (SSE) | ✅ | `sse_client.py` `stream_admin_events` → `tui.py` `_stream_admin_events` | admin lifecycle SSE stream (#11), session-filtered AdminEvents pane; admin key; live-auth-proven | +| `GET /models/available-for-characters` | ✅ | `sessions.py` `list_character_models` → `cli.py` `--characters` | character-capable model profiles (#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) | +| `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) | **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): @@ -91,21 +96,20 @@ on the same path is an unwired frontier item — see frontier Tier 1): - `GET /agents/{id}` — consumer-agent lookup (`GET /agents/:` with the owner key) is **manual-curl-only**, not in code. -### In-scope gaps — the convergence frontier (debug-observability path) +### In-scope gaps — CONVERGED (zero remaining, 2026-07-01) -**Tier 1 — the debug-observability core (design-brief'd for v1, unbuilt):** +**Every in-scope REST I/O point is now covered.** The frontier that opened this +audit (the design-brief §5 observability panes + the presenter-wiring sub-gaps + +the Tier-2 tail) is fully closed: -| Endpoint | Status | Why in-scope | -|---|---|---| -| `GET /admin/sessions/{id}/tools` | ⬜ | admin variant of the Tools inventory — **covered-by-alternative** via the owner-scoped `GET /sessions/{id}/tools` (✅); this admin variant remains a gap only for cross-user operator debug | -| (`GET /sessions` picker · resume) | ⬜ | sub-gaps above — presenter-wiring only, wrappers exist | +- Session picker + SSE-resume — wired (`v0.18.5`–`.7`). +- Persona · Tools · BifrostState · AdminEvents panes — all built + live (`v0.18.x`–`v0.19.0`). +- Transient-characters CRUD + persona-state write — consumed via `--characters` / + `--set-persona-pad` (`v0.19.1`). -**Tier 2 — rounds out I/O coverage under A (postdates the design-brief):** - -| Endpoint | Status | Why in-scope | -|---|---|---| -| `POST /sessions/{id}/persona_state` (write) | ⬜ | affect-injection is debug-relevant; pairs with our provider affect plane | -| `POST /characters` · `DELETE /characters/{id}` · `GET /characters/{id}/state` · `GET /models/available-for-characters` | ⬜ | transient-characters (Echo) is a session-creation **routing path** a debug client should be able to drive a turn through | +The only remaining not-consumed in-scope method is `GET /agents/{id}` (consumer- +agent lookup, manual-curl-only) — a sub-method on an already-✅ path group, not a +path-group gap. Everything else is covered or excluded-by-design below. ### Excluded by design — the design-brief negative clauses @@ -115,6 +119,7 @@ on the same path is an unwired frontier item — see frontier Tier 1): | `GET /sessions/{id}/messages` (history) | 🚫 | §6: single-session live transcript, no history fetch | | `GET /sessions/{id}` | 🚫 | session detail — identity is footer-visible, no detail view | | `GET /sessions/{id}/tool-events` | 🚫 | §5: tool calls observed **inline from SSE** `tool_start`/`tool_result`; persisted-events endpoint is opt-in only | +| `GET /admin/sessions/{id}/tools` | 🚫 | **covered-by-alternative** — the owner-scoped `GET /sessions/{id}/tools` (✅) serves the Tools inventory; this admin variant is only for cross-user operator debug, out of the single-session focus (§6) | | `GET/POST /admin/keys` · `DELETE/POST /admin/keys/{id}` · `POST /admin/keys/{id}/rotate` · `DELETE/POST /admin/keys/bulk` · `POST /admin/keys/bulk/rotate` | 🚫 | §6: **NOT a Worldtree-admin tool** (key mgmt) | | `POST /admin/sessions/{id}/retire` | 🚫 | admin session mutation | | `POST /admin/persona/{archive,erase}` | 🚫 | admin persona GDPR ops (new in b2) | @@ -199,7 +204,13 @@ starts exercising them. --- -## Convergence frontier (the v1 to-do) +## Convergence frontier (the v1 to-do) — CLOSED 2026-07-01 + +**Every in-scope I/O point is covered.** The frontier is empty: REST 17/40 ✅ +with **zero in-scope gaps** (the other 23 REST path-groups are excluded-by-design), +SSE 11/11, Bifrost provider planes 8/8. v1 convergence (per scope A: "every +frozen I/O point classified, zero unaccounted") is **met** — ratatoskr cuts v1 +when Worldtree tags 1.0. The arc, for the record: **Tier 1 — debug-observability core:** @@ -218,8 +229,9 @@ starts exercising them. 5. ✅ **DONE** — `GET /sessions/{id}/tools` (`v0.18.9`, owner-scoped tool inventory in the TUI Tools pane). -6. **Transient-characters routing** (4 endpoints) + **`POST /sessions/{id}/persona_state`** - — the only remaining in-scope client I/O points. +6. ✅ **DONE** — Transient-characters CRUD (4 endpoints) + `POST /sessions/{id}/persona_state` + (`v0.19.1`, `--characters` + `--set-persona-pad` one-shot probes). The last + in-scope client I/O points. --- diff --git a/persistent-memory.md b/persistent-memory.md index 1f36022..896678b 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -163,6 +163,8 @@ decision. Captures rationale that won't be obvious from code alone. - `[2026-07-01]` **AdminEvents pane SHIPPED (`v0.18.11`) — `GET /admin/events` SSE in a new TUI pane; #11 closed-by-build; Tier 1 (debug-observability core) COMPLETE.** `stream_admin_events(client, *, admin_key, last_event_id=None)` (sse_client.py) — a NEW long-lived SSE consumer for the admin lifecycle broadcast (envelope `{id,type,timestamp,data}`, 17-event v0 vocab), admin-scoped (`admin.events.read`, bearer-override), Last-Event-ID resume; non-200→SseConnectFailed, mid-drop→SseConnectionDropped; new `AdminEvent` dataclass (distinct from the turn `Event` union). New "AdminEvents" TabPane + `_format_admin_event` + `_admin_event_matches` (design-brief §6 filter: active-session events + non-heartbeat `system.*`) + `_stream_admin_events` long-lived best-effort worker (unconditional on_mount, cancelled on app exit; self-labels "not configured"/"unavailable"/"stream ended"). Reuses the admin key from the BifrostState slice. **Contract-SKIPPED** for `stream_admin_events` (out of contract #1's turn-SSE scope; spec § Admin Event Stream is the reference; well-TDD'd). TDD: 4 sse_client tests (multi-event+bearer-override, Last-Event-ID header, 403, malformed-skip) + 5 tui (format, filter, worker success/no-key/403). Suite **561 green**; my code ruff-clean (pre-existing tui.py ruff debt untouched, incl. the dead `RichText` import in `_hydrate_persona`). **LIVE-AUTH-PROVEN**: `GET /admin/events` on :8081 → HTTP 200 under the admin key (connected + streamed, idle in the 4s window — no 401/403). **Coverage: REST 12/40 ✅. Tier 1 admin/debug-observability core COMPLETE** (Persona · Tools · BifrostState · AdminEvents). AdminEvents work landed as patch `v0.18.11`; then **`v0.19.0` MINOR cut (operator-approved 2026-07-01)** publishing the milestone: **the debug-observability core is complete** (Persona · Tools · BifrostState · AdminEvents all built + consuming real endpoints — the design-brief's headline deliverable). Pre-1.0 minor = release-note-worthy (no downstream althing push needed pre-1.0); lightweight tag per the SemVer mechanics (annotated reserved for major cuts). Remaining in-scope client I/O: only Tier-2 (transient-characters routing + `POST /sessions/{id}/persona_state`). +- `[2026-07-01]` **Tier-2 SHIPPED (`v0.19.1`) — transient-characters CRUD + persona-state write; the v1 coverage-audit CONVERGES (zero in-scope gaps).** 5 wrappers in sessions.py: `list_character_models`/`create_character`/`get_character_state`/`delete_character` (#161, `character.read`/`.write` scopes) + `set_persona_state` (`POST /sessions/{id}/persona_state` — **FREEFORM body: unpinned in the frozen OpenAPI 2.2.0 + absent from the prose spec**, so the caller supplies the snapshot shape). Two one-shot CLI probes (mirror `--whoami`): `--characters` (models→create→get-state→delete lifecycle report) + `--set-persona-pad "p,a,d"` (requires `--session`; POSTs `{pad:[…]}`). New `ParsedArgs.characters`/`set_persona_pad` + probe-mode mutual-exclusion validation + `_probe_client` helper. Contract #2 amended (5 FNs, validated OK) + TDD (7 wrapper respx + 5 cli tests). Suite **573 green**; touched code ruff-clean. NOT live-proven (character scopes + the persona-write body shape unverified — the probes degrade gracefully on 403/422). **THE v1 COVERAGE-AUDIT HAS CONVERGED: REST 17/40 ✅ with ZERO in-scope gaps** (23 REST path-groups excluded-by-design + rationale), SSE 11/11, Bifrost provider planes 8/8. Scope-A "done" (every frozen I/O point classified, zero unaccounted) is **MET** — ratatoskr cuts v1 when Worldtree tags 1.0. Only not-consumed in-scope sub-method: `GET /agents/{id}` (consumer-agent lookup, manual-curl-only, on an already-✅ path group). Patch bump (Tier-2 tail; `v0.19.0` already published the core-complete milestone — a 2nd minor would be cadence-too-fast). + _41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._ _For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._ diff --git a/pyproject.toml b/pyproject.toml index 19e9287..5675358 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.19.0" +version = "0.19.1" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/cli.py b/src/ratatoskr/cli.py index 72060f8..36a76e0 100644 --- a/src/ratatoskr/cli.py +++ b/src/ratatoskr/cli.py @@ -22,10 +22,15 @@ from ratatoskr.sessions import ( BifrostConsumerKeyMissing, BifrostHandshakeFailed, SessionApiFailed, + create_character, create_session, + delete_character, endpoint_for_plane, get_capabilities, + get_character_state, get_me, + list_character_models, + set_persona_state, ) from ratatoskr.sse_client import ( AffectUpdate, @@ -106,6 +111,11 @@ class ParsedArgs: # admin-scoped inspection reads (BifrostState pane, GET /admin/sessions/…). # None when unset — the BifrostState pane then shows "admin key not configured". admin_key: str | None = None + # Tier-2 one-shot probes (like --whoami). --characters runs the transient- + # character CRUD lifecycle; --set-persona-pad "p,a,d" (with --session) writes + # a session's persona state (affect injection). + characters: bool = False + set_persona_pad: str | None = None class _ArgparseError(Exception): @@ -132,6 +142,8 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: parser.add_argument("--raw", action="store_true") parser.add_argument("--whoami", action="store_true") parser.add_argument("--admin-key", dest="admin_key") + parser.add_argument("--characters", action="store_true") + parser.add_argument("--set-persona-pad", dest="set_persona_pad", default=None) # Issue #5: required for per-end-user agents (lofn etc.); optional otherwise (mimir). parser.add_argument("--end-user-id", dest="end_user_id", default=None) # Issue #17: bind the created session to our own Bifrost provider plane. @@ -152,12 +164,23 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: # Issue #5 INV-001: --end-user-id, if passed, MUST be non-empty (mirrors --send). if ns.end_user_id is not None and not ns.end_user_id: raise UsageError("--end-user-id must be non-empty when passed") - if ns.whoami: - # Standalone boot-time probe (GET /me + /capabilities): opens no session. + if sum([ns.whoami, ns.characters, bool(ns.set_persona_pad)]) > 1: + raise UsageError("--whoami / --characters / --set-persona-pad are mutually exclusive") + if ns.whoami or ns.characters: + # Standalone one-shot probes: open no session. if ns.send is not None or ns.session or ns.new or ns.agent: raise UsageError( - "--whoami is a standalone probe (no --send/--session/--new/--agent)" + "--whoami / --characters are standalone probes " + "(no --send/--session/--new/--agent)" ) + elif ns.set_persona_pad is not None: + # Session-scoped write probe: needs a target session, nothing else. + if not ns.set_persona_pad: + raise UsageError("--set-persona-pad must be non-empty (e.g. '0.4,0.1,-0.2')") + if not ns.session: + raise UsageError("--set-persona-pad requires --session ") + if ns.send is not None or ns.new or ns.agent: + raise UsageError("--set-persona-pad takes only --session") else: if ns.session and ns.new: raise UsageError("--session and --new are mutually exclusive") @@ -231,6 +254,8 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: consumer_key=consumer_key, whoami=ns.whoami, admin_key=admin_key, + characters=ns.characters, + set_persona_pad=ns.set_persona_pad, ) @@ -631,6 +656,82 @@ async def _whoami(args: ParsedArgs) -> int: return 0 +def _probe_client(args: ParsedArgs) -> httpx.AsyncClient: + """AsyncClient for the one-shot probes (--whoami / --characters / --set-persona-pad).""" + return httpx.AsyncClient( + base_url=args.server_url, + headers={"Authorization": f"Bearer {args.api_key}", "User-Agent": USER_AGENT}, + timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=10.0), + ) + + +async def _characters_probe(args: ParsedArgs) -> int: + """--characters one-shot: exercise the transient-character CRUD lifecycle + (models → create → get-state → delete), print a report, exit. A reference- + consumer smoke of the #161 character surface (needs character.read/write).""" + assert isinstance(args, ParsedArgs) + async with _probe_client(args) as client: + try: + models = await list_character_models(client) + names = ", ".join(m.get("name", "?") for m in models.get("items", [])) + sys.stdout.write(f"character models: {names or '(none)'}\n") + created = await create_character( + client, + { + "schema_version": "1", + "name": "ratatoskr-probe", + "ocean": { + "openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.0, + "agreeableness": 0.5, "neuroticism": 0.5, + }, + "description": "ratatoskr --characters lifecycle probe", + "narrative": "A throwaway probe character.", + "voice_profile_block": "plain", + }, + ) + cid = created["character_id"] + sys.stdout.write(f"created: {cid} (ttl {created.get('ttl_expires_at')})\n") + state = await get_character_state(client, cid) + sys.stdout.write(f"state: pad={state.get('pad')}\n") + await delete_character(client, cid) + sys.stdout.write(f"deleted: {cid}\n") + except SessionApiFailed as exc: + sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n") + return 20 + except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: + sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") + return 21 + return 0 + + +async def _set_persona_probe(args: ParsedArgs) -> int: + """--set-persona-pad one-shot: POST a PAD to /sessions/{id}/persona_state + (affect injection), print the result, exit. Requires --session.""" + assert isinstance(args, ParsedArgs) + assert args.session_id is not None and args.set_persona_pad is not None + try: + pad = [float(x) for x in args.set_persona_pad.split(",")] + except ValueError: + sys.stderr.write( + "[usage_error] --set-persona-pad must be comma-separated floats " + "(e.g. '0.4,0.1,-0.2')\n" + ) + return 10 + async with _probe_client(args) as client: + try: + await set_persona_state(client, args.session_id, {"pad": pad}) + except SessionApiFailed as exc: + sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n") + return 20 + except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: + sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") + return 21 + sys.stdout.write( + f"persona_state set: session={args.session_id[-8:]} pad={pad} (204)\n" + ) + return 0 + + def main(argv: list[str] | None = None) -> int: """Sync entry point. Maps UsageError/_AuthError to exit codes BEFORE the event loop.""" assert argv is None or all(isinstance(a, str) for a in argv) @@ -648,6 +749,10 @@ def main(argv: list[str] | None = None) -> int: return int(exc.code) if exc.code is not None else 0 if args.whoami: return asyncio.run(_whoami(args)) + if args.characters: + return asyncio.run(_characters_probe(args)) + if args.set_persona_pad is not None: + return asyncio.run(_set_persona_probe(args)) if args.send_content is None: # TUI mode — lazy import preserves INV-001 (no textual in cli at module scope). from ratatoskr.tui import run_tui diff --git a/src/ratatoskr/sessions.py b/src/ratatoskr/sessions.py index 080144c..e1f6cd8 100644 --- a/src/ratatoskr/sessions.py +++ b/src/ratatoskr/sessions.py @@ -425,6 +425,83 @@ async def get_me(client: httpx.AsyncClient) -> dict[str, Any]: raise SessionApiFailed(status=resp.status_code, body=resp.content) +async def list_character_models(client: httpx.AsyncClient) -> dict[str, Any]: + """GET /models/available-for-characters — character-capable model profiles (#161). + + Requires `character.read`. Returns `{items: [{name, description, thinking}]}`. + Parsed dict verbatim; any non-200 → SessionApiFailed. + """ + assert client is not None + resp = await client.get("/models/available-for-characters") + if resp.status_code == 200: + return resp.json() + raise SessionApiFailed(status=resp.status_code, body=resp.content) + + +async def create_character( + client: httpx.AsyncClient, character: dict[str, Any], *, state: dict[str, Any] | None = None +) -> dict[str, Any]: + """POST /characters — create a transient character (#161). Requires `character.write`. + + Body is `{character, state}` (state optional — a CharacterStateSchema for + mid-conversation rehydration). Returns 201 `{character_id, ttl_expires_at}`; + any non-201 → SessionApiFailed. + """ + assert client is not None + assert isinstance(character, dict) and character + resp = await client.post("/characters", json={"character": character, "state": state}) + if resp.status_code == 201: + return resp.json() + raise SessionApiFailed(status=resp.status_code, body=resp.content) + + +async def get_character_state(client: httpx.AsyncClient, character_id: str) -> dict[str, Any]: + """GET /characters/{character_id}/state — live runtime state (#161). Requires `character.read`. + + Returns `{schema_version, pad, emotions_active, mood_drift, goal_signal_history}`; + refreshes the character's TTL. Any non-200 → SessionApiFailed. + """ + assert client is not None + assert character_id and isinstance(character_id, str) + resp = await client.get(f"/characters/{character_id}/state") + if resp.status_code == 200: + return resp.json() + raise SessionApiFailed(status=resp.status_code, body=resp.content) + + +async def delete_character(client: httpx.AsyncClient, character_id: str) -> None: + """DELETE /characters/{character_id} — remove a transient character (#161). + + Requires `character.write`. Bound sessions detach (next turn → 410 + character_not_found). 200/204 → None; any other status → SessionApiFailed. + """ + assert client is not None + assert character_id and isinstance(character_id, str) + resp = await client.delete(f"/characters/{character_id}") + if resp.status_code in (200, 204): + return 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: the frozen OpenAPI 2.2.0 declares no request + schema and the prose spec documents only the GET counterpart — so the caller + supplies the snapshot shape (e.g. `{pad: [p, a, d]}`, mirroring the GET + `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]: diff --git a/tests/test_cli.py b/tests/test_cli.py index 2ba9633..188ca0b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1633,3 +1633,63 @@ class TestWhoami: rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"]) assert rc == 20 assert "[session_api_failed]" in capsys.readouterr().err + + +class TestTier2Probes: + """--characters + --set-persona-pad one-shot probes (Tier-2: #161 + persona_state-write).""" + + def test_characters_standalone_accepted(self) -> None: + """characters_standalone: --characters alone → valid.""" + args = _parse_args(["--characters", "--api-key", "k"]) + assert args.characters is True + assert args.session_id is None + + def test_set_persona_requires_session(self) -> None: + """set_persona_requires_session [adversarial]: --set-persona-pad needs --session.""" + with pytest.raises(UsageError, match="requires --session"): + _parse_args(["--set-persona-pad", "0.4,0.1,-0.2", "--api-key", "k"]) + + def test_probes_mutually_exclusive(self) -> None: + """probes_mutually_exclusive [adversarial]: --whoami + --characters → UsageError.""" + with pytest.raises(UsageError, match="mutually exclusive"): + _parse_args(["--whoami", "--characters", "--api-key", "k"]) + + @respx.mock + def test_characters_probe_lifecycle(self, capsys: pytest.CaptureFixture[str]) -> None: + """characters_probe [happy,tracer]: models → create → state → delete; report to stdout.""" + respx.get("https://w.example/models/available-for-characters").mock( + return_value=httpx.Response(200, json={"items": [{"name": "fast"}]}) + ) + respx.post("https://w.example/characters").mock( + return_value=httpx.Response(201, json={"character_id": "char_z", "ttl_expires_at": "t"}) + ) + respx.get("https://w.example/characters/char_z/state").mock( + return_value=httpx.Response(200, json={"schema_version": "1", "pad": [0.1, 0.2, 0.3]}) + ) + del_route = respx.delete("https://w.example/characters/char_z").mock( + return_value=httpx.Response(204) + ) + rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"]) + assert rc == 0 + out = capsys.readouterr().out + assert "character models: fast" in out + assert "created: char_z" in out + assert "pad=[0.1, 0.2, 0.3]" in out + assert "deleted: char_z" in out + assert del_route.call_count == 1 # lifecycle cleaned up + + @respx.mock + def test_set_persona_probe(self, capsys: pytest.CaptureFixture[str]) -> None: + """set_persona_probe [happy,tracer]: POST pad to /sessions/{id}/persona_state; 204.""" + import json as _json + + route = respx.post("https://w.example/sessions/s1/persona_state").mock( + return_value=httpx.Response(204) + ) + rc = main( + ["--set-persona-pad", "0.4,0.1,-0.2", "--session", "s1", + "--api-key", "k", "--server", "https://w.example"] + ) + assert rc == 0 + assert "persona_state set" in capsys.readouterr().out + assert _json.loads(route.calls[0].request.content) == {"pad": [0.4, 0.1, -0.2]} diff --git a/tests/test_sessions.py b/tests/test_sessions.py index a8b6dbc..ef9e30a 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -16,15 +16,20 @@ from ratatoskr.sessions import ( PersonaNotConfigured, SessionApiFailed, SessionPage, + create_character, create_session, + delete_character, endpoint_for_plane, get_capabilities, + get_character_state, get_me, get_persona_state, get_session_bifrost, get_session_tools, list_agents, + list_character_models, list_sessions, + set_persona_state, ) @@ -1102,3 +1107,89 @@ class TestGetSessionBifrost: with pytest.raises(AssertionError): await get_session_bifrost(client, "s1", admin_key="") assert route.call_count == 0 + + +class TestTransientCharacters: + """docs/contracts/issues/2.contract.md — transient-character wrappers (#161).""" + + @respx.mock + async def test_list_models(self) -> None: + """list_models [happy,tracer]: 200 → {items:[...]} verbatim.""" + respx.get("https://w.example/models/available-for-characters").mock( + return_value=httpx.Response(200, json={"items": [{"name": "fast", "thinking": False}]}) + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + models = await list_character_models(client) + assert models["items"][0]["name"] == "fast" + + @respx.mock + async def test_create_body_and_response(self) -> None: + """create [happy]: body is {character, state}; 201 → {character_id, ttl_expires_at}.""" + import json as _json + + route = respx.post("https://w.example/characters").mock( + return_value=httpx.Response(201, json={"character_id": "char_x", "ttl_expires_at": "t"}) + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + out = await create_character(client, {"schema_version": "1", "name": "H"}) + assert out["character_id"] == "char_x" + body = _json.loads(route.calls[0].request.content) + assert body == {"character": {"schema_version": "1", "name": "H"}, "state": None} + + @respx.mock + async def test_get_state(self) -> None: + """get_state [happy]: 200 → live PAD/emotions snapshot.""" + respx.get("https://w.example/characters/char_x/state").mock( + return_value=httpx.Response(200, json={"schema_version": "1", "pad": [0.4, 0.1, -0.2]}) + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + state = await get_character_state(client, "char_x") + assert state["pad"] == [0.4, 0.1, -0.2] + + @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) + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + assert await delete_character(client, "char_x") is None + + @respx.mock + async def test_create_403_scope(self) -> None: + """create_403 [error]: key lacks character.write → SessionApiFailed(403).""" + respx.post("https://w.example/characters").mock( + return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"}) + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + 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": [0.4, 0.1, -0.2]}) + assert result is None + assert _json.loads(route.calls[0].request.content) == {"pad": [0.4, 0.1, -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 diff --git a/uv.lock b/uv.lock index 3e438ea..1f94ece 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,7 +1052,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.19.0" +version = "0.19.1" source = { editable = "." } dependencies = [ { name = "httpx" },