Volva's code-spec review (thread 01KS4EKVKKGF) surfaced three findings
on the TDD-passing sessions module. All three addressed; one carries
a collateral contract amendment to keep INV-002 truthful.
1) drift: archived=item.get("archived", False) returned None for an
explicit "archived": null in the response. dict.get(k, default) only
fires the default when the key is absent — it does NOT default for
explicit-null values. The dataclass type is `bool` (not `bool | None`)
and INV-002 says explicit-null → False; the .get() form silently
violated both. Fixed: archived=item.get("archived") or False
(handles absent, null, false, and true cleanly).
INV-002 wording was the source of the bug — I introduced the
mis-spelled form during the Volva amendment round. Updated to spell
out the .get(default) foot-gun explicitly so future readers (and
future paraphrase rounds) don't fall back to the broken pattern.
2) test-gap: no test exercised explicit-null archived/tags. The
_list_item() helper had its own defaulting layer (tags=None →
["work"]) so a happy path test couldn't catch the underlying drift.
Added test_explicit_null_list_defaults using a raw dict to bypass
the helper. Catches the drift directly.
3) precision: message_count=body.get("message_count") could silently
default to None while POST-003 required it non-None. INV-001 prose
literally said "body['message_count']" (bracket access) so the
STEP 5 .get() was the contract's own internal inconsistency.
Aligned the code to bracket access (matches sibling required
fields like session_id) and amended STEP 5 + INV-001 to spell out
the strict semantics explicitly.
Volva's meta-note: "modest weight" — TDD caught the main surface;
this round caught a narrow Python .get() semantics edge that no
human reading would have spotted without explicit-null priors.
Still pulls real weight: that's the kind of bug that ships and
shows up months later when a server starts emitting null where
it used to omit a field.
63 tests GREEN (42 sse_client + 20 sessions + 1 boundary).
Ruff clean. Drift check still GREEN against the pinned issue body.
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.
httpx
ratatoskr.cli
ratatoskr.tui
python
low
150
0.9
Worldtree spec pin (`docs/conversation-api-spec.md` at v1.0, repo SHA `55101e909abcd2219833266b6f905c5bc956e0f0`) is the wire contract. POST /sessions response shape (§POST /sessions) and GET /sessions response shape (§GET /sessions) are read FROM the spec, not from any Worldtree source import.
POST /sessions returns 201 Created with a body matching the documented shape (session_id, agent_id, message_count, created_at, last_active, metadata). The created_at/last_active fields are ISO 8601 strings with +HH:MM offsets.
GET /sessions cursor pagination uses the `v1.<base64url>` envelope (§Pagination); the consumer treats cursors as opaque strings (does not parse or construct them).
Bifrost binding (Worldtree issue #160) is NOT used. create_session does not accept a `bifrost` parameter and never sends one in the request body.
Should SessionInfo split into two dataclasses (CreatedSessionInfo with message_count vs ListedSessionInfo with archived/tags/name)? Draft uses one SessionInfo with origin-conditional fields whose defaults are codified in INV-001 (create) and INV-002 (list). Splitting would force callers to handle two types where they currently handle one; collapsing felt right for v1 but reconsider if presenters end up branching by origin.
Should list_sessions transparently paginate (iterate all pages) or surface one page at a time? Draft surfaces one page (SessionPage with next_cursor). Caller decides whether to iterate. Matches Worldtree's pagination idiom and lets the TUI render lazily.
Convention dependency, not a code dependency. Issue #1 establishes the API-consumption posture (caller-owns httpx client, async-native, no Worldtree imports, response-parsing into frozen dataclasses, exception body truncation to [:1024]). sessions.py follows the same shape.
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).
metadata: dict[str, Any] (defaults to {} if the response omits the field — see INV-001)
message_count: int | None (present from POST response; None when SessionInfo was sourced from a list item per spec §GET /sessions)
name: str | None (always None when sourced from POST response; None if absent from list item; otherwise the list item's value)
archived: bool (always False when sourced from POST response; defaults to False if 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_session returns a SessionInfo whose session_id, agent_id, created_at, last_active, and metadata are sourced from the 201 response body. metadata is taken from body["metadata"] when present and defaults to {} when absent (defensive against minor server-side spec drift; spec example always shows it present). message_count is taken from body["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_sessions returns a SessionPage where every SessionInfo has session_id, agent_id, created_at, last_active, and metadata from the response item (same defensive metadata default as INV-001). name is item.get("name") (may be None). archived is item.get("archived") or False — absent, explicit-null, or explicit-false all yield False; explicit-true passes through. (Note: item.get(key, default) only fires default for absent keys, NOT for explicit-null values, so the or False form is load-bearing here.) tags is item.get("tags") or [] (absent, explicit-null, or empty list all yield []; a populated list passes through). message_count is None (the list endpoint does not include it — spec §GET /sessions: "message_count is not included in list items").
INV-003 [hard]: list_sessions treats cursors as opaque strings. The module never parses, base64-decodes, or constructs a cursor — it threads the server-provided next_cursor back 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 .body payloads to [:1024] at construction. Matches the issue #1 precedent (SseConnectFailed, CancelFailed).
INV-005 [hard]: No core.* or worldtree.* imports. Boundary verified by tests/test_no_worldtree_imports.py.
INV-006 [hard]: list_sessions rejects out-of-range limit values (< 1 or > 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_session does not accept or send a bifrost field. 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; --send doesn't need history.
Transparent multi-page iteration.list_sessions returns one page; caller threads next_cursor for the next call. Don't add an iter_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) -> SessionInfo
BRIEF: POST /sessions with {"agent_id": agent_id} to create a new conversation session. Returns SessionInfo populated from the 201 response.
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)
POST: [POST-001 side_effect] exactly one POST to /sessions was issued with body {"agent_id": agent_id} -- assert mock_router.calls.call_count == 1 and json.loads(req.content) == {"agent_id": agent_id}
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)
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
2. [sequential, flexibility=prescriptive] CALL client.post("/sessions", json={"agent_id": agent_id})
tool: { destructive: false, idempotent: false, read_only: false, open_world: false }
3. [branch, flexibility=prescriptive] IF resp.status_code == 404: RAISE AgentNotFound
ELIF resp.status_code != 201: RAISE SessionApiFailed
4. [sequential] Parse resp.json() → body
5. [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>} — 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
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