feat(sessions): implement issue #2 contract via TDD
Implements docs/contracts/issues/2.contract.md. Two functions (create_session, list_sessions), two frozen dataclasses (SessionInfo, SessionPage), three exception types (AgentNotFound, InvalidCursor, SessionApiFailed). 19 contract-listed tests cover every TESTS: entry verbatim per the tracer-bullet vertical-slice ordering. SessionInfo uses one shape across both endpoints with origin- conditional defaults per INV-001 (create) and INV-002 (list). create- origin always sets list-only fields to (name=None, archived=False, tags=[]); list-origin reads them from the response item with absent/null treated as those same defaults — keeps the dataclass uniform without forcing callers to handle two types. Spotted an internal-inconsistency in the contract at TDD start — POST-003 and happy_create's test description still said "archived is None, tags is None" while the freshly-applied Volva amendment had moved INV-001 to (archived=False, tags=[]). Fixed in-place before writing any tests so the spec stayed coherent. SessionApiFailed.body truncates to <= 1024 bytes at construction, matching the SseConnectFailed / CancelFailed precedent from issue #1. No code shared with sse_client.py (convention-dependency only per issue #2's dependencies: block). 62 tests GREEN total (42 sse_client + 19 sessions + 1 boundary smoke). Ruff clean. No refactor pass — the two functions are ~25 LOC each with distinct error-routing branches that don't naturally share more than they already do.
This commit is contained in:
@@ -100,7 +100,7 @@ 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
|
||||
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)
|
||||
@@ -133,7 +133,7 @@ STEPS:
|
||||
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 None
|
||||
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")
|
||||
|
||||
@@ -30,7 +30,7 @@ separate dev team rather than an in-tree Worldtree tool.
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
**Status: `ratatoskr.sse_client` implemented via TDD against issue #1's contract.** 38/38 tests GREEN; ruff clean; boundary smoke (`tests/test_no_worldtree_imports.py`) still passes.
|
||||
**Status: `ratatoskr.sse_client` + `ratatoskr.sessions` both implemented via TDD against their issue-scoped contracts.** 62/62 tests GREEN (42 sse_client + 19 sessions + 1 boundary); ruff clean.
|
||||
|
||||
What's in the repo:
|
||||
- `docs/design-brief.md` — the locked design (copy from `brokkr-smithy/docs/ratatoskr-design-brief.md`).
|
||||
@@ -42,6 +42,8 @@ What's in the repo:
|
||||
- `src/ratatoskr/__init__.py` + `cli.py` — stubs.
|
||||
- `src/ratatoskr/sse_client.py` — **implemented 2026-05-21** per `docs/contracts/issues/1.contract.md`. Four public entry points + nine typed Event variants + ten custom exceptions. Shared SSE-iteration logic (INV-002 + INV-003 + terminal-break) lives in private `_iter_events(event_source, *, expected_turn_id)` helper consumed by both `stream_turn` and `reconnect_turn` — `expected_turn_id=None` triggers "establish from first event" semantics, `expected_turn_id=N` triggers "first event is already a flip-candidate" semantics (the two-entry-point distinction Volva surfaced).
|
||||
- `tests/test_sse_client.py` — 37 tests covering all four FN blocks' TESTS: entries verbatim (13 + 10 + 8 + 6). Real HTTP wire via respx mocks; SSE wire format constructed by helper `_sse_chunk`. Connection-drop test uses custom `httpx.AsyncByteStream` subclass that yields chunks then raises `RemoteProtocolError`.
|
||||
- `src/ratatoskr/sessions.py` — **implemented 2026-05-21** per `docs/contracts/issues/2.contract.md`. Two functions (`create_session`, `list_sessions`) + two frozen dataclasses (`SessionInfo`, `SessionPage`) + three exception types (`AgentNotFound`, `InvalidCursor`, `SessionApiFailed`). `SessionInfo` uses origin-conditional defaults per INV-001/INV-002 (create-origin: `name=None`, `archived=False`, `tags=[]`, `message_count=<from body>`; list-origin: same defaults for absent/null fields, `message_count=None`). `SessionApiFailed` truncates `.body` to ≤1024 at construction. No code shared with `sse_client.py` (convention-dependency only per issue #2 `dependencies:`).
|
||||
- `tests/test_sessions.py` — 19 tests covering both FN blocks' TESTS: entries verbatim (7 + 12). Helper `_list_item()` builds GET /sessions list-item bodies for tests.
|
||||
- `tests/test_no_worldtree_imports.py` — boundary smoke test (passes; verified 2026-05-20).
|
||||
- `tests/snapshots/README.md` — recording/replay convention for SSE snapshot tests.
|
||||
|
||||
@@ -54,8 +56,8 @@ 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. 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).
|
||||
1. Optional: `/volva-code-review docs/contracts/issues/2.contract.md` against the freshly-landed implementation (precedent: issue #1's code-review caught 4 negative-space drifts the TDD round missed).
|
||||
2. Build the `--send` stdout presenter under `ratatoskr.cli` — composes `create_session` + `stream_turn` into the non-interactive mode (design-brief §8b). First chance to exercise both modules against a real Worldtree.
|
||||
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.
|
||||
|
||||
@@ -80,6 +82,7 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
- `[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]` **`ratatoskr.sessions` implemented via TDD against issue #2's contract.** 19 contract-listed tests authored + GREEN per tracer-bullet vertical-slice (`create_session` first, then `list_sessions`). One internal-inconsistency in the contract spotted at TDD start — POST-003 and `happy_create` test description still said "archived is None, tags is None" while the freshly-amended INV-001 set them to `False` and `[]`; fixed the contract in-place before writing tests so the spec stayed coherent. Implementation is small (~115 LOC for src module); no refactor pass deemed worthwhile (the two functions are ~25 LOC each with distinct error-routing branches).
|
||||
- `[2026-05-21]` **Volva paraphrase round on `docs/contracts/issues/2.contract.md`.** Volva flagged 5 ambiguities; operator approved amendments to 3 of them. (1) `tags`/`archived`/`name` defaulting semantics now explicit: `tags: list[str]` (default `[]`), `archived: bool` (default `False`), `name: str | None` (default `None`); INV-001/INV-002 + STEPS aligned. (2) `include_archived_query` test tightened: default → URL has NO `include_archived` param at all (was "no param OR explicit false" — softened the assertion against STEP 2's prescriptive behavior). (5) `metadata` populated-vs-defaulted slippage resolved: INV-001 + INV-002 now spell out the defensive `body.get("metadata", {})` default for spec drift tolerance. Volva flags #3 (exception `.body` sensitivity) and #4 (`assert` for runtime validation) reviewed and kept as-is — both intentional and consistent with issue #1's precedent. Drift check still clean (amendments don't touch the pinned issue body).
|
||||
- `[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).
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Worldtree Conversation API session-lifecycle client.
|
||||
|
||||
Implements docs/contracts/issues/2.contract.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionInfo:
|
||||
"""Worldtree session envelope; shared shape for create + list responses.
|
||||
|
||||
INV-001 / INV-002: origin-conditional defaults — `create_session` sets fixed
|
||||
`name=None`, `archived=False`, `tags=[]`; `list_sessions` populates from the
|
||||
response item with the same absent/null defaults but `message_count=None`.
|
||||
"""
|
||||
|
||||
session_id: str
|
||||
agent_id: str
|
||||
created_at: str
|
||||
last_active: str
|
||||
metadata: dict[str, Any]
|
||||
message_count: int | None
|
||||
name: str | None
|
||||
archived: bool
|
||||
tags: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionPage:
|
||||
"""One page of GET /sessions results. `next_cursor=None` on the last page."""
|
||||
|
||||
items: list[SessionInfo]
|
||||
next_cursor: str | None
|
||||
|
||||
|
||||
class AgentNotFound(Exception):
|
||||
"""Raised on HTTP 404 from POST /sessions — unknown agent_id."""
|
||||
|
||||
def __init__(self, *, agent_id: str) -> None:
|
||||
super().__init__(f"unknown agent_id: {agent_id!r}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
class InvalidCursor(Exception):
|
||||
"""Raised on HTTP 422 cursor_invalid from GET /sessions."""
|
||||
|
||||
def __init__(self, *, raw: str | None) -> None:
|
||||
super().__init__(f"server rejected cursor: {raw!r}")
|
||||
self.raw = raw
|
||||
|
||||
|
||||
class SessionApiFailed(Exception):
|
||||
"""Raised on unexpected response status from /sessions endpoints.
|
||||
|
||||
`body` is truncated to ≤ 1024 bytes at construction (INV-004), consistent
|
||||
with the issue #1 precedent for `SseConnectFailed` / `CancelFailed`.
|
||||
"""
|
||||
|
||||
def __init__(self, *, status: int, body: bytes) -> None:
|
||||
body = body[:1024]
|
||||
super().__init__(f"sessions API failed: status={status}, body={body[:128]!r}")
|
||||
self.status = status
|
||||
self.body = body
|
||||
|
||||
|
||||
async def list_sessions(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
include_archived: bool = False,
|
||||
limit: int = 50,
|
||||
cursor: str | None = None,
|
||||
) -> SessionPage:
|
||||
"""GET /sessions. See contract FN list_sessions."""
|
||||
assert client is not None
|
||||
assert 1 <= limit <= 200
|
||||
assert cursor is None or (isinstance(cursor, str) and cursor)
|
||||
|
||||
params: dict[str, str] = {"limit": str(limit)}
|
||||
if include_archived:
|
||||
params["include_archived"] = "true"
|
||||
if cursor is not None:
|
||||
params["cursor"] = cursor
|
||||
|
||||
resp = await client.get("/sessions", params=params)
|
||||
if resp.status_code == 422:
|
||||
try:
|
||||
err = resp.json()
|
||||
except ValueError:
|
||||
err = {}
|
||||
if err.get("error_code") == "cursor_invalid":
|
||||
raise InvalidCursor(raw=cursor)
|
||||
raise SessionApiFailed(status=422, body=resp.content)
|
||||
if resp.status_code != 200:
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
body = resp.json()
|
||||
items = [
|
||||
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,
|
||||
name=item.get("name"),
|
||||
archived=item.get("archived", False),
|
||||
tags=item.get("tags") or [],
|
||||
)
|
||||
for item in body["items"]
|
||||
]
|
||||
return SessionPage(items=items, next_cursor=body.get("next_cursor"))
|
||||
|
||||
|
||||
async def create_session(client: httpx.AsyncClient, agent_id: str) -> SessionInfo:
|
||||
"""POST /sessions to create a new session. See contract FN create_session."""
|
||||
assert client is not None
|
||||
assert agent_id and isinstance(agent_id, str)
|
||||
|
||||
resp = await client.post("/sessions", json={"agent_id": agent_id})
|
||||
if resp.status_code == 404:
|
||||
raise AgentNotFound(agent_id=agent_id)
|
||||
if resp.status_code != 201:
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
body = resp.json()
|
||||
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=False,
|
||||
tags=[],
|
||||
)
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Tests for ratatoskr.sessions per docs/contracts/issues/2.contract.md."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from ratatoskr.sessions import (
|
||||
AgentNotFound,
|
||||
InvalidCursor,
|
||||
SessionApiFailed,
|
||||
SessionPage,
|
||||
create_session,
|
||||
list_sessions,
|
||||
)
|
||||
|
||||
|
||||
class TestCreateSession:
|
||||
@respx.mock
|
||||
async def test_happy_create(self) -> None:
|
||||
"""happy_create [happy,tracer]: full 201 body -> SessionInfo with create-origin defaults."""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"agent_id": "mimir",
|
||||
"message_count": 0,
|
||||
"created_at": "2026-04-15T12:00:00+00:00",
|
||||
"last_active": "2026-04-15T12:00:00+00:00",
|
||||
"metadata": {},
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
info = await create_session(client, "mimir")
|
||||
assert info.session_id == "550e8400-e29b-41d4-a716-446655440000"
|
||||
assert info.agent_id == "mimir"
|
||||
assert info.created_at == "2026-04-15T12:00:00+00:00"
|
||||
assert info.last_active == "2026-04-15T12:00:00+00:00"
|
||||
assert info.metadata == {}
|
||||
assert info.message_count == 0
|
||||
# INV-001 create-origin fixed defaults
|
||||
assert info.name is None
|
||||
assert info.archived is False
|
||||
assert info.tags == []
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_create_with_metadata(self) -> None:
|
||||
"""happy_create_with_metadata: response carries metadata -> SessionInfo.metadata matches."""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"session_id": "s1",
|
||||
"agent_id": "mimir",
|
||||
"message_count": 0,
|
||||
"created_at": "2026-04-15T12:00:00+00:00",
|
||||
"last_active": "2026-04-15T12:00:00+00:00",
|
||||
"metadata": {"model": "glm5-turbo"},
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
info = await create_session(client, "mimir")
|
||||
assert info.metadata == {"model": "glm5-turbo"}
|
||||
|
||||
@respx.mock
|
||||
async def test_request_body_shape(self) -> None:
|
||||
"""request_body_shape [trace]: outbound JSON is exactly {"agent_id": <arg>}."""
|
||||
import json as _json
|
||||
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"session_id": "s1",
|
||||
"agent_id": "mimir",
|
||||
"message_count": 0,
|
||||
"created_at": "2026-04-15T12:00:00+00:00",
|
||||
"last_active": "2026-04-15T12:00:00+00:00",
|
||||
"metadata": {},
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await create_session(client, "mimir")
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
assert body == {"agent_id": "mimir"}
|
||||
|
||||
@respx.mock
|
||||
async def test_unknown_agent_id(self) -> None:
|
||||
"""unknown_agent_id: 404 -> AgentNotFound(agent_id=<arg>)."""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(404, json={"error": "unknown_agent_id"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AgentNotFound) as exc_info:
|
||||
await create_session(client, "mimir")
|
||||
assert exc_info.value.agent_id == "mimir"
|
||||
|
||||
@respx.mock
|
||||
async def test_validation_failed(self) -> None:
|
||||
"""validation_failed: 422 -> SessionApiFailed(status=422); body truncated."""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
422,
|
||||
json={"error_code": "validation_failed", "message": "missing agent_id"},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc_info:
|
||||
await create_session(client, "mimir")
|
||||
assert exc_info.value.status == 422
|
||||
assert len(exc_info.value.body) <= 1024
|
||||
|
||||
@respx.mock
|
||||
async def test_unexpected_status_truncates(self) -> None:
|
||||
"""unexpected_status_truncates: 500 + 5000-byte body -> SessionApiFailed; body == 1024."""
|
||||
big = b"x" * 5000
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(500, content=big)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc_info:
|
||||
await create_session(client, "mimir")
|
||||
assert exc_info.value.status == 500
|
||||
assert exc_info.value.body == big[:1024]
|
||||
|
||||
@respx.mock
|
||||
async def test_empty_agent_id(self) -> None:
|
||||
"""empty_agent_id [adversarial]: '' -> AssertionError; no HTTP issued."""
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, content=b"{}")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await create_session(client, "")
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
def _list_item(
|
||||
*,
|
||||
session_id: str = "s1",
|
||||
agent_id: str = "mimir",
|
||||
created_at: str = "2026-04-15T12:00:00+00:00",
|
||||
last_active: str = "2026-04-15T12:05:00+00:00",
|
||||
metadata: dict[str, object] | None = None,
|
||||
name: str | None = "Research session",
|
||||
archived: bool = False,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Build a GET /sessions list-item body for tests."""
|
||||
item: dict[str, object] = {
|
||||
"session_id": session_id,
|
||||
"agent_id": agent_id,
|
||||
"created_at": created_at,
|
||||
"last_active": last_active,
|
||||
"metadata": metadata if metadata is not None else {},
|
||||
"name": name,
|
||||
"archived": archived,
|
||||
"tags": tags if tags is not None else ["work"],
|
||||
}
|
||||
return item
|
||||
|
||||
|
||||
class TestListSessions:
|
||||
@respx.mock
|
||||
async def test_happy_first_page(self) -> None:
|
||||
"""happy_first_page [happy,tracer]: one item + next_cursor -> SessionPage shape."""
|
||||
respx.get("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"items": [_list_item()],
|
||||
"next_cursor": "v1.eyJhYmMifQ",
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
page = await list_sessions(client)
|
||||
assert isinstance(page, SessionPage)
|
||||
assert len(page.items) == 1
|
||||
assert page.next_cursor == "v1.eyJhYmMifQ"
|
||||
info = page.items[0]
|
||||
assert info.session_id == "s1"
|
||||
assert info.message_count is None # INV-002: not in list response
|
||||
assert info.name == "Research session"
|
||||
assert info.archived is False
|
||||
assert info.tags == ["work"]
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_last_page(self) -> None:
|
||||
"""happy_last_page: next_cursor=null -> SessionPage(next_cursor=None)."""
|
||||
respx.get("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"items": [_list_item()], "next_cursor": None},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
page = await list_sessions(client)
|
||||
assert page.next_cursor is None
|
||||
|
||||
@respx.mock
|
||||
async def test_empty_results(self) -> None:
|
||||
"""empty_results: {items: [], next_cursor: null} -> SessionPage([], None)."""
|
||||
respx.get("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(200, json={"items": [], "next_cursor": None})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
page = await list_sessions(client)
|
||||
assert page == SessionPage(items=[], next_cursor=None)
|
||||
|
||||
@respx.mock
|
||||
async def test_include_archived_query(self) -> None:
|
||||
"""include_archived_query: True -> has param; default -> NO param at all."""
|
||||
route = respx.get("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"items": [], "next_cursor": None}
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await list_sessions(client, include_archived=True)
|
||||
await list_sessions(client) # default
|
||||
url_with = str(route.calls[0].request.url)
|
||||
url_default = str(route.calls[1].request.url)
|
||||
assert "include_archived=true" in url_with
|
||||
assert "include_archived" not in url_default
|
||||
|
||||
@respx.mock
|
||||
async def test_cursor_threaded(self) -> None:
|
||||
"""cursor_threaded: cursor=opaque -> URL has cursor=opaque."""
|
||||
route = respx.get("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"items": [], "next_cursor": None}
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await list_sessions(client, cursor="opaque-from-prev-page")
|
||||
assert "cursor=opaque-from-prev-page" in str(route.calls[0].request.url)
|
||||
|
||||
@respx.mock
|
||||
async def test_limit_query(self) -> None:
|
||||
"""limit_query: limit=10 -> URL has limit=10."""
|
||||
route = respx.get("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"items": [], "next_cursor": None}
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await list_sessions(client, limit=10)
|
||||
assert "limit=10" in str(route.calls[0].request.url)
|
||||
|
||||
@respx.mock
|
||||
async def test_invalid_cursor_server(self) -> None:
|
||||
"""invalid_cursor_server: 422 cursor_invalid -> InvalidCursor(raw=<passed cursor>)."""
|
||||
respx.get("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
422,
|
||||
json={"error_code": "cursor_invalid", "message": "bad cursor"},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(InvalidCursor) as exc_info:
|
||||
await list_sessions(client, cursor="bogus")
|
||||
assert exc_info.value.raw == "bogus"
|
||||
|
||||
@respx.mock
|
||||
async def test_other_validation_failed(self) -> None:
|
||||
"""other_validation_failed: 422 other error_code -> SessionApiFailed(422); truncated."""
|
||||
respx.get("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
422,
|
||||
json={"error_code": "validation_failed", "message": "limit out of range"},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc_info:
|
||||
await list_sessions(client)
|
||||
assert exc_info.value.status == 422
|
||||
assert len(exc_info.value.body) <= 1024
|
||||
|
||||
@respx.mock
|
||||
async def test_unexpected_status_truncates(self) -> None:
|
||||
"""unexpected_status_truncates: 500 + 5000-byte body -> SessionApiFailed; body == 1024."""
|
||||
big = b"x" * 5000
|
||||
respx.get("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(500, content=big)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc_info:
|
||||
await list_sessions(client)
|
||||
assert exc_info.value.status == 500
|
||||
assert exc_info.value.body == big[:1024]
|
||||
|
||||
@respx.mock
|
||||
async def test_limit_below_one(self) -> None:
|
||||
"""limit_below_one [adversarial]: limit=0 -> AssertionError; no HTTP."""
|
||||
route = respx.get("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(200, json={"items": [], "next_cursor": None})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await list_sessions(client, limit=0)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_limit_above_max(self) -> None:
|
||||
"""limit_above_max [adversarial]: limit=300 -> AssertionError; no HTTP."""
|
||||
route = respx.get("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(200, json={"items": [], "next_cursor": None})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await list_sessions(client, limit=300)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_empty_cursor(self) -> None:
|
||||
"""empty_cursor [adversarial]: cursor='' -> AssertionError; no HTTP."""
|
||||
route = respx.get("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(200, json={"items": [], "next_cursor": None})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await list_sessions(client, cursor="")
|
||||
assert route.call_count == 0
|
||||
Reference in New Issue
Block a user