diff --git a/docs/contracts/issues/2.contract.md b/docs/contracts/issues/2.contract.md new file mode 100644 index 0000000..7f2bae4 --- /dev/null +++ b/docs/contracts/issues/2.contract.md @@ -0,0 +1,201 @@ +--- +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.` 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) since the two endpoints return different field sets? Draft uses one SessionInfo with optional fields keyed by origin; consumers can rely on `metadata` being present in both, and `message_count`/`archived`/`tags`/`name` defaulting to sensible None/empty when not present." + - "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]` + - `message_count: int | None` (present from POST response; None when SessionInfo was sourced from a list item) + - `name: str | None`, `archived: bool | None`, `tags: list[str] | None` (present from list items; None when sourced from POST response) +- `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` fields are populated from the 201 response. `message_count` is populated from the response; list-only fields (`name`, `archived`, `tags`) are `None`. +- **INV-002 [hard]**: `list_sessions` returns a `SessionPage` where every `SessionInfo` has `session_id`, `agent_id`, `created_at`, `last_active`, `metadata`, `name`, `archived`, and `tags` populated from the response item shape. `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) -> 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 (name, archived, tags) are None -- assert info.message_count is not None and info.name is None and info.archived is None and info.tags is None +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", {}), + message_count=body.get("message_count"), + name=None, + archived=None, + tags=None, + ) +TESTS: + happy_create [happy,tracer]: mock returns 201 with full body → returns SessionInfo with all create-side fields populated; list-only fields are None + 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": } — 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 +``` + +```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=` always; `include_archived=true` iff caller passed include_archived=True; `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", {}), + message_count=None, # not in list response per spec + name=item.get("name"), + archived=item.get("archived"), + tags=item.get("tags", []) if "tags" in item else None, + ) + 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 → URL has no include_archived param OR explicit false (asserts default behavior) + 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=) + 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 +``` diff --git a/persistent-memory.md b/persistent-memory.md index e4e29a3..62a40b2 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -54,10 +54,10 @@ What's NOT in the repo yet: **Branch:** `main`. Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git` (added 2026-05-20). **Next natural moves:** -1. Record real SSE snapshot fixtures from a running Worldtree (per `tests/snapshots/README.md`). Current tests use respx mocks against hand-rolled SSE wire — recording against a real Worldtree exercises spec conformance and gives version-skew detection per design-brief §2. -2. Build the `--send` stdout presenter — simplest consumer of `stream_turn`, exercises the API path without TUI machinery. Useful as a tracer for `ratatoskr.cli` work. -3. Textual TUI app shell — second presenter; multi-pane observability dashboard per design-brief §5. Layout shape locked there (Horizontal split, left=chat, right=TabbedContent with persona/tools/admin/bifrost/server-log). -4. Run `/sleipnir-preflight 1` if any AFK dispatch is wanted for these follow-ups, but most of this is hands-on dev work. +1. TDD-implement `ratatoskr.sessions` per `docs/contracts/issues/2.contract.md`. Two FNs (`create_session`, `list_sessions`) + two dataclasses (`SessionInfo`, `SessionPage`). complexity=low; ~150 LOC. Tracer order: `create_session` first (unblocks `--send --new`), then `list_sessions` (for the eventual TUI picker). Optional: `/volva-contract-review docs/contracts/issues/2.contract.md` before implementing. +2. Build the `--send` stdout presenter under `ratatoskr.cli` — composes `create_session` + `stream_turn` into the non-interactive mode (design-brief §8b). +3. Record real SSE snapshot fixtures from a running Worldtree. `--send --new` is itself a recording probe — capture its outputs to `tests/snapshots/` for replay-based regression coverage. +4. Textual TUI app shell — second presenter; multi-pane observability dashboard per design-brief §5. ## Recent decisions @@ -79,6 +79,7 @@ decision. Captures rationale that won't be obvious from code alone. - `[2026-05-21]` **Contract converted to issue-scoped (issue #1).** Moved `docs/contracts/sse_client.contract.md` → `docs/contracts/issues/1.contract.md`. Frontmatter shape switched from module-scoped (`module:`/`purpose:`) to issue-scoped (`target_module:`/`scope:`/`prd:`) per CONTRACT-FORMAT §2.1.I. `prd:` block pins to issue #1's body hash (`abcbc49467e86f1d`). `scripts/contract_drift_check.py` returns clean. **Known parser stale-ness**: `contract_parser.py --validate` ERRORs on issue-scoped frontmatter (missing `module:`/`purpose:`) — this is CONTRACT-FORMAT §2.1.L H10, a documented Brokkr-side follow-up. Parser is a canonical sync, so we do NOT patch it locally (would drift from canonical). Treat parser ERROR-on-issue-scoped as expected until the canonical bumps. - `[2026-05-21]` **Default issue-tracker labels seeded** (17 total). Sleipnir gating (`ready-for-agent`, `blocked-needs-contract`, `blocked-needs-dependency`), triage (`needs-triage`, `needs-architect-decision`, `needs-info`), type (`bug`, `enhancement`, `task`, `documentation`), resolution (`duplicate`, `wontfix`, `invalid`), Ratatoskr-specific area (`sse-client`, `tui`, `cli`, `observability`). - `[2026-05-21]` **`ratatoskr.sse_client` implemented via TDD against issue #1's contract.** 37 contract-listed tests authored + GREEN per the tracer-bullet vertical-slice ordering (`_parse_sse_id` → `stream_turn` → `reconnect_turn` → `cancel_turn`). Refactor pass extracted `_iter_events` helper to dedupe INV-002 + INV-003 + terminal-break logic across `stream_turn` and `reconnect_turn`; `expected_turn_id=None` vs `expected_turn_id=N` distinguishes the two entry-point semantics Volva surfaced. Notable choices made during implementation: (a) regex `^-?\d+$` pre-check in `_parse_sse_id` to reject whitespace before `int()` (Python's `int(" 3 ")` would silently strip — this kept the strict-no-whitespace test honest); (b) `_DropAfter` AsyncByteStream subclass in tests to simulate mid-stream `RemoteProtocolError`; (c) ToolResult.result and ToolStart.arguments typed as `Any` (server JSON varies); (d) ruff line-length=100 (per pyproject) forced some test docstrings to be tighter than v0 draft. +- `[2026-05-21]` **Issue #2 + contract: `ratatoskr.sessions`.** Scope is narrow — `create_session` (POST /sessions) + `list_sessions` (GET /sessions, cursor-paginated) + shared `SessionInfo` and `SessionPage` frozen dataclasses. Bundles two endpoints in one contract because they share the response envelope shape; splitting would duplicate the dataclass. Bifrost binding (Worldtree issue #160), ephemeral sessions, `GET /sessions/{id}`, `PATCH`, `DELETE`, and `GET /sessions/{id}/messages` (history) are explicitly out of scope (codified in the contract's `## Out of scope` H2 — first contract in this repo to carry that section, so future Volva consults resolve cleanly via the default path instead of needing `--out-of-scope` overrides). `prd:` pinned to issue #2 body SHA `01fbbd52b6d90eb0` at `2026-05-21T04:45:06+00:00`; drift check clean. `dependencies:` lists issue #1 as a convention-dependency (no code import; same API-consumption posture). - `[2026-05-21]` **Volva code-vs-contract review round on `ratatoskr.sse_client`.** Volva flagged 4 findings (3 drifts + 1 test-gap), all code-side "fix it" recommendations: (1) `_iter_events` fell off cleanly on EOF before terminal, violating INV-001 ("MUST NOT raise StopAsyncIteration before a terminal event arrives unless connection drops"); fix tracks `terminal_seen` flag and raises `SseConnectionDropped` on clean-EOF-without-terminal. (2) Both `SseConnectFailed.body` and `CancelFailed.body` stored full response bytes; ERROR_ROUTING specified truncation to `[:1024]`; fix truncates in `__init__` before storing. (3) `_parse_sse_id` PRE-001 specified `assert isinstance(raw, str)`, but code called `.split(":")` directly (incidental `AttributeError` on non-str); fix adds the assert. (4) Test-gap on cancel_turn's "other status → CancelFailed" branch; fix adds a 503 test with >1024-byte body that double-covers finding #2. Meta-note: Volva said TDD caught the main happy/adversarial shape; the misses were "negative space" cases (clean EOF, exception payload truncation, untested generic cancel branch) — calibration evidence that cross-model review pulls weight on the same-model author's blind spots. 43 tests GREEN post-fix (42 sse_client + 1 boundary), ruff clean. - `[2026-05-21]` **Volva paraphrase round on `docs/contracts/issues/1.contract.md`.** Volva flagged 5 ambiguities; operator approved amendments to 3 of them. (1) `reconnect_turn` STEP 2 punt resolved: signature now carries `content: str`; STEP 2 body is `json={"content": content}` matching spec §Reconnect flow example verbatim. Spec line 732 makes the agent's tools+LLM run "exactly once regardless of disconnects/reconnects" — the `content` is a wire-schema requirement, not re-processed server-side. (2) `_parse_sse_id` tightened: `turn_id ≥ 1` AND `seq ≥ 1` (was `≥ 0`); spec §SSE id format line 705 explicitly states `seq` starts at 1, and `turn_id` is SQLite autoincrement (≥1). Test `happy_zero_seq` flipped to `zero_seq [adversarial]`; new `zero_turn_id` + `negative_seq` adversarial tests added. (3) INV-003 clarified to spell out the two-entry-point semantics: `stream_turn` establishes `turn_id` from the first event (first event always yields); `reconnect_turn` parses the expected `turn_id` FROM `last_event_id` BEFORE the connection opens, so the first server event is already a flip-candidate and is NOT yielded on mismatch. Volva flags #3 (MalformedSseId-vs-ValueError split) and #5 (exactly-one-terminal as server-assumed) noted but kept as-is — deliberate distinctions. Drift check still clean against issue #1 (amending the contract doesn't touch the pinned issue body).