feat: authored-history-write consumer side (Worldtree #347) — v0.19.6
Consumer side of Worldtree's #347 authored-history-write (the SillyTavern first-message primitive), shipped via direct in-session TDD: - write_authored_history (POST /sessions/{id}/history): v1 author=assistant, effects=none, per-session idempotency; body server-pinned (AuthoredWriteRequest extra=forbid) so null effects/claimed_original_at are omitted; 200 replay / 201 fresh both return the AuthoredTurnResponse dict. - AuthoredHistoryUnavailable: the hide-existence 404 (feature-absent / ungranted / session-absent, indistinguishable by design — INV-347-1) raised DISTINCT from SessionApiFailed so callers branch feature-absent and never capability-probe. - get_session_messages (GET /sessions/{id}/messages): un-deferred as the seed read-back — confirms a seed renders as a normal role=assistant turn (model-invisible provenance). - --seed-first-message probe: create session -> seed -> read-back; a 404 reports a benign feature-absent result (exit 0), never a capability-probe. Contract #2 amended (2 FNs, validated OK). 19 new tests (12 wrapper + 7 cli), suite 601 green. Coverage-map re-converged: REST 19/41 (the #347 route + the messages read-back close the one gap the 2.3.0 re-vendor opened). Live-proof pending the session.history.write grant (requested infra-ops).
This commit is contained in:
@@ -8,6 +8,7 @@ from ratatoskr.sessions import (
|
||||
AgentInfo,
|
||||
AgentNotAvailable,
|
||||
AgentNotFound,
|
||||
AuthoredHistoryUnavailable,
|
||||
AuthScopeDenied,
|
||||
BifrostBinding,
|
||||
BifrostConsumerKeyMissing,
|
||||
@@ -25,11 +26,13 @@ from ratatoskr.sessions import (
|
||||
get_me,
|
||||
get_persona_state,
|
||||
get_session_bifrost,
|
||||
get_session_messages,
|
||||
get_session_tools,
|
||||
list_agents,
|
||||
list_character_models,
|
||||
list_sessions,
|
||||
set_persona_state,
|
||||
write_authored_history,
|
||||
)
|
||||
|
||||
|
||||
@@ -1193,3 +1196,181 @@ class TestSetPersonaState:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user