feat(#2): Tier-2 — transient characters + persona-state write; audit converges
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.
This commit is contained in:
@@ -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": <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)
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user