be171304f5
Codifies 'give an agent a first message' (Worldtree #347): new module ratatoskr.first_message (FIRST_MESSAGE_PRESETS + seed_preset_first_message) seeds a preset agent's opening as a #347 authored turn-0 on every new session, wired into all three create paths — cli._amain (--send --new), tui._resolve_then_run (bare --new), web._create_session_endpoint (POST /api/sessions). seed_preset_first_message is strictly best-effort (INV-001): it soft-guards its inputs (return None, never assert), bounds the write with asyncio.wait_for so a stalled /history can't block create (the CLI/TUI clients disable read timeout for SSE), and swallows every exception except asyncio.CancelledError (which propagates) — so it can NEVER raise into or block the session-create path it is wired into. Per-content idempotency key → idempotent replay, no dup. Seeded with ratatoskr:sindra, whose opening greeting moved out of her card: her live system_prompt was PATCHed (non-destructive) to drop the Startup workaround the #347 first-message now replaces. Quality gate (both cross-frontier panels): heid-code-review returned zero implementation drift (2 test-only fixups applied); heid-bug-hunt caught the gap the conformance lens can't see — code matched the contract's narrow ERROR_ROUTING but INV-001's 'never raises' is broader — driving the broad-except + soft-guard + wait_for hardening above. Contract docs/contracts/first_message.contract.md (module-scoped, validated). TDD: 12 unit + 1 web wire-in; the 3 existing sindra bind tests gained a history-endpoint mock (creating a preset agent now auto-seeds). Suite 615 green, ruff+mypy clean. Auto-seed live-proven generation-free against personal :8081.
156 lines
6.6 KiB
Python
156 lines
6.6 KiB
Python
"""Tests for ratatoskr.first_message per docs/contracts/first_message.contract.md."""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
from ratatoskr.first_message import (
|
|
FIRST_MESSAGE_PRESETS,
|
|
preset_for,
|
|
seed_preset_first_message,
|
|
)
|
|
|
|
|
|
class TestPresetFor:
|
|
"""first_message contract — preset_for (dict lookup)."""
|
|
|
|
def test_preset_hit(self) -> None:
|
|
"""preset_hit [happy,tracer]: sindra has a non-empty str preset."""
|
|
val = preset_for("ratatoskr:sindra")
|
|
assert isinstance(val, str) and val
|
|
|
|
def test_preset_miss(self) -> None:
|
|
"""preset_miss [happy]: an agent with no preset → None."""
|
|
assert preset_for("mimir") is None
|
|
|
|
def test_empty_agent_id(self) -> None:
|
|
"""empty_agent_id [adversarial]: "" → AssertionError."""
|
|
with pytest.raises(AssertionError):
|
|
preset_for("")
|
|
|
|
|
|
class TestSeedPresetFirstMessage:
|
|
"""first_message contract — seed_preset_first_message (best-effort #347 seed)."""
|
|
|
|
@respx.mock
|
|
async def test_seeds_preset(self) -> None:
|
|
"""seeds_preset [happy,tracer]: preset agent → one history POST, correct body."""
|
|
content = FIRST_MESSAGE_PRESETS["ratatoskr:sindra"]
|
|
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
|
|
route = respx.post("https://w.example/sessions/s1/history").mock(
|
|
return_value=httpx.Response(
|
|
201,
|
|
json={
|
|
"author": "assistant",
|
|
"seq": 0,
|
|
"phase": "seeded",
|
|
"turn_id": "t1",
|
|
"session_id": "s1",
|
|
"content_chars": len(content),
|
|
"injected_at": "2026-07-06T00:00:00+00:00",
|
|
},
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
|
assert result == content
|
|
assert route.call_count == 1 # POST-002: exactly one history POST
|
|
assert json.loads(route.calls[0].request.content) == {
|
|
"author": "assistant",
|
|
"content": content,
|
|
"idempotency_key": key,
|
|
}
|
|
|
|
@respx.mock
|
|
async def test_no_preset_zero_http(self) -> None:
|
|
"""no_preset_zero_http [happy]: no-preset agent → None, ZERO HTTP (INV-002)."""
|
|
route = respx.post("https://w.example/sessions/s1/history").mock(
|
|
return_value=httpx.Response(201, json={})
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
result = await seed_preset_first_message(client, "s1", "mimir")
|
|
assert result is None
|
|
assert not route.called
|
|
|
|
@respx.mock
|
|
async def test_feature_absent_swallowed(self) -> None:
|
|
"""feature_absent_swallowed [error]: 404 hide-existence → None, no raise (INV-001)."""
|
|
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:
|
|
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
|
assert result is None
|
|
|
|
@respx.mock
|
|
async def test_session_api_failed_swallowed(self) -> None:
|
|
"""session_api_failed_swallowed [error]: 409 → None, no raise (INV-001)."""
|
|
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:
|
|
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
|
assert result is None
|
|
|
|
@respx.mock
|
|
async def test_transport_error_swallowed(self) -> None:
|
|
"""transport_error_swallowed [error]: httpx.ConnectError → None, no raise (INV-001)."""
|
|
respx.post("https://w.example/sessions/s1/history").mock(
|
|
side_effect=httpx.ConnectError("boom")
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
|
assert result is None
|
|
|
|
@respx.mock
|
|
async def test_unexpected_exception_swallowed(self) -> None:
|
|
"""unexpected_exception [error]: write raises ValueError → None (broad never-raise)."""
|
|
respx.post("https://w.example/sessions/s1/history").mock(
|
|
side_effect=ValueError("unexpected")
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
|
assert result is None
|
|
|
|
async def test_cancellation_propagates(self) -> None:
|
|
"""cancellation_propagates [error]: CancelledError from the write is RE-RAISED."""
|
|
import ratatoskr.first_message as fm
|
|
|
|
async def _cancel(*_a: object, **_k: object) -> None:
|
|
raise asyncio.CancelledError
|
|
|
|
orig = fm.write_authored_history
|
|
fm.write_authored_history = _cancel # type: ignore[assignment]
|
|
try:
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
|
finally:
|
|
fm.write_authored_history = orig # type: ignore[assignment]
|
|
|
|
@respx.mock
|
|
async def test_malformed_agent_id_no_http(self) -> None:
|
|
"""malformed_agent_id [adversarial]: non-str or empty agent_id → None; no HTTP; no raise."""
|
|
route = respx.post(url__regex=r".*/history$").mock(
|
|
return_value=httpx.Response(201, json={})
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
assert await seed_preset_first_message(client, "s1", 123) is None # type: ignore[arg-type]
|
|
assert await seed_preset_first_message(client, "s1", "") is None
|
|
assert not route.called
|
|
|
|
@respx.mock
|
|
async def test_empty_session_id(self) -> None:
|
|
"""empty_session_id [adversarial]: "" → None (soft guard); no HTTP; no raise."""
|
|
route = respx.post("https://w.example/sessions/s1/history").mock(
|
|
return_value=httpx.Response(201, json={})
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
result = await seed_preset_first_message(client, "", "ratatoskr:sindra")
|
|
assert result is None
|
|
assert not route.called
|