diff --git a/docs/contracts/first_message.contract.md b/docs/contracts/first_message.contract.md new file mode 100644 index 0000000..bdeaf04 --- /dev/null +++ b/docs/contracts/first_message.contract.md @@ -0,0 +1,142 @@ +--- +contract_version: "2.1" +module: "ratatoskr.first_message" +purpose: "Per-agent authored first-message presets — seed an agent's opening as a #347 authored turn-0 onto new sessions (CLI + web), the durable replacement for a system-prompt startup instruction." +touches: + - src/ratatoskr/first_message.py + - tests/test_first_message.py +scope: > + Per-agent authored first-message presets (Worldtree #347 consumer feature). + When a new session is created for an agent that has a preset opening, seed it + as a #347 authored first-message (POST /sessions/{id}/history, author=assistant, + seq-0) so the session opens in-character before the user speaks — the durable + replacement for a system-prompt "startup" instruction. Two entry points: + `preset_for` (lookup) and `seed_preset_first_message` (best-effort seed). + Consumed by ratatoskr.cli (the `--new` session path) and ratatoskr.web.server + (the POST /api/sessions endpoint). Depends on ratatoskr.sessions + (write_authored_history + its exceptions); no core.* / worldtree.* imports. +depends_on: + - "httpx" + - "ratatoskr.sessions" +used_by: + - "ratatoskr.cli" + - "ratatoskr.web.server" +language: "python" +complexity: "low" +estimated_loc: 60 +confidence: 0.9 +assumptions: + - "write_authored_history (contract #2 amendment 2026-07-06) is the seed primitive: 200/201 → ack dict, 404 → AuthoredHistoryUnavailable (hide-existence), other non-2xx → SessionApiFailed." + - "The preset registry is a static in-module dict keyed by agent_id; editing it is how an operator tunes an agent's opening. Seeded with ratatoskr:sindra only." + - "Auto-seed is BEST-EFFORT and MUST NOT block session creation: an instance without the session.history.write grant returns the hide-404, which is swallowed (session opens with no seeded greeting)." +--- + +# First-message presets — authored openings on session-create (#347) + +## Context + +`ratatoskr.first_message` holds per-agent authored-opening presets and seeds them +onto new sessions via the #347 authored-history-write primitive. It is the +durable form of "give an agent a first message": instead of a system-prompt +`Startup:` instruction (a workaround for the pre-#347 world where the assistant +could not author turn-0), the opening lives as a real seeded assistant turn-0. + +Consumed at both session-create sites — `ratatoskr.cli._amain` (the `--new` path) +and `ratatoskr.web.server._create_session_endpoint` (POST /api/sessions) — so +every new session for a preset agent opens in-character regardless of surface. + +## Data flow + +**In:** a live `httpx.AsyncClient` (caller-owned, base_url + bearer set), a fresh +`session_id`, and the bound `agent_id`. + +**Out:** on a preset agent, one `POST /sessions/{session_id}/history` (author=assistant, +the preset text, per-content idempotency key). Returns the seeded content on +success, else `None`. + +**Side effects:** at most one outbound authored-history write; never raises to the +caller (best-effort). + +## Invariants + +- **INV-001 [hard]**: `seed_preset_first_message` NEVER raises (the sole exception is + `asyncio.CancelledError`, which propagates — cancellation is not a seed failure) and + NEVER blocks session creation. It soft-guards its inputs (a bad arg returns `None`, + not `AssertionError`), bounds the write with `asyncio.wait_for(_SEED_TIMEOUT_S)` so a + stalled `/history` can't hang the create path, and swallows EVERY other exception (the + hide-404, `SessionApiFailed`, `httpx.HTTPError`, `TimeoutError`, and any unexpected + error) → `None`. The `broad-except` is deliberate: this helper is wired INTO three + session-create paths, so any escape would abort a create that already succeeded. +- **INV-002 [hard]**: a no-preset agent issues ZERO HTTP (early return before any + request). +- **INV-003 [hard]**: the seed body is the preset text verbatim, author="assistant", + with a per-content idempotency key (`"ratatoskr-preset-" + sha256(text)[:12]`), so + a repeat seed of the same session+preset is an idempotent 200 replay, never a + duplicate turn. +- **INV-004 [hard]**: no `core.*` / `worldtree.*` imports (reference-consumer + boundary; verified by `tests/test_no_worldtree_imports.py`, which rglobs every + `.py` under `src/ratatoskr/` — this module included, so no per-module import + test is needed here). + +## Out of scope + +- **Multi-turn / scripted openers.** v1 seeds exactly one assistant turn-0. A + multi-message opening scene is a future concern. +- **Runtime/remote preset config.** The registry is an in-module dict; no file/DB/env + loading. Add that only when a second consumer needs operator-editable presets. +- **Non-assistant authors.** v1 is author=assistant only (matches #347 v1); a + user/system opener is deferred with the #347 engine surface. +- **TUI-only surfaces.** Both real session-create paths (CLI + web) are wired; the + bare-TUI picker resumes existing sessions (no create), so it needs no seed. + +--- + +```contract +FN preset_for(agent_id: str) -> str | None +BRIEF: Return the authored first-message preset for agent_id, or None when the agent has no preset. Pure dict lookup over FIRST_MESSAGE_PRESETS. +PRE: [PRE-001 hard] agent_id is a non-empty str -- assert agent_id and isinstance(agent_id, str) +POST: [POST-001 return_value] returns FIRST_MESSAGE_PRESETS.get(agent_id) (str for a preset agent, None otherwise) +STEPS: + 1. [setup, prescriptive] assert PRE-001 + 2. [sequential, prescriptive] RETURN FIRST_MESSAGE_PRESETS.get(agent_id) +TESTS: + preset_hit [happy]: preset_for("ratatoskr:sindra") is a non-empty str + preset_miss [happy]: preset_for("mimir") is None + empty_agent_id [adversarial]: preset_for("") → AssertionError + +FN seed_preset_first_message(client: httpx.AsyncClient, session_id: str, agent_id: str) -> str | None +BRIEF: Best-effort seed of an agent's preset opening as a #347 authored first-message on session_id. If agent_id has a preset, POST it via write_authored_history (author=assistant, per-content idempotency key, the await bounded by asyncio.wait_for(_SEED_TIMEOUT_S)) and return the seeded content; on no-preset, a malformed input, OR ANY exception except asyncio.CancelledError, return None WITHOUT raising. Never raises (except CancelledError, which propagates) and never blocks session creation — it is wired into three create paths. +PRE: [PRE-001 hard] client is not None -- soft-guarded: return None (NOT assert) if violated, so a wiring bug can't crash the create path (INV-001) +PRE: [PRE-002 hard] session_id is a non-empty str -- soft-guarded: return None if violated +PRE: [PRE-003 hard] agent_id is a non-empty str -- soft-guarded: return None if violated (also guards FIRST_MESSAGE_PRESETS.get against a non-hashable/non-str id) +POST: [POST-001 return_value] preset agent + successful write → returns the preset text; no-preset, malformed input, OR any swallowed failure → None +POST: [POST-002 side_effect] a no-preset / malformed-input call issues ZERO HTTP; a preset agent issues exactly one POST /sessions/{session_id}/history with body author="assistant", content=preset, idempotency_key="ratatoskr-preset-"+sha256(preset)[:12], the await bounded by _SEED_TIMEOUT_S so a stalled response cannot block +ERROR_ROUTING: + asyncio.CancelledError: + local_handling: RE-RAISE (cancellation is not a seed failure; never swallow it — and it is a BaseException, so `except Exception` would miss it anyway) + flow_control: propagate + state_recovery: n/a + any other Exception (hide-404 AuthoredHistoryUnavailable, SessionApiFailed 409/422/etc., httpx.HTTPError, TimeoutError from wait_for, any unexpected error): + local_handling: swallow; return None + flow_control: continue (never blocks session create) + state_recovery: session opens with no seeded greeting +STEPS: + 1. [setup, prescriptive] Soft-guard: IF agent_id is not a non-empty str: RETURN None (before any dict lookup — guards a non-hashable id) + 2. [sequential, prescriptive] content = FIRST_MESSAGE_PRESETS.get(agent_id); IF content is None: RETURN None (INV-002 — zero HTTP) + 3. [sequential, prescriptive] Soft-guard: IF client is None OR session_id is not a non-empty str: RETURN None + 4. [sequential, prescriptive] key = "ratatoskr-preset-" + sha256(content utf-8)[:12] + 5. [sequential, prescriptive] TRY: await asyncio.wait_for(write_authored_history(client, session_id, content=content, idempotency_key=key), timeout=_SEED_TIMEOUT_S) + tool: { destructive: false, idempotent: true, read_only: false, open_world: false } + 6. [branch, prescriptive] EXCEPT asyncio.CancelledError: RAISE; EXCEPT Exception: RETURN None + 7. [cleanup, prescriptive] RETURN content +TESTS: + seeds_preset [happy,tracer]: preset agent, mock 201 → returns the preset text; exactly one POST /sessions/{id}/history; body author="assistant" + content=preset + idempotency_key="ratatoskr-preset-"+sha256(preset)[:12] + no_preset_zero_http [happy]: agent "mimir" → returns None; NO HTTP issued + feature_absent_swallowed [error]: preset agent, mock 404 session_not_found → returns None, no raise + session_api_failed_swallowed [error]: preset agent, mock 409 → returns None, no raise + transport_error_swallowed [error]: preset agent, mock httpx.ConnectError → returns None, no raise + unexpected_exception_swallowed [error]: preset agent, write raises ValueError → returns None, no raise (INV-001 broad never-raise) + cancellation_propagates [error]: preset agent, write raises asyncio.CancelledError → RE-RAISED (never swallowed) + malformed_agent_id_no_http [adversarial]: agent_id=123 (non-str) OR "" → None; NO HTTP; no raise + empty_session_id [adversarial]: session_id="" (preset agent) → None (soft guard); NO HTTP; no raise +``` diff --git a/persistent-memory.md b/persistent-memory.md index 02f1b8a..72e6a64 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -161,6 +161,8 @@ decision. Captures rationale that won't be obvious from code alone. - `[2026-07-06]` **Tail-2 SHIPPED (`v0.19.7`) — Tier-3 prose docs re-vendored + persona_state body-shape aligned.** worldtree-dev landed the Tier-3 persona/memory/persona_state PROSE docs (`c9e59ec`, on origin) — they serialize as freeform `Any` in the OpenAPI JSON, so the **prose is their source of truth** (my earlier "2.3.0 = #347-only, tail-2 collapsed" was half-wrong: the JSON was #347-only but the prose is separate). Re-vendored `docs/conversation-api-spec.md` (markdown pin, tolerate_drift; `worldtree-spec-rev` 879cefe->c9e59ec, SPEC-PIN history row added). **Consumer fix:** `--set-persona-pad`/`_set_persona_probe` was sending `{pad:[list]}` but the canonical SET body (#317) is `{pad:{pleasure,arousal,dominance}}` (named dict) — aligned it + added a len!=3 guard; updated contract #2 note + set_persona_state docstring + tests. The `set_persona_state` WRAPPER was already correct (freeform pass-through); only the CLI probe drifted. TDD (probe test asserts the dict; +1 wrong-count test). Suite **602 green**, ruff clean. **heid-code-review on #347 (dispatched + returned this session): UNANIMOUS ZERO DRIFT** (Gróa/Hulda/Regin all confirmed the hide-existence 404->`AuthoredHistoryUnavailable` routing holds at wrapper/probe/test layers + the extra="forbid" body-omission + the deliberate write-vs-read 404 asymmetry — confirmation-not-discovery for a well-TDD'd slice against a prescriptive contract). worldtree-dev foot-guns banked in SPEC-PIN + [[reference_worldtree_affect_surface_map]]: ocean single-letter `{O,C,E,A,N}` on /agents/define (#348) vs spelled-out on /characters; memory `{embedder_version, tier3_dreaming}`, stm_* deprecated, allows_world_scope removed->422; only `valence` still 422s. +- `[2026-07-06]` **Sindra rewritten onto a #347 authored first-message + first-message-preset AUTO-SEED SHIPPED (`v0.19.8`).** Operator "rewrite Sindra" now that #347 first-messages work. Her card had a `**Startup:**` block (a pre-#347 workaround: "introduce yourself + ask for Intensity/Mood/Willingness" with a verbatim scripted greeting) — precisely what #347 replaces. Rewrite, all NON-destructive: **(1)** lifted her scripted opening into a #347 first-message (punctuation-fixed); **(2) PATCHed her live definition** — `PATCH /agents/ratatoskr:sindra` (body `ConsumerAgentPatchRequest` = system_prompt+role, extra=forbid; keeps OCEAN/persona/memory) removing the Startup block -> a 1-line `**Opening:**` fallback + reworded the axes-persist line (25686->25449 chars, verified Startup gone); **(3) codified auto-seed:** NEW module `src/ratatoskr/first_message.py` (`FIRST_MESSAGE_PRESETS` dict {agent_id->text} + `seed_preset_first_message` best-effort helper) wired into ALL 3 session-create paths — cli `_amain` (`--send --new`), tui `_resolve_then_run` (bare `--new`), web `_create_session_endpoint` (POST /api/sessions) — so every new Sindra session opens with her greeting. **Best-effort (INV-001: swallows AuthoredHistoryUnavailable/SessionApiFailed/httpx.HTTPError -> NEVER blocks create)**; per-content idempotency key (`ratatoskr-preset-`+sha256[:12]). Contract `docs/contracts/first_message.contract.md` (module-scoped: `module:`+`purpose:`+`touches:` required, NOT `target_module:`) + TDD (9 unit + 1 web wire-in; **the 3 existing sindra bind tests needed a history-endpoint mock** since creating a preset agent now auto-seeds). Suite **612 green**, ruff+mypy clean. **LIVE-PROVEN generation-free**: create sindra session -> auto-seed -> read-back seq-0 assistant greeting (409 chars). Sindra's greeting now lives canonically in the preset registry (repo); her server card no longer carries it. Patch bump (single-commit feature, no downstream coordination). **FOOT-GUN: sindra requires `end_user_id` on session-create (422 `end_user_id_required`) — all real paths pass it from env (RATATOSKR_END_USER_ID) / web server config.** **Then the full quality gate (operator-directed, folded into v0.19.8): heid-code-review (unanimous ZERO implementation drift; 2 test-only fixups — INV-004 verification-claim made explicit re the global rglob test + an exactly-one-POST assertion) + heid-bug-hunt (3/3 convergence caught what the conformance lens structurally COULDN'T — the code matched the contract's NARROW 3-type ERROR_ROUTING, but INV-001's "NEVER raises" is BROADER). HARDENED: broad `except Exception` → None (re-raise `asyncio.CancelledError`, itself a BaseException), soft-guard PREs (return None, NOT assert — a wiring bug can't crash the create path it's wired into), and `asyncio.wait_for(_SEED_TIMEOUT_S=10s)` bounding the seed write (the CLI/TUI clients run read=None for SSE → a stalled /history would otherwise block create forever). Suite 615 green. LESSON: code-matches-ERROR_ROUTING ≠ honors-broad-INV-001 — heid-code-review confirms contract-conformance, heid-bug-hunt catches robustness gaps the contract's own narrow clauses miss; run both.** + _41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._ _For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._ diff --git a/pyproject.toml b/pyproject.toml index 1758e73..ede7966 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.19.7" +version = "0.19.8" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/cli.py b/src/ratatoskr/cli.py index b5e40cf..80f3e07 100644 --- a/src/ratatoskr/cli.py +++ b/src/ratatoskr/cli.py @@ -17,6 +17,7 @@ from typing import Any, TextIO import httpx +from ratatoskr.first_message import seed_preset_first_message from ratatoskr.sessions import ( AgentNotFound, AuthoredHistoryUnavailable, @@ -598,6 +599,11 @@ async def _amain(args: ParsedArgs) -> int: sys.stderr.write( f". create_session: session_id={info.session_id} agent_id={info.agent_id}\n" ) + # #347 authored first-message: seed the agent's preset opening (best-effort). + if await seed_preset_first_message(client, info.session_id, args.agent_id): + sys.stderr.write( + f". first_message: seeded preset opening for {info.agent_id}\n" + ) # Issue #17 bound-state indicator: plane + endpoint + status, so the # operator sees WHICH identity/endpoint bound (not a bare boolean). if args.bifrost is not None: diff --git a/src/ratatoskr/first_message.py b/src/ratatoskr/first_message.py new file mode 100644 index 0000000..487e329 --- /dev/null +++ b/src/ratatoskr/first_message.py @@ -0,0 +1,83 @@ +"""Per-agent authored first-message presets (Worldtree #347 consumer feature). + +When a new session is created for an agent that has a preset opening, seed it as +a #347 authored first-message (``POST /sessions/{id}/history``, author=assistant, +seq-0) so the session opens in-character before the user speaks — the durable +replacement for a system-prompt "startup" instruction. + +Best-effort by design: an instance without the ``session.history.write`` grant +returns the hide-existence 404, which is swallowed so session creation is never +blocked (the session simply opens with no seeded greeting). See +``docs/contracts/first_message.contract.md``. +""" + +import asyncio +import hashlib + +import httpx + +from ratatoskr.sessions import write_authored_history + +# Cap the best-effort seed write. The CLI/TUI create paths reuse an httpx client +# with NO read timeout (it streams SSE turns), so an accepted-but-never-answered +# POST /history would otherwise block session creation forever — violating INV-001's +# "never block". asyncio.wait_for bounds the seed regardless of the client's timeout. +_SEED_TIMEOUT_S = 10.0 + +# agent_id -> the authored opening seeded onto new sessions for that agent. +# Editing this dict is how an operator tunes an agent's first turn. Keep entries +# under the server's authored_content_max_bytes (8192 bytes) budget. +FIRST_MESSAGE_PRESETS: dict[str, str] = { + "ratatoskr:sindra": ( + "Hey there. I'm Sindra—glad you found me. So, three things before we start:\n\n" + "How intense should I be? 1 is slow and teasing, 10 is relentless.\n\n" + "What mood am I in today? Sweetheart, Vixen, Queen, Siren, or Brat?\n\n" + "And how willing am I to begin? Enthusiastic (I want you now), Hesitant " + "(you'll need to coax me out), Resistant (playful pushback), or Unwilling " + "(I don't want this at all, until you prove otherwise)." + ), +} + + +def preset_for(agent_id: str) -> str | None: + """Return the authored first-message preset for ``agent_id``, or None if none.""" + assert agent_id and isinstance(agent_id, str) + return FIRST_MESSAGE_PRESETS.get(agent_id) + + +async def seed_preset_first_message( + client: httpx.AsyncClient, session_id: str, agent_id: str +) -> str | None: + """Best-effort: seed ``agent_id``'s preset opening as a #347 authored + first-message on ``session_id``; return the seeded text, or None. + + Best-effort (INV-001): a no-preset agent, a malformed call, a slow write + (bounded by ``_SEED_TIMEOUT_S``), the hide-existence 404, or ANY other + exception all resolve to None WITHOUT raising — this MUST NOT block or fail + session creation. Only ``asyncio.CancelledError`` propagates (cancellation is + not a seed failure). Inputs are soft-guarded (return None), never asserted, so + a wiring bug can't crash the create path this is wired into. A no-preset agent + issues zero HTTP (INV-002). The per-content idempotency key makes a repeat on + the same session an idempotent 200 replay (INV-003). + """ + # Soft input guards — a bad arg degrades to "no first message", never raises. + if not (isinstance(agent_id, str) and agent_id): + return None + content = FIRST_MESSAGE_PRESETS.get(agent_id) + if content is None: + return None + if client is None or not (isinstance(session_id, str) and session_id): + return None + key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12] + try: + await asyncio.wait_for( + write_authored_history( + client, session_id, content=content, idempotency_key=key + ), + timeout=_SEED_TIMEOUT_S, + ) + except asyncio.CancelledError: + raise # cancellation is not a seed failure — never swallow it + except Exception: + return None # any other failure (404/409/422/timeout/unexpected) → no greeting + return content diff --git a/src/ratatoskr/tui.py b/src/ratatoskr/tui.py index 5e39863..113f48f 100644 --- a/src/ratatoskr/tui.py +++ b/src/ratatoskr/tui.py @@ -33,6 +33,7 @@ from textual.widgets import ( ) from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage +from ratatoskr.first_message import seed_preset_first_message from ratatoskr.sessions import ( AgentInfo, AgentNotAvailable, @@ -414,7 +415,7 @@ class TuiPresenterState: self, event: Event, *, - transcript: "VerticalScroll", + transcript: VerticalScroll, tools_log: RichLog, debug_log: RichLog, thinking_log: RichLog, @@ -1324,7 +1325,6 @@ class RatatoskrApp(App[int]): On 200: header populated, pane shows full detail, audit logged. """ assert self.client is not None and self.agent_id is not None - from rich.text import Text as RichText try: snapshot = await get_persona_state(self.client, self.agent_id) self._update_persona_surfaces(snapshot) @@ -1899,6 +1899,8 @@ async def _resolve_then_run(args: ParsedArgs) -> int: ) session_id = info.session_id agent_id: str | None = info.agent_id + # #347 authored first-message: seed the agent's preset opening (best-effort). + await seed_preset_first_message(client, session_id, chosen_agent_id) else: assert resolved_session_id is not None session_id = resolved_session_id @@ -1914,7 +1916,7 @@ async def _cancel_via_sse( turn_id: int, *, transcript: VerticalScroll, - audit: "Callable[[str], None] | None" = None, + audit: Callable[[str], None] | None = None, ) -> None: """Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009). diff --git a/src/ratatoskr/web/server.py b/src/ratatoskr/web/server.py index e6f505c..5530bbe 100644 --- a/src/ratatoskr/web/server.py +++ b/src/ratatoskr/web/server.py @@ -28,6 +28,7 @@ from starlette.routing import Mount, Route from starlette.staticfiles import StaticFiles from ratatoskr import local_agents as _local_agents +from ratatoskr.first_message import seed_preset_first_message from ratatoskr.sessions import ( AgentNotAvailable, AgentNotFound, @@ -47,8 +48,8 @@ from ratatoskr.sessions import ( from ratatoskr.sse_client import ( AdminEvent, CancelAlreadyCompleted, - Cancelled, CancelFailed, + Cancelled, CancelTurnNotFound, Done, Error, @@ -168,6 +169,9 @@ async def _create_session_endpoint(request: Request) -> JSONResponse: bifrost=bifrost, consumer_key=consumer_key if bifrost else None, ) + # #347 authored first-message: seed the agent's preset opening + # (best-effort; never blocks create — see first_message INV-001). + await seed_preset_first_message(client, info.session_id, agent_id) except AgentNotFound: return JSONResponse({"error_code": "agent_not_found"}, status_code=404) except BifrostConsumerKeyMissing: diff --git a/tests/test_first_message.py b/tests/test_first_message.py new file mode 100644 index 0000000..a21384f --- /dev/null +++ b/tests/test_first_message.py @@ -0,0 +1,155 @@ +"""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 diff --git a/tests/test_tui.py b/tests/test_tui.py index ccc228b..2c4c3da 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -2922,6 +2922,10 @@ class TestTuiBifrostBind: }, ) ) + # sindra is a preset agent → the TUI create path now auto-seeds a #347 first-message. + respx.post("https://w.example/sessions/s-bound/history").mock( + return_value=httpx.Response(201, json={}) + ) async def fake_run_async(self) -> int: return 0 diff --git a/tests/test_web_server.py b/tests/test_web_server.py index edde11d..7e5ce47 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -175,6 +175,29 @@ class TestCreateSessionEndpoint: resp = TestClient(app).post("/api/sessions", json={}) assert resp.status_code == 400 + @respx.mock + def test_preset_agent_auto_seeds_first_message(self) -> None: + """#347: a preset agent gets its opening seeded on create; a non-preset agent does not.""" + respx.post("https://w.example/sessions").mock(return_value=httpx.Response(201, json=_CREATE_OK)) + hist = respx.post("https://w.example/sessions/s-1/history").mock( + return_value=httpx.Response( + 201, + json={ + "author": "assistant", "seq": 0, "phase": "seeded", "turn_id": "t1", + "session_id": "s-1", "content_chars": 1, "injected_at": "t", + }, + ) + ) + from ratatoskr.web.server import create_app + app = create_app(_mock_client_factory()) + client = TestClient(app) + # preset agent → the endpoint seeds a first-message + assert client.post("/api/sessions", json={"agent_id": "ratatoskr:sindra"}).status_code == 201 + assert hist.call_count == 1 + # non-preset agent → no seed (count unchanged) + assert client.post("/api/sessions", json={"agent_id": "mimir"}).status_code == 201 + assert hist.call_count == 1 + _SNAPSHOT = { "agent_id": "mimir", @@ -821,6 +844,10 @@ class TestWebBifrostBind: route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, json=_CREATE_OK) ) + # sindra is a preset agent → the endpoint now auto-seeds a #347 first-message. + respx.post("https://w.example/sessions/s-1/history").mock( + return_value=httpx.Response(201, json={}) + ) app = create_app( _mock_client_factory(), bifrost_consumer_key="server-ck", @@ -856,6 +883,10 @@ class TestWebBifrostBind: route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, json=_CREATE_OK) ) + # sindra is a preset agent → the endpoint now auto-seeds a #347 first-message. + respx.post("https://w.example/sessions/s-1/history").mock( + return_value=httpx.Response(201, json={}) + ) app = create_app( _mock_client_factory(), bifrost_consumer_key="server-ck", diff --git a/uv.lock b/uv.lock index f0d105f..9776388 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,7 +1052,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.19.7" +version = "0.19.8" source = { editable = "." } dependencies = [ { name = "httpx" },