feat(#20): persona + authored-history + first-message onto the wt adapter (slice-3)

Slice-3 of the worldtree-sdk cutover: migrate the session persona-state write,
the #347 authored-history write, and get_session_messages onto ratatoskr.wt, and
route the first-message preset seed through the adapter. Retire the last
hand-rolled sessions.py paths the --seed-first-message probe kept alive
(create_session + SessionInfo, set_persona_state, write_authored_history,
get_session_messages, _bifrost_error_from).

- wt.set_persona_state (SDK PadState) — the CLI passes three finite PAD axes; the
  SDK owns the {"pad": {...}} wire (#317). No route-specific error row → the
  SessionApiFailed default.
- wt.write_authored_history (SDK write_history) — v1 author=assistant; 404 →
  AuthoredHistoryUnavailable (hide-existence; the route is the discriminator,
  never the body); every other ApiError → the default. Drops the unused
  author/effects/claimed_original_at params (no caller uses them).
- first_message.seed_preset_first_message now takes a WorldtreeClient and routes
  through wt.write_authored_history; the best-effort invariants (INV-001..004,
  never-raise/never-block/one-write/zero-worldtree-source-import) are unchanged.
  Tests drive a fake WorldtreeClient — the wire is the SDK's to prove.
- CLI --set-persona-pad / --seed-first-message + the _amain and web create-path
  first-message seeds rewired onto the adapter. --set-persona-pad pre-validates
  PAD finiteness (clean usage_error, never a crash on the SDK ConfigurationError).

LIVE-SMOKE on personal :8081 (b128, INV-CUT-5): --seed-first-message → 201
(seq=0, phase=seeded) → read-back verbatim; --set-persona-pad → 204; the --new
create-path preset seed observed routing through the adapter. All slice-3 route
families proven end-to-end through the ratatoskr surface.

docs/coverage-map.md + first_message.contract.md re-anchored onto the adapter;
the slice-2 create/stream/cancel rows re-anchored too (they still named the
deleted sse_client/sessions symbols).

Suite 466 green; mypy no new errors (baseline 22 → 20 in the touched modules);
ruff clean. INV-CUT-1..5 held. Bifrost provider planes untouched.
This commit is contained in:
vh
2026-07-19 08:53:45 -07:00
parent 0b23334c68
commit ca9a339050
13 changed files with 313 additions and 1110 deletions
+5 -722
View File
@@ -7,16 +7,10 @@ import respx
from ratatoskr.sessions import (
AgentInfo,
AgentNotAvailable,
AgentNotFound,
AuthoredHistoryUnavailable,
AuthScopeDenied,
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
PersonaNotConfigured,
SessionApiFailed,
create_character,
create_session,
delete_character,
endpoint_for_plane,
get_capabilities,
@@ -24,522 +18,25 @@ from ratatoskr.sessions import (
get_me,
get_persona_state,
get_session_bifrost,
get_session_messages,
list_agents,
list_character_models,
set_persona_state,
write_authored_history,
)
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
@respx.mock
async def test_happy_create_with_end_user_id(self) -> None:
"""happy_create_with_end_user_id [happy]: body carries both keys (issue #5)."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s-new",
"agent_id": "lofn",
"message_count": 0,
"created_at": "2026-05-22T12:00:00+00:00",
"last_active": "2026-05-22T12:00:00+00:00",
"metadata": {},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await create_session(client, "lofn", end_user_id="alice")
body = _json.loads(route.calls[0].request.content)
# INV: body MUST be exactly {"agent_id": ..., "end_user_id": ...} — byte-for-byte
assert body == {"agent_id": "lofn", "end_user_id": "alice"}
assert info.session_id == "s-new"
assert info.agent_id == "lofn"
@respx.mock
async def test_default_omits_end_user_id(self) -> None:
"""default_omits_end_user_id [trace]: omit kwarg → body has no end_user_id (INV-002)."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s-new",
"agent_id": "mimir",
"message_count": 0,
"created_at": "2026-05-22T12:00:00+00:00",
"last_active": "2026-05-22T12: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)
# Exact equality — no end_user_id key in the body when the kwarg is omitted
assert body == {"agent_id": "mimir"}
assert "end_user_id" not in body
@respx.mock
async def test_empty_end_user_id(self) -> None:
"""empty_end_user_id [adversarial]: '' → AssertionError before HTTP (PRE-003)."""
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, "mimir", end_user_id="")
assert route.call_count == 0
class TestCreateSessionEphemeral:
"""Amendment 2026-07-18 — ephemeral-template (Echo) session creation."""
@respx.mock
async def test_happy_ephemeral_create(self) -> None:
"""happy_ephemeral_create [happy,tracer]: config threaded; kind/config captured."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "eph-1",
"agent_id": "echo",
"kind": "ephemeral",
"message_count": 0,
"created_at": "2026-07-18T09:30:13+00:00",
"last_active": "2026-07-18T09:30:13+00:00",
"metadata": {},
"config": {"system_prompt": "You are X.", "role": "echo"},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await create_session(
client, "echo", config={"system_prompt": "You are X."}
)
body = _json.loads(route.calls[0].request.content)
assert body == {"agent_id": "echo", "config": {"system_prompt": "You are X."}}
assert info.kind == "ephemeral"
assert info.config == {"system_prompt": "You are X.", "role": "echo"}
@respx.mock
async def test_foundational_omits_config(self) -> None:
"""foundational_omits_config [trace]: no config key; kind/config None when absent."""
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:
info = await create_session(client, "mimir")
body = _json.loads(route.calls[0].request.content)
assert body == {"agent_id": "mimir"}
assert info.kind is None
assert info.config is None
@respx.mock
async def test_foundational_captures_kind(self) -> None:
"""foundational_captures_kind [happy]: kind='foundational' captured; config None."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s1",
"agent_id": "mimir",
"kind": "foundational",
"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.kind == "foundational"
assert info.config is None
@respx.mock
async def test_ephemeral_requires_config_422(self) -> None:
"""ephemeral_requires_config_422 [error]: 422 → SessionApiFailed carrying the code."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
422,
json={"detail": {"error_code": "ephemeral_requires_config"}},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await create_session(client, "echo")
assert exc.value.status == 422
assert b"ephemeral_requires_config" in exc.value.body
@respx.mock
async def test_model_not_allowed_422(self) -> None:
"""model_not_allowed_422 [error]: config.model → 422 (wrapper passes config verbatim)."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
422, json={"detail": {"error_code": "model_not_allowed"}}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await create_session(
client, "echo", config={"system_prompt": "x", "model": "glm5-turbo"}
)
body = _json.loads(route.calls[0].request.content)
assert body["config"] == {"system_prompt": "x", "model": "glm5-turbo"}
assert exc.value.status == 422
@respx.mock
async def test_config_and_bifrost_conflict(self) -> None:
"""config_and_bifrost_conflict [adversarial]: both → AssertionError (PRE-005); no HTTP."""
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,
"echo",
config={"system_prompt": "x"},
bifrost=BifrostBinding(endpoint_url="https://p.example"),
consumer_key="ck",
)
assert route.call_count == 0
@respx.mock
async def test_config_not_a_mapping(self) -> None:
"""config_not_a_mapping [adversarial]: non-Mapping → AssertionError (PRE-004); no HTTP."""
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, "echo", config="not-a-dict") # type: ignore[arg-type]
assert route.call_count == 0
class TestCreateSessionBifrostBind:
"""Issue #17 slice 1 — the create_session Bifrost-bind primitive."""
@respx.mock
async def test_bind_happy_consumer_key_and_body(self) -> None:
"""bind_happy [tracer]: a bifrost binding makes the body carry the
`bifrost` field AND overrides the bearer to the consumer key (NOT the
client's canary default), 201 → SessionInfo. Proves the bind path
end-to-end (FN create_session STEPS 1-2, POST-001, INV-001)."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s-bound",
"agent_id": "ratatoskr:sindra",
"message_count": 0,
"created_at": "2026-06-18T12:00:00+00:00",
"last_active": "2026-06-18T12:00:00+00:00",
"metadata": {},
},
)
)
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
async with httpx.AsyncClient(
base_url="https://w.example",
headers={"Authorization": "Bearer canary-key"},
) as client:
info = await create_session(
client,
"ratatoskr:sindra",
end_user_id="smoke-user",
bifrost=binding,
consumer_key="consumer-key",
)
req = route.calls[0].request
body = _json.loads(req.content)
# body carries the bifrost field alongside agent_id/end_user_id
assert body == {
"agent_id": "ratatoskr:sindra",
"end_user_id": "smoke-user",
"bifrost": {
"endpoint_url": "http://10.100.10.50:8391",
"scope": None,
},
}
# bearer overridden to the consumer key (INV-001: never the canary default)
assert req.headers["Authorization"] == "Bearer consumer-key"
assert info.session_id == "s-bound"
assert info.agent_id == "ratatoskr:sindra"
@respx.mock
async def test_bind_without_consumer_key_raises_before_http(self) -> None:
"""missing_key [adversarial]: bifrost set but consumer_key None →
BifrostConsumerKeyMissing BEFORE any HTTP (PRE-001, INV-001: never fall
back to the canary key)."""
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, content=b"{}")
)
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(BifrostConsumerKeyMissing):
await create_session(client, "ratatoskr:sindra", bifrost=binding)
assert route.call_count == 0
@respx.mock
async def test_bind_with_empty_consumer_key_raises_before_http(self) -> None:
"""empty_key [adversarial]: empty-string consumer_key is also rejected
before HTTP (PRE-001 requires a NON-EMPTY str)."""
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, content=b"{}")
)
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(BifrostConsumerKeyMissing):
await create_session(
client, "ratatoskr:sindra", bifrost=binding, consumer_key=""
)
assert route.call_count == 0
@respx.mock
async def test_bind_handshake_failure_maps_to_502(self) -> None:
"""handshake_502 [adversarial]: a bound create that 502s with
detail.bifrost_error → BifrostHandshakeFailed carrying the bifrost_error
+ raw body (POST-002, INV-002 bind-time failure). 'bifrost.auth_rejected'
is the canary-key-instead-of-consumer-key tell."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
502,
json={
"error_code": "bifrost_handshake_failed",
"detail": {"bifrost_error": "bifrost.auth_rejected"},
},
)
)
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(BifrostHandshakeFailed) as exc_info:
await create_session(
client, "ratatoskr:sindra", bifrost=binding, consumer_key="ck"
)
assert exc_info.value.bifrost_error == "bifrost.auth_rejected"
# the raw 502 body is carried for debugging
assert exc_info.value.body
@respx.mock
async def test_bind_ephemeral_rejection_is_session_api_failed(self) -> None:
"""ephemeral_422 [boundary]: 422 ephemeral_does_not_accept_bifrost is a
generic create failure → SessionApiFailed, NOT a distinct exception
(POST-003 — deliberate, an operator config error)."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
422, json={"error_code": "ephemeral_does_not_accept_bifrost"}
)
)
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await create_session(
client, "echo", bifrost=binding, consumer_key="ck"
)
assert exc_info.value.status == 422
@respx.mock
async def test_unbound_create_unchanged_no_auth_override(self) -> None:
"""unbound_unchanged [regression]: with no bifrost, the body is the
pre-#17 shape AND create_session sends NO per-request Authorization
override — the client's default canary bearer governs (INV-001: the two
call sites never cross)."""
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",
headers={"Authorization": "Bearer canary-key"},
) as client:
await create_session(client, "mimir")
req = route.calls[0].request
body = _json.loads(req.content)
assert body == {"agent_id": "mimir"}
# the client default bearer is used unchanged — no consumer-key override
assert req.headers["Authorization"] == "Bearer canary-key"
class TestEndpointForPlane:
"""Issue #17 — endpoint_for_plane: plane name → Worldtree-visible base URL."""
def test_memory_plane_maps_to_8391(self) -> None:
"""memory [tracer]: 'memory' → http://<host>:8391 (POST-001)."""
assert (
endpoint_for_plane("memory", "10.100.10.50")
== "http://10.100.10.50:8391"
)
assert endpoint_for_plane("memory", "10.100.10.50") == "http://10.100.10.50:8391"
def test_affect_plane_maps_to_8390(self) -> None:
"""affect: 'affect' → http://<host>:8390 (POST-001)."""
assert (
endpoint_for_plane("affect", "10.100.10.50")
== "http://10.100.10.50:8390"
)
assert endpoint_for_plane("affect", "10.100.10.50") == "http://10.100.10.50:8390"
def test_combined_plane_maps_to_8392(self) -> None:
"""combined [#18 composite]: 'combined' → http://<host>:8392 (POST-001)."""
assert (
endpoint_for_plane("combined", "10.100.10.50")
== "http://10.100.10.50:8392"
)
assert endpoint_for_plane("combined", "10.100.10.50") == "http://10.100.10.50:8392"
def test_unknown_plane_raises_value_error(self) -> None:
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
@@ -636,9 +133,7 @@ class TestListAgents:
@respx.mock
async def test_happy_empty(self) -> None:
"""happy_empty: 200 with [] returns empty list (no error)."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json=[])
)
respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[]))
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert agents == []
@@ -1026,9 +521,7 @@ class TestTransientCharacters:
@respx.mock
async def test_delete_204(self) -> None:
"""delete [happy]: 204 → None."""
respx.delete("https://w.example/characters/char_x").mock(
return_value=httpx.Response(204)
)
respx.delete("https://w.example/characters/char_x").mock(return_value=httpx.Response(204))
async with httpx.AsyncClient(base_url="https://w.example") as client:
assert await delete_character(client, "char_x") is None
@@ -1042,213 +535,3 @@ class TestTransientCharacters:
with pytest.raises(SessionApiFailed) as exc:
await create_character(client, {"name": "H"})
assert exc.value.status == 403
class TestSetPersonaState:
"""#2 contract — set_persona_state (POST /sessions/{id}/persona_state)."""
@respx.mock
async def test_happy_204(self) -> None:
"""happy [happy,tracer]: freeform snapshot body; 204 → None."""
import json as _json
route = respx.post("https://w.example/sessions/s1/persona_state").mock(
return_value=httpx.Response(204)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await set_persona_state(
client, "s1", {"pad": {"pleasure": 0.4, "arousal": 0.1, "dominance": -0.2}}
)
assert result is None
assert _json.loads(route.calls[0].request.content) == {
"pad": {"pleasure": 0.4, "arousal": 0.1, "dominance": -0.2}
}
@respx.mock
async def test_non_204_raises(self) -> None:
"""non_204 [error]: 422 (bad snapshot shape) → SessionApiFailed(422)."""
respx.post("https://w.example/sessions/s1/persona_state").mock(
return_value=httpx.Response(422, json={"error_code": "validation_failed"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await set_persona_state(client, "s1", {"pad": [1, 2, 3]})
assert exc.value.status == 422
_AUTHORED_ACK = {
"author": "assistant",
"content_chars": 5,
"injected_at": "2026-07-06T12:00:00+00:00",
"phase": "seeded",
"seq": 0,
"session_id": "s1",
"turn_id": "t1",
}
class TestWriteAuthoredHistory:
"""write_authored_history — #347 POST /sessions/{id}/history (contract #2 amendment)."""
@respx.mock
async def test_happy_fresh_201(self) -> None:
"""happy_fresh_201 [happy,tracer]: 201 → ack verbatim; minimal body."""
import json as _json
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await write_authored_history(
client, "s1", content="hello", idempotency_key="k1"
)
assert result == _AUTHORED_ACK
assert _json.loads(route.calls[0].request.content) == {
"author": "assistant",
"content": "hello",
"idempotency_key": "k1",
}
@respx.mock
async def test_happy_replay_200(self) -> None:
"""happy_replay_200 [happy]: 200 replay (byte-identical body) → dict verbatim."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(200, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await write_authored_history(
client, "s1", content="hello", idempotency_key="k1"
)
assert result == _AUTHORED_ACK
@respx.mock
async def test_body_includes_effects(self) -> None:
"""body_includes_effects [trace]: effects + claimed_original_at appear iff non-None."""
import json as _json
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await write_authored_history(
client,
"s1",
content="hi",
idempotency_key="k1",
effects="none",
claimed_original_at="2020-01-01T00:00:00Z",
)
assert _json.loads(route.calls[0].request.content) == {
"author": "assistant",
"content": "hi",
"idempotency_key": "k1",
"effects": "none",
"claimed_original_at": "2020-01-01T00:00:00Z",
}
@respx.mock
async def test_hide_existence_404(self) -> None:
"""hide_existence_404 [error]: 404 → AuthoredHistoryUnavailable (NOT SessionApiFailed)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AuthoredHistoryUnavailable) as exc:
await write_authored_history(client, "s1", content="hi", idempotency_key="k1")
assert exc.value.session_id == "s1"
@respx.mock
async def test_generation_active_409(self) -> None:
"""generation_active_409 [error]: 409 → SessionApiFailed(409)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(409, json={"error_code": "generation_active"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await write_authored_history(client, "s1", content="hi", idempotency_key="k1")
assert exc.value.status == 409
@respx.mock
async def test_content_too_long_422(self) -> None:
"""content_too_long_422 [error]: 422 → SessionApiFailed(422)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(422, json={"error_code": "content_too_long"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await write_authored_history(client, "s1", content="x", idempotency_key="k1")
assert exc.value.status == 422
@respx.mock
async def test_empty_content(self) -> None:
"""empty_content [adversarial]: content="" → AssertionError; no HTTP issued."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await write_authored_history(client, "s1", content="", idempotency_key="k1")
assert not route.called
@respx.mock
async def test_empty_idempotency_key(self) -> None:
"""empty_idempotency_key [adversarial]: key="" → AssertionError; no HTTP issued."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await write_authored_history(client, "s1", content="hi", idempotency_key="")
assert not route.called
@respx.mock
async def test_empty_session_id(self) -> None:
"""empty_session_id [adversarial]: session_id="" → AssertionError; no HTTP issued."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json=_AUTHORED_ACK)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await write_authored_history(client, "", content="hi", idempotency_key="k1")
assert not route.called
class TestGetSessionMessages:
"""#2 contract (amendment 2026-07-06) — get_session_messages (GET /sessions/{id}/messages)."""
@respx.mock
async def test_happy(self) -> None:
"""happy [happy,tracer]: 200 {session_id, items, next_cursor} → dict verbatim."""
payload = {
"session_id": "s1",
"items": [{"seq": 0, "role": "assistant", "content": "hello there"}],
"next_cursor": None,
}
respx.get("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(200, json=payload)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await get_session_messages(client, "s1")
assert result == payload
@respx.mock
async def test_not_found_404(self) -> None:
"""not_found_404 [error]: 404 → SessionApiFailed(404)."""
respx.get("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_session_messages(client, "s1")
assert exc.value.status == 404
@respx.mock
async def test_empty_session_id(self) -> None:
"""empty_session_id [adversarial]: "" → AssertionError; no HTTP issued."""
route = respx.get("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(200, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await get_session_messages(client, "")
assert not route.called