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.
30 KiB
contract_version, target_module, scope, depends_on, used_by, language, complexity, estimated_loc, confidence, assumptions, open_questions, prd, dependencies
| contract_version | target_module | scope | depends_on | used_by | language | complexity | estimated_loc | confidence | assumptions | open_questions | prd | dependencies | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2.1 | ratatoskr.sessions | Implement the Worldtree Conversation API session-lifecycle client for Ratatoskr. Two entry points: create_session (POST /sessions) and list_sessions (GET /sessions with cursor pagination), plus two shared frozen dataclasses (SessionInfo, SessionPage). Consumed by ratatoskr.cli for --send --new (single session create) and by ratatoskr.tui for the startup session picker (list). No core.* / worldtree.* imports; caller owns httpx.AsyncClient and Authorization header lifecycle. Convention-aligned with ratatoskr.sse_client (issue #1) — same posture, no shared types. |
|
|
python | low | 150 | 0.9 |
|
|
|
|
Sessions — Worldtree Conversation API session lifecycle
Context
ratatoskr.sessions is Ratatoskr's session-lifecycle client. Two entry points (create_session, list_sessions) plus two shared frozen dataclasses (SessionInfo, SessionPage). The module is the surface that ratatoskr.cli calls when --send --new mints a fresh session against Worldtree, and that ratatoskr.tui calls to populate the startup picker's DataTable of existing sessions.
The module deliberately does NOT cover per-turn operations (those live in ratatoskr.sse_client), session mutation (PATCH /sessions/{id} is out of scope per design-brief §4 negative clauses), or session deletion (DELETE /sessions/{id} is admin work via sessions_cli.py).
Convention-aligned with issue #1: caller owns the httpx.AsyncClient and Authorization header; the module never imports Worldtree source; responses are parsed into typed frozen dataclasses; exception .body payloads are truncated to [:1024] at construction.
Data flow
Input:
httpx.AsyncClient(caller-owned, base_url + bearer auth on the client).agent_id: str— forcreate_session.include_archived: bool,limit: int,cursor: str | None— forlist_sessions.
Output:
create_session→SessionInfo:session_id: stragent_id: strcreated_at: str(ISO 8601 with offset)last_active: strmetadata: dict[str, Any](defaults to{}if the response omits the field — see INV-001)message_count: int | None(present from POST response;Nonewhen SessionInfo was sourced from a list item per spec §GET /sessions)name: str | None(alwaysNonewhen sourced from POST response;Noneif absent from list item; otherwise the list item's value)archived: bool(alwaysFalsewhen sourced from POST response; defaults toFalseif absent or null in a list item; otherwise the list item's value)tags: list[str](always[]when sourced from POST response; defaults to[]if absent or null in a list item; otherwise the list item's value)
list_sessions→SessionPage:items: list[SessionInfo]next_cursor: str | None(None on the last page; opaque string otherwise)
Side effects: outbound HTTP only; no disk I/O, no global state.
Invariants
- INV-001 [hard]:
create_sessionreturns aSessionInfowhosesession_id,agent_id,created_at,last_active, andmetadataare sourced from the 201 response body.metadatais taken frombody["metadata"]when present and defaults to{}when absent (defensive against minor server-side spec drift; spec example always shows it present).message_countis taken frombody["message_count"](strict — bracket access, not.get(); the spec lists it as a response field and absent should surface as KeyError rather than silently default to None). List-only fields are fixed:name=None,archived=False,tags=[]. - INV-002 [hard]:
list_sessionsreturns aSessionPagewhere everySessionInfohassession_id,agent_id,created_at,last_active, andmetadatafrom the response item (same defensivemetadatadefault as INV-001).nameisitem.get("name")(may beNone).archivedisitem.get("archived") or False— absent, explicit-null, or explicit-false all yieldFalse; explicit-true passes through. (Note:item.get(key, default)only firesdefaultfor absent keys, NOT for explicit-null values, so theor Falseform is load-bearing here.)tagsisitem.get("tags") or [](absent, explicit-null, or empty list all yield[]; a populated list passes through).message_countisNone(the list endpoint does not include it — spec §GET /sessions: "message_countis not included in list items"). - INV-003 [hard]:
list_sessionstreats cursors as opaque strings. The module never parses, base64-decodes, or constructs a cursor — it threads the server-providednext_cursorback verbatim on the next call. Per spec §Pagination ("Cursors are opaque to clients — do not parse or construct them."). - INV-004 [hard]: Both functions truncate exception
.bodypayloads to[:1024]at construction. Matches the issue #1 precedent (SseConnectFailed,CancelFailed). - INV-005 [hard]: No
core.*orworldtree.*imports. Boundary verified bytests/test_no_worldtree_imports.py. - INV-006 [hard]:
list_sessionsrejects out-of-rangelimitvalues (< 1or> 200) client-side before issuing any HTTP request. Spec §GET /sessions specifies the server returns 422 on out-of-range; the client refuses to send an obviously-invalid request rather than depending on the server to reject it.
Constraints
- [compatibility] Module must work against the spec pin (
55101e909abcd2219833266b6f905c5bc956e0f0, Worldtree v0.19.0). - [security] Module does not log full response bodies (they may carry user-readable session names + tags). Logging limited to status code + session_id when present.
- [style] Async-native. No sync entry points. Consistent with
sse_client.
Out of scope
- Bifrost binding (Worldtree issue #160).
create_sessiondoes not accept or send abifrostfield. Ratatoskr is not a Bifrost consumer; consumer-side tool injection is an advanced feature outside the dev TUI's purpose. - Ephemeral / Saga sessions. Separate session class with TTL semantics; not needed for hands-on dev probing.
GET /sessions/{id}(single fetch),PATCH /sessions/{id}(mutation),DELETE /sessions/{id}(deletion). Per design-brief §4 negative clauses; admin operations live outside Ratatoskr.GET /sessions/{id}/messages(history pagination). Deferred until the TUI needs scrollback replay;--senddoesn't need history.- Transparent multi-page iteration.
list_sessionsreturns one page; caller threadsnext_cursorfor the next call. Don't add aniter_all_sessions()until the TUI proves it needs that shape. - Server retry / backoff. Caller's policy. The module does not retry on 5xx; it surfaces failure once and returns control.
FN create_session(client: httpx.AsyncClient, agent_id: str, *, end_user_id: str | None = None) -> SessionInfo
BRIEF: POST /sessions with {"agent_id": agent_id} (and {"end_user_id": end_user_id} when non-None) to create a new conversation session. Returns SessionInfo populated from the 201 response. Per issue #5: keyword-only `end_user_id` for per-end-user agents (lofn etc.); default-None preserves the pre-#5 baseline.
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] agent_id is a non-empty string -- assert agent_id and isinstance(agent_id, str)
PRE: [PRE-003 hard, issue #5] end_user_id is None OR a non-empty string -- assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id)
POST: [POST-001 side_effect] exactly one POST to /sessions was issued; body is {"agent_id": agent_id} when end_user_id is None, OR {"agent_id": agent_id, "end_user_id": end_user_id} when non-None (issue #5 INV-002: omitting the field when None is NOT the same as sending empty)
POST: [POST-002 return_value] returns SessionInfo with session_id, agent_id, created_at, last_active, metadata populated from response -- assert all 5 fields non-None
POST: [POST-003 return_value] returns SessionInfo where message_count == response["message_count"] (typically 0 for a fresh session) and list-only fields carry the create-origin fixed defaults per INV-001 -- assert info.message_count is not None and info.name is None and info.archived is False and info.tags == []
ERROR_ROUTING:
HTTP 404 unknown_agent_id:
local_handling: raise AgentNotFound(agent_id=agent_id)
flow_control: abort
state_recovery: none (caller passed an unknown agent_id; that's a user error)
HTTP 422 validation_failed:
local_handling: raise SessionApiFailed(status=422, body=resp.content[:1024])
flow_control: abort
state_recovery: none (typically client bug; surface for debugging. Issue #5: a `end_user_id_required` 422 indicates the agent requires --end-user-id; raw label is honest, hint translation deferred.)
httpx.HTTPStatusError (other status):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content[:1024])
flow_control: abort
state_recovery: none
STEPS:
1. [setup, flexibility=prescriptive] Validate inputs per PRE-001, PRE-002, PRE-003
2. [sequential, flexibility=prescriptive] Build body = {"agent_id": agent_id}; IF end_user_id is not None: body["end_user_id"] = end_user_id
3. [sequential, flexibility=prescriptive] CALL client.post("/sessions", json=body)
tool: { destructive: false, idempotent: false, read_only: false, open_world: false }
4. [branch, flexibility=prescriptive] IF resp.status_code == 404: RAISE AgentNotFound
ELIF resp.status_code != 201: RAISE SessionApiFailed
5. [sequential] Parse resp.json() → body
6. [cleanup] 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", {}), # INV-001 defensive default
message_count=body["message_count"], # INV-001/POST-003: required, never defaulted
name=None, # INV-001 fixed for create-origin
archived=False, # INV-001 fixed for create-origin
tags=[], # INV-001 fixed for create-origin
)
TESTS:
happy_create [happy,tracer]: mock returns 201 with full body → returns SessionInfo with all create-side fields populated; list-only fields are at create-origin defaults (name=None, archived=False, tags=[])
happy_create_with_metadata [happy]: response includes metadata={"model": "glm5-turbo"} → SessionInfo.metadata == {"model": "glm5-turbo"}
request_body_shape [trace]: outbound JSON body is exactly {"agent_id": <arg>} when end_user_id omitted — no Bifrost field, no extra keys
unknown_agent_id [error]: mock returns 404 → raises AgentNotFound(agent_id="mimir")
validation_failed [error]: mock returns 422 → raises SessionApiFailed(status=422); body truncated to ≤1024 bytes
unexpected_status_truncates [error]: mock returns 500 with 5000-byte body → SessionApiFailed; .body is exactly the first 1024 bytes
empty_agent_id [adversarial]: agent_id="" → AssertionError; no HTTP issued
happy_create_with_end_user_id [happy, issue #5]: end_user_id="alice" → outbound JSON body == {"agent_id": "mimir", "end_user_id": "alice"} byte-for-byte; SessionInfo populated as today
default_omits_end_user_id [trace, issue #5]: omit end_user_id kwarg → outbound JSON body == {"agent_id": "mimir"} (no end_user_id key); preserves the pre-#5 baseline
empty_end_user_id [adversarial, issue #5]: end_user_id="" → AssertionError before HTTP (PRE-003)
FN list_sessions(client: httpx.AsyncClient, *, include_archived: bool = False, limit: int = 50, cursor: str | None = None) -> SessionPage
BRIEF: GET /sessions with cursor pagination. Returns one SessionPage. Caller threads next_cursor for subsequent pages.
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] limit is in [1, 200] -- assert 1 <= limit <= 200 (INV-006: refuse out-of-range client-side; do not depend on server 422)
PRE: [PRE-003 hard] cursor is None or a non-empty string -- assert cursor is None or (isinstance(cursor, str) and cursor)
POST: [POST-001 side_effect] exactly one GET to /sessions was issued -- assert mock_router.calls.call_count == 1
POST: [POST-002 side_effect] query string carries `limit=<limit>` always; `include_archived=true` iff caller passed include_archived=True; `cursor=<cursor>` iff caller passed a cursor -- assert URL params match
POST: [POST-003 return_value] returns SessionPage(items=[SessionInfo, ...], next_cursor=str|None) per response -- assert isinstance(result.items, list) and (result.next_cursor is None or isinstance(result.next_cursor, str))
POST: [POST-004 return_value] each SessionInfo in items has list-side fields (name, archived, tags) populated and message_count=None per INV-002 -- assert all(info.message_count is None for info in result.items)
ERROR_ROUTING:
HTTP 422 (cursor_invalid):
local_handling: parse body for error_code; raise InvalidCursor(raw=cursor) if error_code == "cursor_invalid"; else raise SessionApiFailed
flow_control: abort
state_recovery: caller policy — restart from page 1 (cursor=None)
HTTP 422 (other validation_failed):
local_handling: raise SessionApiFailed(status=422, body=resp.content[:1024])
flow_control: abort
state_recovery: none (PRE-002/003 should have caught client-side issues; server-side 422 means spec mismatch)
httpx.HTTPStatusError (other status):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content[:1024])
flow_control: abort
state_recovery: none
STEPS:
1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-003
2. [sequential, flexibility=prescriptive] Build params dict: {"limit": limit}; ADD "include_archived": "true" iff include_archived; ADD "cursor": cursor iff cursor is not None
3. [sequential, flexibility=prescriptive] CALL client.get("/sessions", params=params)
tool: { destructive: false, idempotent: true, read_only: true, open_world: false }
4. [branch, flexibility=prescriptive] IF resp.status_code == 422:
Parse body; IF body.get("error_code") == "cursor_invalid": RAISE InvalidCursor(raw=cursor)
ELSE: RAISE SessionApiFailed(status=422, body=resp.content[:1024])
ELIF resp.status_code != 200: RAISE SessionApiFailed
5. [sequential] Parse resp.json() → body
6. [loop] FOR EACH item in body["items"]: CONSTRUCT SessionInfo(
session_id=item["session_id"],
agent_id=item["agent_id"],
created_at=item["created_at"],
last_active=item["last_active"],
metadata=item.get("metadata", {}), # INV-002 defensive default
message_count=None, # not in list response per spec
name=item.get("name"), # INV-002: may be None
archived=item.get("archived") or False, # INV-002: absent/null/false → False (the `or` form is load-bearing — .get(k, default) does not fire default on explicit null)
tags=item.get("tags") or [], # INV-002: absent/null/[] → []
)
7. [cleanup] RETURN SessionPage(items=infos, next_cursor=body.get("next_cursor"))
TESTS:
happy_first_page [happy,tracer]: GET /sessions, mock returns {items: [one full session shape], next_cursor: "v1.abc..."} → SessionPage(items=[1], next_cursor="v1.abc...")
happy_last_page [happy]: mock returns {items: [...], next_cursor: null} → SessionPage with next_cursor=None
empty_results [happy]: mock returns {items: [], next_cursor: null} → SessionPage([], None)
include_archived_query [trace]: include_archived=True → URL has include_archived=true; default (include_archived=False) → URL has NO include_archived param at all (STEP 2 prescribes "ADD include_archived='true' iff include_archived" — the test asserts absence on default, not an explicit false)
cursor_threaded [trace]: cursor="opaque-from-prev-page" → URL has cursor=opaque-from-prev-page
limit_query [trace]: limit=10 → URL has limit=10
invalid_cursor_server [error]: mock returns 422 with body {"error_code":"cursor_invalid","message":"..."} → raises InvalidCursor(raw=<the cursor passed in>)
other_validation_failed [error]: mock returns 422 with body {"error_code":"validation_failed",...} → raises SessionApiFailed(status=422); body truncated
unexpected_status_truncates [error]: mock returns 500 with 5000-byte body → SessionApiFailed; .body is exactly the first 1024 bytes
limit_below_one [adversarial]: limit=0 → AssertionError; no HTTP issued
limit_above_max [adversarial]: limit=300 → AssertionError; no HTTP issued
empty_cursor [adversarial]: cursor="" → AssertionError; no HTTP issued
Amendment 2026-06-30 — boot-time introspection reads (v1 coverage-audit: capabilities+me)
The v1 coverage-audit added two read-only server-introspection endpoints as
cheap debug primitives (surfaced via a new ratatoskr --whoami one-shot). Both
mirror get_persona_state: GET, 200 → parsed dict verbatim, any non-200 →
SessionApiFailed. The frozen OpenAPI types both responses as freeform objects,
so the wrappers return dict[str, Any] (not a typed dataclass).
FN get_me(client: httpx.AsyncClient) -> dict[str, Any]
BRIEF: GET /me — the authenticated principal's identity + key metadata (spec §GET /me). Boot-time whoami: verify the key without agent-config side effects. Returns parsed JSON verbatim; spec documents {user_id, scopes, tier, display_name?, key_id?, key_label?, ...} with optional fields OMITTED (not null). Read-only, rate-exempt, no audit emission.
PRE: [PRE-001 hard] client is not None -- assert client is not None
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
ERROR_ROUTING:
HTTP non-200 (incl. 401 bad/absent key when auth enabled):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none (caller decides: bad key → re-key; degraded tier="unknown" is still a 200)
STEPS:
1. [setup, prescriptive] assert client is not None
2. [sequential, prescriptive] resp = await client.get("/me")
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy_authenticated [happy,tracer]: 200 {user_id, scopes, tier, key_id} → dict returned verbatim
anonymous_dev_mode: 200 {user_id:"anonymous", tier:"anonymous"} → dict; no key_* fields (omitted)
401_raises [error]: 401 → SessionApiFailed(status=401)
FN get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]
BRIEF: GET /capabilities — server capability discovery (spec §Ephemeral Templates). Returns {ephemeral_templates: {echo: {allowed_models, default_model, system_prompt_max_bytes}}}. Any authenticated caller may read it (no instantiate scope). Parsed dict verbatim; any non-200 → SessionApiFailed.
PRE: [PRE-001 hard] client is not None -- assert client is not None
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
ERROR_ROUTING:
HTTP non-200:
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none
STEPS:
1. [setup, prescriptive] assert client is not None
2. [sequential, prescriptive] resp = await client.get("/capabilities")
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy [happy]: 200 {ephemeral_templates:{echo:{...}}} → dict returned verbatim
non_200_raises [error]: 500 → SessionApiFailed(status=500)
Amendment 2026-07-01 — session tool introspection (v1 coverage-audit)
Owner-scoped tool-inventory read (spec #183, GET /sessions/{id}/tools),
surfaced in the TUI Tools pane on session-attach. Same shape as the other
introspection wrappers: GET, 200 → parsed dict verbatim, non-200 →
SessionApiFailed. Reachable with the consumer key (no admin scope), unlike the
admin variant GET /admin/sessions/{id}/tools.
FN get_session_tools(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]
BRIEF: GET /sessions/{session_id}/tools — owner-scoped merged tool inventory (spec #183) the LLM saw at turn-fire: {agent_id, builtin_tools: [...], bifrost_tools: [{name, description, parameters}, ...]}. Owner gate (ctx.user_id == session.user_id); cross-owner → 404 session_not_found (existence-hiding), revoked → 401 auth_revoked. Parsed dict verbatim; any non-200 → SessionApiFailed.
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] session_id is non-empty str -- assert session_id and isinstance(session_id, str)
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
ERROR_ROUTING:
HTTP non-200 (incl. 404 session_not_found cross-owner/unknown, 401 auth_revoked):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none
STEPS:
1. [setup, prescriptive] assert PRE-001, PRE-002
2. [sequential, prescriptive] resp = await client.get(f"/sessions/{session_id}/tools")
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy [happy,tracer]: 200 {agent_id, builtin_tools:[], bifrost_tools:[{name,...}]} → dict verbatim
cross_owner_404 [error]: 404 session_not_found → SessionApiFailed(status=404)
empty_session_id [adversarial]: "" → AssertionError; no HTTP issued
Amendment 2026-07-01 — admin BifrostState read (v1 coverage-audit)
Admin-scoped Bifrost dispatch-state read (spec #176, GET /admin/sessions/{id}/bifrost),
surfaced in the TUI BifrostState pane on session-attach. The first admin-key
consumer in ratatoskr: requires the admin.sessions.read scope, so the request
OVERRIDES the Authorization header with the caller-supplied admin_key (distinct
from the client's default consumer key). Same result-shape convention as the
other introspection wrappers: 200 → parsed dict verbatim, non-200 → SessionApiFailed.
FN get_session_bifrost(client: httpx.AsyncClient, session_id: str, *, admin_key: str) -> dict[str, Any]
BRIEF: GET /admin/sessions/{session_id}/bifrost — admin-scoped live Bifrost binding (spec #176): {endpoint_url, consumer_id, connected, capabilities_granted, tools:[{name, description}]}. Requires admin.sessions.read; the request sets Authorization: Bearer <admin_key> (override), NOT the client's default consumer bearer. Parsed dict verbatim; any non-200 → SessionApiFailed — notably 403 auth_scope_denied and 404 session_not_bifrost_bound.
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] session_id is non-empty str -- assert session_id and isinstance(session_id, str)
PRE: [PRE-003 hard] admin_key is non-empty str -- assert admin_key and isinstance(admin_key, str)
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
POST: [POST-002 state_change] the outbound request Authorization header == f"Bearer {admin_key}" (override) -- assert request.headers["Authorization"] == "Bearer " + admin_key
ERROR_ROUTING:
HTTP non-200 (incl. 403 auth_scope_denied, 404 session_not_found / session_not_bifrost_bound):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none (caller decides: 403 → key lacks scope; 404 not-bound → benign unbound session)
STEPS:
1. [setup, prescriptive] assert PRE-001..PRE-003
2. [sequential, prescriptive] resp = await client.get(f"/admin/sessions/{session_id}/bifrost", headers={"Authorization": f"Bearer {admin_key}"})
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy_uses_admin_bearer [happy,tracer]: 200 {endpoint_url, connected, capabilities_granted, tools} → dict verbatim; request Authorization == "Bearer <admin_key>" (override)
scope_denied_403 [error]: 403 → SessionApiFailed(status=403)
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:[…]}).
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": <arg>, "state": <state|null>}
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)