804c2df6eb
Issue #6 (TUI startup error visibility): restructure run_tui lifecycle so pre-App.run() failures land on real stderr instead of getting eaten by the alt-screen teardown. New _resolve_then_run async helper opens the AsyncClient via async-with, does pre-flight session resolution, routes AgentNotFound / SessionApiFailed / network errors to sys.stderr (verbatim same labels + exit codes as cli._amain), then constructs RatatoskrApp with pre-resolved state and awaits app.run_async(). RatatoskrApp.__init__ signature widens to (args, *, session_id, agent_id, client) — all three required. on_mount narrows to identity-widget population; on_unmount becomes a no-op (client lifetime owned by run_tui's async-with). Issue #5 (--end-user-id for per-end-user agents): sessions.create_session gains keyword-only end_user_id kwarg with PRE-003 non-empty assertion; ParsedArgs.end_user_id field added (default None); --end-user-id flag with non-empty validation; _amain + _resolve_then_run thread it to their create_session calls. RATATOSKR_END_USER_ID env-var fallback (flag > env > None) per the post-2026-05-23 amendment; env.sh (gitignored) ships "ratatoskr-tui" as project-stable partition default. Worldtree-dev consumer-API follow-up (althing 01KSBARG2B8M): User-Agent header added (ratatoskr/<version> (vh@phasefinal.com), version pulled via importlib.metadata) to both AsyncClient constructions so server logs can distinguish ratatoskr traffic from other consumers. Volva code-review (2 rounds on #6) found 8 test-precision gaps + 1 PRE assertion drift, all Category 1 fixed: missing PRE-001 at _resolve_then_run entry; Rule separator assertions on markdown render; RichLog-write spy on empty submit; input-cleared + no-new-worker on cancelling busy; worker.cancel observation on three force-exit paths; on_unmount-no-close focused test (the prior client-lifetime test patched run_async so on_unmount was never exercised); happy --new resolve test verifying POST count + identity propagation. Issues #2/#3/#4/#5 contracts amended in-place to reflect: - create_session widened (PRE-003, body construction step, body shape POST) - ParsedArgs description + _parse_args STEPS + _amain create_session call + new TESTS for end_user_id + env-var fallback - _resolve_then_run STEPS + new TEST entries; on_mount narrowed; INV-007 amended for new client ownership - Post-#6 adjustment note on issue #5 (_resolve_then_run replaces on_mount as the threading site since #6 moved session resolution out of the alt-screen) 188 tests GREEN; ruff clean. Bumps to v0.1.0 — first minor release, the load-bearing reason is RatatoskrApp.__init__'s breaking signature change (additive end_user_id alone wouldn't have triggered a minor pre-v1.x). Files Gitea issues #9 (spec-pin refresh v0.19.0 → v0.22.1), #10 (track Worldtree #196 subject:{type,id} migration), #11 (AdminEvents pane auth prerequisite admin.events.read). Infra-ops pinged via althing for agents.call:lofn scope add (broker pattern; they forwarded to worldtree-dev because personal Worldtree exposes no public scope-mutation endpoint).
209 lines
19 KiB
Markdown
209 lines
19 KiB
Markdown
---
|
|
contract_version: "2.1"
|
|
target_module: "ratatoskr.sessions"
|
|
scope: "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."
|
|
depends_on:
|
|
- "httpx"
|
|
used_by:
|
|
- "ratatoskr.cli"
|
|
- "ratatoskr.tui"
|
|
language: "python"
|
|
complexity: "low"
|
|
estimated_loc: 150
|
|
confidence: 0.9
|
|
assumptions:
|
|
- "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."
|
|
open_questions:
|
|
- "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."
|
|
prd:
|
|
issue: 2
|
|
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/2"
|
|
body_sha256_16: "01fbbd52b6d90eb0"
|
|
lock_in_comment_id: null
|
|
lock_in_sha256_16: null
|
|
lock_in_at: null
|
|
pinned_at: "2026-05-21T04:45:06+00:00"
|
|
dependencies:
|
|
- issue: 1
|
|
path: "src/ratatoskr/sse_client.py"
|
|
reason: "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).
|
|
- `agent_id: str` — for `create_session`.
|
|
- `include_archived: bool`, `limit: int`, `cursor: str | None` — for `list_sessions`.
|
|
|
|
**Output:**
|
|
- `create_session` → `SessionInfo`:
|
|
- `session_id: str`
|
|
- `agent_id: str`
|
|
- `created_at: str` (ISO 8601 with offset)
|
|
- `last_active: str`
|
|
- `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.
|
|
|
|
---
|
|
|
|
```contract
|
|
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)
|
|
```
|
|
|
|
```contract
|
|
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
|
|
```
|