Compare commits

..

4 Commits

Author SHA1 Message Date
vh 263ec2917b fix: render the seeded first-message on the web UI (v0.19.9)
The #347 auto-seed worked (the greeting was in the session ledger at seq-0),
but the web UI never showed it: there was no GET /api/sessions/{id}/messages
route and startSession() went straight from create to persona/tools/admin
hydration, so the transcript only filled from the live turn stream + user
echoes — a seeded turn-0 was invisible.

- server: new proxy route GET /api/sessions/{id}/messages -> get_session_messages
  (mirrors the tools/bifrost proxies; status-preserving envelope).
- SPA: loadTranscript(sessionId) fetches it on open and renders existing turns
  (assistant -> .response .md-body via markdownSafe escape-first; user ->
  .prompt-echo via textContent), called after the workspace opens. Best-effort.

web_debug_surface contract amended (endpoint + loadTranscript). 2 web route
tests, suite 617 green. Playwright DOM check proved the render end-to-end
(drive the real UI -> Sindra's greeting bubble appears).
2026-07-06 15:19:56 -07:00
vh be171304f5 feat: authored first-message presets — auto-seed on session-create (v0.19.8)
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.
2026-07-06 14:23:53 -07:00
vh 4aec3061d5 docs: #347 consumer side live-proven (201 path) on personal :8081
Grant applied (rule-based Heimdall allow, worldtree-dev). Smoke: create
mimir session -> seed -> 201 (seq=0, phase=seeded, turn_id=1798) -> GET
/messages reads back a plain role=assistant turn (model-invisible provenance
confirmed). Coverage-map #347 row + persistent-memory upgraded pending -> live-proven.
2026-07-06 13:01:57 -07:00
vh e643d38f58 fix: persona_state SET body → canonical {pad:{pleasure,arousal,dominance}} + re-vendor Tier-3 prose (v0.19.7)
worldtree-dev landed the Tier-3 persona/memory/persona_state prose docs
(c9e59ec) — shapes that serialize as freeform Any in the OpenAPI, so the
prose markdown is their source of truth. Re-vendored docs/conversation-api-spec.md
(tolerate_drift markdown pin; worldtree-spec-rev 879cefe→c9e59ec).

Consumer alignment: --set-persona-pad / _set_persona_probe was building
{pad:[list]}, but the canonical POST /sessions/{id}/persona_state body (#317)
is {pad:{pleasure,arousal,dominance}} (named dict). Aligned the probe to the
named dict + a len!=3 guard; updated contract #2's note, the set_persona_state
docstring, and the tests. The set_persona_state wrapper was already correct
(freeform pass-through) — only the CLI probe's body construction drifted.

Suite 602 green. (Also this session: heid-code-review on the #347 slice
returned unanimous zero drift across all three panel arms.)
2026-07-06 09:56:46 -07:00
21 changed files with 746 additions and 34 deletions
+2 -2
View File
@@ -120,8 +120,8 @@ id = "worldtree-conversation-api-spec-v1"
canonical_source = "Worldtree" canonical_source = "Worldtree"
canonical_path = "docs/conversation-api-spec.md" canonical_path = "docs/conversation-api-spec.md"
consumer_path = "docs/conversation-api-spec.md" consumer_path = "docs/conversation-api-spec.md"
pinned_sha256_16 = "2d111a3b8322b7d1" pinned_sha256_16 = "c656a789caceef14"
pinned_at = "2026-06-30T22:25:56+00:00" pinned_at = "2026-07-06T16:51:09+00:00"
tolerate_drift = true # prose reference; OpenAPI+SSE are the gates tolerate_drift = true # prose reference; OpenAPI+SSE are the gates
# Worldtree persona render canons (d2) — the deterministic affect->NL the agent is # Worldtree persona render canons (d2) — the deterministic affect->NL the agent is
+5 -4
View File
@@ -7,16 +7,17 @@ documents the pin, the vendored artifacts, and the bump procedure.
| Field | Value | | Field | Value |
|---|---| |---|---|
| Worldtree git SHA | `879cefef02bbf442c2d01ff4214299fac7c89809` | | Worldtree git SHA | `c9e59ec` |
| Worldtree HEAD message | `fix(#347): freeze the authored-history route into the OpenAPI schema (spec 2.3.0)` | | Worldtree HEAD message | `docs: document Tier-3 persona/memory schemas + persona_state SET body (OpenAPI 2.3.0)` |
| Pinned on | 2026-07-06 | | Pinned on | 2026-07-06 |
| Pinned by | ratatoskr-dev (re-vendor OpenAPI 2.2.0→2.3.0 — #347 authored-history-write route shipped) | | Pinned by | ratatoskr-dev (re-vendor prose markdown — Tier-3 persona/memory/persona_state consumer shapes) |
| Worldtree version at pin | `v1.0.0b22` | | Worldtree version at pin | `v1.0.0b22` |
## Pin history ## Pin history
| Date | SHA | Version | Notable deltas consumed | | Date | SHA | Version | Notable deltas consumed |
|---|---|---|---| |---|---|---|---|
| 2026-07-06 | `c9e59ec` | v1.0.0b22 | **Re-vendor the prose markdown — Tier-3 consumer shapes documented.** `c9e59ec` (docs-only, OpenAPI byte-unchanged vs `879cefe`) adds `docs/conversation-api-spec.md` § "Tier 3 — Consumer-defined agents": the persona / memory / persona_state SET-body shapes that serialize as freeform `Any` in the OpenAPI (so prose is their source of truth). Drove a consumer fix: `--set-persona-pad` now sends the canonical `{pad:{pleasure,arousal,dominance}}` named dict (was `{pad:[list]}`) — #317, `v0.19.7`. Foot-guns encoded: persona.ocean single-letter `{O,C,E,A,N}` on `/agents/define` (spelled-out → 422, the #348 mismatch) vs spelled-out on `POST /characters`; memory `{embedder_version, tier3_dreaming}`, stm_* deprecated, allows_world_scope removed→422; only `valence` still 422s. `pin:`-only for the markdown; the `v0.19.7` bump rode the persona_state code fix. |
| 2026-07-06 | `879cefe` | v1.0.0b22 | **Re-vendor OpenAPI 2.2.0→2.3.0 — Worldtree shipped #347 authored-history-write.** One new REST path-group: `POST /sessions/{session_id}/history` (the authored-history-write primitive) + the `AuthoredTurnResponse` schema (openapi path count 40→41). #347 is **OpenAPI-only** — the prose `conversation-api-spec.md` + server `conversation_api.contract.md` are byte-unchanged since the 5810a26 pin (empty `git log` delta), so those `tolerate_drift` pins stay clean; the SSE schema is unchanged (#347 is event-silent by design). **Consumer side NOT yet built**`POST /sessions/{id}/history` is a fresh in-scope ⬜ gap in `docs/coverage-map.md` (re-opens the v1 coverage-audit with exactly one gap; Heimdall-gated hide-existence → consumer treats 404 as feature-absent). `pin:`-only, no version bump. | | 2026-07-06 | `879cefe` | v1.0.0b22 | **Re-vendor OpenAPI 2.2.0→2.3.0 — Worldtree shipped #347 authored-history-write.** One new REST path-group: `POST /sessions/{session_id}/history` (the authored-history-write primitive) + the `AuthoredTurnResponse` schema (openapi path count 40→41). #347 is **OpenAPI-only** — the prose `conversation-api-spec.md` + server `conversation_api.contract.md` are byte-unchanged since the 5810a26 pin (empty `git log` delta), so those `tolerate_drift` pins stay clean; the SSE schema is unchanged (#347 is event-silent by design). **Consumer side NOT yet built**`POST /sessions/{id}/history` is a fresh in-scope ⬜ gap in `docs/coverage-map.md` (re-opens the v1 coverage-audit with exactly one gap; Heimdall-gated hide-existence → consumer treats 404 as feature-absent). `pin:`-only, no version bump. |
| 2026-06-30 | `5810a26` | v1.0.0b2 | **Re-pin to Worldtree's FROZEN v1 surface (#326), as part of the v1 coverage-audit.** Vendored the machine-readable artifacts — `conversation-api-openapi.json` (OpenAPI **2.2.0**, 40 path-groups) + `conversation-api-sse-events.schema.json` (11 events) — now the **authoritative drift gates** (pinned in `.corviduo-canonicals.toml`, CI-checked by `canonical_drift.py`). The prose `conversation-api-spec.md` is **byte-identical** to the v0.35.16 pin (last WT markdown edit 2026-05-31), kept as the human reference (`tolerate_drift`). b2 deltas already consumed in code: 409/503 eager turn-launch statuses (#331, v0.18.3/.4) + the unified error envelope (#328). 7 endpoints documented only in the OpenAPI, not the prose, all classified in `docs/coverage-map.md`: `admin/keys/bulk`, `admin/persona/{archive,erase}`, `admin/usage`, `embed`, `judgments`, `me/usage`. No client-breaking change — `pin:`-only, no version bump. | | 2026-06-30 | `5810a26` | v1.0.0b2 | **Re-pin to Worldtree's FROZEN v1 surface (#326), as part of the v1 coverage-audit.** Vendored the machine-readable artifacts — `conversation-api-openapi.json` (OpenAPI **2.2.0**, 40 path-groups) + `conversation-api-sse-events.schema.json` (11 events) — now the **authoritative drift gates** (pinned in `.corviduo-canonicals.toml`, CI-checked by `canonical_drift.py`). The prose `conversation-api-spec.md` is **byte-identical** to the v0.35.16 pin (last WT markdown edit 2026-05-31), kept as the human reference (`tolerate_drift`). b2 deltas already consumed in code: 409/503 eager turn-launch statuses (#331, v0.18.3/.4) + the unified error envelope (#328). 7 endpoints documented only in the OpenAPI, not the prose, all classified in `docs/coverage-map.md`: `admin/keys/bulk`, `admin/persona/{archive,erase}`, `admin/usage`, `embed`, `judgments`, `me/usage`. No client-breaking change — `pin:`-only, no version bump. |
| 2026-06-17 | `f1b59f8` | v0.35.16 | **#297 + #298/#299 — Worldtree adopts the bifrost v0.6 scope wire (emits `scope_any`/`scope_all`) + client-side per-scope-value union recall. With our v0.17.6 provider this closes cold cross-session recall end-to-end.** Catch-up bump (v0.29.0→v0.35.16). Intervening client-facing deltas reviewed, none break our consumer: #211 agent rename (`saga``echo`, `actor``mask` — slugs only); #245 `end_user_id` persistence + memory-scope resolver; #187/#188/#219 Tier-3 define/PATCH policy (additive); `bifrost` binding field + `ephemeral_does_not_accept_bifrost` 422 now documented (the #17 surface). Error codes stable; no ratatoskr code change required. | | 2026-06-17 | `f1b59f8` | v0.35.16 | **#297 + #298/#299 — Worldtree adopts the bifrost v0.6 scope wire (emits `scope_any`/`scope_all`) + client-side per-scope-value union recall. With our v0.17.6 provider this closes cold cross-session recall end-to-end.** Catch-up bump (v0.29.0→v0.35.16). Intervening client-facing deltas reviewed, none break our consumer: #211 agent rename (`saga``echo`, `actor``mask` — slugs only); #245 `end_user_id` persistence + memory-scope resolver; #187/#188/#219 Tier-3 define/PATCH policy (additive); `bifrost` binding field + `ephemeral_does_not_accept_bifrost` 422 now documented (the #17 surface). Error codes stable; no ratatoskr code change required. |
@@ -32,7 +33,7 @@ documents the pin, the vendored artifacts, and the bump procedure.
**Reference (prose; allowed to lag — `tolerate_drift`):** **Reference (prose; allowed to lag — `tolerate_drift`):**
- `docs/conversation-api-spec.md` — copy of `Worldtree/docs/conversation-api-spec.md` at the pinned SHA. The **client-facing prose narrative**. Byte-frozen at v0.35.16-era content (last WT edit 2026-05-31); the OpenAPI/SSE JSON above are the source of truth where they diverge. Pinned `worldtree-conversation-api-spec-v1` (tolerate_drift). - `docs/conversation-api-spec.md` — copy of `Worldtree/docs/conversation-api-spec.md` at the pinned SHA. The **client-facing prose narrative**. Re-vendored at `c9e59ec` (2026-07-06) to carry the § "Tier 3 — Consumer-defined agents" subsections (persona/memory/persona_state SET body) that serialize as freeform `Any` in the OpenAPI JSON — so the **prose is the source of truth for those consumer shapes** (e.g. persona.ocean single-letter `{O,C,E,A,N}` on `/agents/define`; `POST /sessions/{id}/persona_state` body `{pad:{pleasure,arousal,dominance}}`). Elsewhere the OpenAPI/SSE JSON above remain authoritative. Pinned `worldtree-conversation-api-spec-v1` (tolerate_drift).
- `docs/conversation_api.contract.md` — copy of `Worldtree/docs/contracts/conversation_api.contract.md` at the pinned SHA (byte-identical at b2 — server contract unchanged since the v0.35.16 pin). The **server-side contract** including INV-001..INV-052 and amendments. Useful for understanding load-bearing server invariants (e.g., INV-014 turn-id-public, INV-046 admin-events-envelope-stable, INV-049 admin-events-pii-discipline) when designing client behavior against them. Not in the canonical manifest (reference-only). - `docs/conversation_api.contract.md` — copy of `Worldtree/docs/contracts/conversation_api.contract.md` at the pinned SHA (byte-identical at b2 — server contract unchanged since the v0.35.16 pin). The **server-side contract** including INV-001..INV-052 and amendments. Useful for understanding load-bearing server invariants (e.g., INV-014 turn-id-public, INV-046 admin-events-envelope-stable, INV-049 admin-events-pii-discipline) when designing client behavior against them. Not in the canonical manifest (reference-only).
Both files are vendored — they reflect Worldtree at the pinned SHA, not Both files are vendored — they reflect Worldtree at the pinned SHA, not
+142
View File
@@ -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
```
+4 -2
View File
@@ -322,7 +322,9 @@ via a `--characters` one-shot lifecycle probe; persona-state write surfaced via
wrappers: parsed dict verbatim (or None on 204), any off-status → SessionApiFailed. wrappers: parsed dict verbatim (or None on 204), any off-status → SessionApiFailed.
**Note:** `set_persona_state`'s request body is FREEFORM — the frozen OpenAPI 2.2.0 **Note:** `set_persona_state`'s request body is FREEFORM — the frozen OpenAPI 2.2.0
declares no request schema and the prose spec documents only the GET counterpart, declares no request schema and the prose spec documents only the GET counterpart,
so the caller supplies the snapshot shape (`--set-persona-pad` sends `{pad:[…]}`). so the caller supplies the snapshot shape. **Canonical (worldtree-dev prose #317,
`c9e59ec`): `{pad:{pleasure,arousal,dominance}}` — a named-key dict, NOT a list;
`--set-persona-pad` builds + sends the named dict (each float in [-1,1]).**
```contract ```contract
FN list_character_models(client) -> dict[str, Any] FN list_character_models(client) -> dict[str, Any]
@@ -369,7 +371,7 @@ POST: [POST-001 return_value] on 204 returns None; [POST-002 side_effect] outbou
STEPS: STEPS:
1. [sequential, prescriptive] resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot); IF 204 RETURN None; ELSE RAISE SessionApiFailed 1. [sequential, prescriptive] resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot); IF 204 RETURN None; ELSE RAISE SessionApiFailed
TESTS: TESTS:
happy [happy]: 204 → None; body == {"pad":[...]} verbatim happy [happy]: 204 → None; body == {"pad":{"pleasure","arousal","dominance"}} verbatim (canonical named-key dict, #317)
non_204 [error]: 422 → SessionApiFailed(422) non_204 [error]: 422 → SessionApiFailed(422)
``` ```
@@ -35,6 +35,17 @@ functions:
steps: "Open client_factory() client; await get_session_tools(client, session_id); return 200. Except SessionApiFailed -> status-preserving envelope." steps: "Open client_factory() client; await get_session_tools(client, session_id); return 200. Except SessionApiFailed -> status-preserving envelope."
flexibility: "prescriptive" flexibility: "prescriptive"
- name: "_session_messages_endpoint"
signature: "async _session_messages_endpoint(request: Request) -> JSONResponse"
description: "GET /api/sessions/{session_id}/messages — proxy the session's message history so the SPA renders existing turns on open (notably a #347 authored first-message seeded at create-time; without it a seeded session's transcript is blank until the user speaks)."
preconditions:
- "session_id in path_params."
postconditions:
- "POST-001: 200 with the upstream {session_id, items, next_cursor} dict verbatim on success."
- "POST-002: on SessionApiFailed(status) -> JSONResponse({error_code:'session_messages_unavailable', status}, status_code=status) — status-preserving."
steps: "Open client_factory() client; await get_session_messages(client, session_id); return 200. Except SessionApiFailed -> status-preserving envelope."
flexibility: "prescriptive"
- name: "_session_bifrost_endpoint" - name: "_session_bifrost_endpoint"
signature: "async _session_bifrost_endpoint(request: Request) -> JSONResponse" signature: "async _session_bifrost_endpoint(request: Request) -> JSONResponse"
description: "GET /api/sessions/{session_id}/bifrost — proxy admin-scoped Bifrost dispatch state." description: "GET /api/sessions/{session_id}/bifrost — proxy admin-scoped Bifrost dispatch state."
@@ -95,6 +106,14 @@ functions:
- "POST-002: a scheduled poll no-ops if a NEW turn has started (state.turnId truthy) or already settled — no refresh of a stale agent, no unbounded polling." - "POST-002: a scheduled poll no-ops if a NEW turn has started (state.turnId truthy) or already settled — no refresh of a stale agent, no unbounded polling."
flexibility: "open" flexibility: "open"
- name: "loadTranscript (index.html)"
signature: "async loadTranscript(sessionId) -> void"
description: "On session open, GET /api/sessions/{id}/messages and render each EXISTING turn into #transcript — notably a #347 authored first-message seeded at create-time (which lives in the ledger, not the live turn stream, so without this the transcript is blank until the user speaks)."
postconditions:
- "POST-001: assistant items render as a .response .md-body bubble via markdownSafe(content) (escape-first whitelist, same path as appendResponse); user items render as a .prompt-echo via textContent — no upstream content reaches innerHTML unescaped (INV-004)."
- "POST-002: any non-200, fetch error, or parse error is swallowed (best-effort) — a blank transcript is acceptable; opening the workspace is never blocked."
flexibility: "prescriptive"
- name: "web pane renderers (index.html: renderToolsInventory / renderBifrostState / openAdminEvents)" - name: "web pane renderers (index.html: renderToolsInventory / renderBifrostState / openAdminEvents)"
signature: "renderToolsInventory(inv) ; renderBifrostState(b) ; openAdminEvents(sessionId)" signature: "renderToolsInventory(inv) ; renderBifrostState(b) ; openAdminEvents(sessionId)"
description: "Render the three new surfaces; all content escaped (INV-004)." description: "Render the three new surfaces; all content escaped (INV-004)."
+158 -9
View File
@@ -2699,10 +2699,13 @@ The `turn.started` event always carries `bifrost_override_applied: bool` (True/F
Tier 3 agents are consumer-owned, Worldtree-hosted agents whose Tier 3 agents are consumer-owned, Worldtree-hosted agents whose
identity lives at `<user_id>:<agent_name>`. They share the persistent identity lives at `<user_id>:<agent_name>`. They share the persistent
session infrastructure with Tier 1 / Tier 2 but layer-specific session infrastructure with Tier 1 / Tier 2. The layer-specific
machinery (persona, motivational, memory, valence) is reserved for machinery is now largely active: **`persona` (Phase 2.1, #186),
later phases — Phase 2.0 ships baseline addressing + ownership + `memory` (Phase 2.1, #197), and `motivational` (Phase 2.2, #187) are
lifecycle only. shipped and consumer-settable at define-time.** Only **`valence` remains
deferred** (non-null → 422 `layer_deferred`). Phase 2.0 shipped the
baseline addressing + ownership + lifecycle substrate; the subsections
below document the active layers and their exact validated shapes.
### Endpoints ### Endpoints
@@ -2720,11 +2723,13 @@ lifecycle only.
{ {
"agent_name": "wizard", "agent_name": "wizard",
"system_prompt": "You are a guided-elicitation wizard...", "system_prompt": "You are a guided-elicitation wizard...",
"model": "glm5-turbo", "role": "gen-reasoning", // REQUIRED — a configured model-role (#344), not a raw model id
"persona": null, // schema-reserved; non-null → 422 layer_deferred "persona": { // active (Phase 2.1) — single-letter OCEAN keys; see "Persona layer"
"motivational": null, "ocean": {"O": 0.4, "C": 0.6, "E": -0.3, "A": 0.2, "N": 0.5}
"valence": null, },
"memory": null "motivational": null, // active (Phase 2.2) — see "Motivational layer"
"memory": null, // active (Phase 2.1) — see "Memory layer"
"valence": null // still deferred — non-null → 422 layer_deferred
} }
``` ```
@@ -2756,6 +2761,150 @@ after definition.
The 201 response includes an advisory `warnings` array (#219) — see The 201 response includes an advisory `warnings` array (#219) — see
"Model-assignment warnings" under `PATCH` below. "Model-assignment warnings" under `PATCH` below.
> **Vendoring note (OpenAPI 2.3.0).** In the frozen OpenAPI 2.3.0 document
> the `persona` / `motivational` / `memory` / `valence` request fields
> serialize as **untyped/freeform** — the `POST /agents/define` request
> model types them as `Any` so the layers can activate without a
> schema-breaking change. The shapes documented in the subsections below
> are the **authoritative, validator-enforced** schemas; generate client
> types from this section, not from the freeform OpenAPI fields.
##### Persona layer (Phase 2.1, #186)
`persona` is **active** as of Phase 2.1. It carries the agent's OCEAN
personality vector — the durable trait profile from which Worldtree
derives the mood setpoint (`baseline_pad`) and the mood dynamics
(gain + relaxation time-constants). Shape:
```json
"persona": {
"ocean": { // REQUIRED — exactly these 5 keys, no more, no fewer
"O": 0.4, // Openness — float in [-1.0, 1.0]
"C": 0.6, // Conscientiousness
"E": -0.3, // Extraversion
"A": 0.2, // Agreeableness
"N": 0.5 // Neuroticism
},
"behavioral_notes": "...", // optional, ≤ 4096 chars
"temperament_notes": "..." // optional, ≤ 4096 chars
}
```
**⚠ OCEAN key format — single-letter, uppercase.** The `/agents/define`
persona validator requires the `ocean` map to contain **exactly** the five
uppercase single-letter keys `O, C, E, A, N`. This is a deliberate,
load-bearing contrast with the transient-character primitive
(`POST /characters`), whose `ocean` block uses the **spelled-out**
lowercase keys (`openness`, `conscientiousness`, …). Sending spelled-out
keys to `/agents/define` returns 422 `persona_ocean_required` ("must
contain exactly the 5 keys O, C, E, A, N").
> **Fixed in v1.0.0b21 (#348).** Before that build a correctly
> single-letter-keyed persona was accepted and stored, but resolved to a
> **neutral** mood, because Worldtree's internal mood-derivation read the
> spelled-out key form. On v1.0.0b21+ an API-declared persona correctly
> drives the derived mood setpoint. If you observe neutral mood on a
> persona-defined agent, confirm the deployment is ≥ v1.0.0b21.
**Range.** Each value is a float in `[-1.0, 1.0]` **signed** — `0.0` is the
population mean, NOT `[0.0, 1.0]`. Booleans are rejected. Out-of-range → 422
`persona_ocean_out_of_range`. See [`docs/ocean-traits.md`](ocean-traits.md)
for the SOTA-grounded 5-band behavioural mapping.
Semantics:
- **Per-agent identity trait** — identical for every end-user and session;
immutable post-define (`PATCH {"persona": …}` → 422 `field_not_mutable`).
To change the OCEAN profile, delete and re-define the agent.
- **`extensions` is reserved** — the field exists but must be empty at v0.1;
a non-empty `extensions` returns 422 `layer_deferred`.
- **Sets the mood SETPOINT, not the current mood.** The OCEAN vector fixes
`baseline_pad` (the PAD point the mood relaxes toward over time); the
*current* per-session mood point is seeded separately via
`POST /sessions/{id}/persona_state` (below).
Validation 422 codes: `persona_ocean_required` (missing `ocean`, or keys
≠ {O,C,E,A,N}), `persona_ocean_out_of_range` (a value outside [-1.0, 1.0], or
a boolean), `persona_notes_too_large` (a note > 4096 chars), `layer_deferred`
(non-empty `extensions`), `validation_failed` (unknown top-level field).
##### `POST /sessions/{session_id}/persona_state` — seed the session mood point (Phase 2.1, #186/#189)
Session-scoped mood seed. Sets the *current* PAD mood point for one
session's bound agent — the starting emotional state, distinct from the
OCEAN-derived setpoint the mood relaxes toward. Works on any
persona-enabled session (Tier 1 or Tier 3); most useful for a Tier 3
durable-agent session that wants to start a conversation from a specific
mood.
Request:
```json
{
"pad": {
"pleasure": 0.42, // float in [-1.0, 1.0]
"arousal": 0.25,
"dominance": 0.33
}
}
```
Response: **`204 No Content`** — no body, no audit event (a session-scoped
runtime overlay, not a security-relevant event).
Semantics:
- **PAD-only** (#317 Option A). The body accepts exactly one key, `pad`,
which must carry all three of `pleasure` / `arousal` / `dominance`, each a
float in `[-1.0, 1.0]`. Any other top-level key → 422 `validation_failed`;
a missing or malformed `pad` → 422 `persona_seed_invalid`.
- **Seeds the current mood POINT, not the setpoint.** The OCEAN persona
(above) fixes the setpoint the mood relaxes toward; this endpoint sets
where the mood *starts*. It does not alter the persona.
- **Cross-owner sessions return 404** (existence-hiding — a session that
isn't yours is indistinguishable from one that doesn't exist).
- **Pull-over-push precedence (#289).** Once a session's baseline has been
rehydrated from an `affect.fetch` (the authoritative cross-session
source), a later SET seed is silently ignored — the fetched baseline wins.
There is **no** `POST /agents/{id}/persona_state` — mood is per-session, not
a durable agent property. `GET /agents/{agent_id}/persona_state`
short-circuits to 404 for Tier-3 colon-ids: Tier-3 mood is observable only
over the Bifrost `affect.emit` egress (ADR-0009), never read back through
the HTTP API.
##### Memory layer (Phase 2.1, #197)
`memory` is **active** as of Phase 2.1 but exposes a deliberately minimal
surface — the short-term-memory (STM) tier was removed (#197), so the
historically-present `stm_*` knobs are accept-and-ignore no-ops. Shape:
```json
"memory": {
"embedder_version": "<pinned>", // optional; MUST equal the library-pinned version
"tier3_dreaming": false // optional bool, default false
}
```
Semantics:
- **`embedder_version`** — optional. If supplied it MUST equal the library's
currently-pinned embedder version; a mismatch → 422
`embedder_version_mismatch` (with `expected` / `received` in the detail).
Omit it to accept the pin. Fixed at define-time and library-pinned
thereafter.
- **`tier3_dreaming`** — optional bool (default `false`); opt-in flag for the
Tier-3 dreaming / consolidation path.
- **`stm_capacity` / `stm_token_budget`** — **deprecated no-ops.** Accepted at
define (201) with a `DeprecationWarning`; they carry no runtime effect since
the STM tier was removed, and are slated for rejection at the next schema
break. Do not send them in new integrations.
- **`allows_world_scope` — removed.** Sending it → 422 `validation_failed`
("world-shared knowledge belongs in the KB/Mimir plane").
- **Wholesale-immutable post-define.** `PATCH {"memory": …}` → 422
`field_not_mutable` (even for the deprecated `stm_*` fields) — see the
PATCH table above.
##### Motivational layer (Phase 2.2, #187) ##### Motivational layer (Phase 2.2, #187)
`motivational` is **active** as of Phase 2.2 (persona + memory activated in `motivational` is **active** as of Phase 2.2 (persona + memory activated in
+1 -1
View File
@@ -69,7 +69,7 @@ sub-gap).
|---|---|---|---| |---|---|---|---|
| `POST /sessions` | ✅ | `sessions.py:307``cli.py:482`,`tui.py:1508`,`web/server.py:155` | + `end_user_id`, `bifrost` binding; 404→AgentNotFound, 502→BifrostHandshakeFailed | | `POST /sessions` | ✅ | `sessions.py:307``cli.py:482`,`tui.py:1508`,`web/server.py:155` | + `end_user_id`, `bifrost` binding; 404→AgentNotFound, 502→BifrostHandshakeFailed |
| `POST /sessions/{id}/messages` (turn stream, SSE) | ✅ | `sse_client.py:484` `stream_turn` → cli/tui/web | the primary surface; 409→AgentNotAvailable, 503→TurnLaunchUnavailable (b2 #331) | | `POST /sessions/{id}/messages` (turn stream, SSE) | ✅ | `sse_client.py:484` `stream_turn` → cli/tui/web | the primary surface; 409→AgentNotAvailable, 503→TurnLaunchUnavailable (b2 #331) |
| `POST /sessions/{id}/history` (authored-history-write, #347) | ✅ | `sessions.py:583` `write_authored_history``cli.py:758` `--seed-first-message` | v1: author=assistant, effects=none, per-session idempotency; 404→AuthoredHistoryUnavailable (hide-existence: feature-absent, never probe); 409/422 mapped. **Live-proof pending** the `session.history.write` grant (requested infra-ops 2026-07-06) — ungranted returns the hide-404, so the probe exercises the feature-absent fallback until granted (Tier-2 precedent: ✅ code-complete + graceful-degrade) | | `POST /sessions/{id}/history` (authored-history-write, #347) | ✅ | `sessions.py:583` `write_authored_history``cli.py:758` `--seed-first-message` | v1: author=assistant, effects=none, per-session idempotency; 404→AuthoredHistoryUnavailable (hide-existence: feature-absent, never probe); 409/422 mapped. **LIVE-PROVEN 2026-07-06** on personal :8081 (grant applied via a rule-based Heimdall allow, worldtree-dev): create mimir session → seed → **201** (seq=0, phase=seeded, turn_id=1798) → GET /messages reads it back as a plain role=assistant turn (model-invisible provenance confirmed). Hide-404 for ungranted is unit+probe covered |
| `GET /sessions/{id}/messages` (history) | ✅ | `sessions.py:635` `get_session_messages``cli.py:758` `--seed-first-message` read-back | un-deferred as the #347 seed read-back — confirms model-invisible provenance (a seed reads back as a normal `role=assistant` turn) | | `GET /sessions/{id}/messages` (history) | ✅ | `sessions.py:635` `get_session_messages``cli.py:758` `--seed-first-message` read-back | un-deferred as the #347 seed read-back — confirms model-invisible provenance (a seed reads back as a normal `role=assistant` turn) |
| `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `sse_client.py:581` → cli/tui/web | two-stage Ctrl-C; 404/409 mapped | | `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `sse_client.py:581` → cli/tui/web | two-stage Ctrl-C; 404/409 mapped |
| `GET /agents` | ✅ | `sessions.py:341``tui.py:1472`,`web/server.py:100` | Tier-1 roster; merged with local index | | `GET /agents` | ✅ | `sessions.py:341``tui.py:1472`,`web/server.py:100` | Tier-1 roster; merged with local index |
+7 -1
View File
@@ -41,7 +41,7 @@ upstream API key stays server-side (INV-003).
_As of 2026-07-06:_ _As of 2026-07-06:_
**LATEST (2026-07-06 cont.): #347 authored-history-write CONSUMER SIDE SHIPPED (`v0.19.6`) + OpenAPI re-vendored 2.2.0->2.3.0 (`75da676`).** worldtree-dev shipped #347 as spec 2.3.0 (deployed on personal b22 `879cefe`); ratatoskr built the consumer side via direct in-session TDD: `write_authored_history` (POST /sessions/{id}/history) + `get_session_messages` (un-deferred read-back) + a `--seed-first-message` one-shot probe (create session -> seed -> read-back), with **404-as-feature-absent per hide-existence** (`AuthoredHistoryUnavailable`, distinct from SessionApiFailed; caller never capability-probes). Contract #2 amended + TDD (19 new tests; suite 601 green; ruff clean; mypy only the sibling-consistent `resp.json()` no-any-return). Coverage-map re-converged: **REST 19/41** (#347 route + messages read-back close the one gap the re-vendor opened). **LIVE-PROOF PENDING** the `session.history.write` grant (requested infra-ops `01KWW3KQEY`, monitor armed) -- ungranted the route returns the hide-404, so the probe exercises the feature-absent fallback until granted. **OPEN TAIL-2 (worldtree-dev `c9e59ec`, LOCAL not-yet-origin):** Tier-3 persona/memory/persona_state PROSE docs landed in `docs/conversation-api-spec.md` § "Tier 3" (they serialize as freeform `Any` in the OpenAPI JSON, hence prose-not-schema) -> (a) prose markdown re-vendor pending (tolerate_drift pin), (b) a **likely `set_persona_state` body-shape drift to align**: my `--set-persona-pad` sends `{pad:[list]}`, the doc's canonical is `{pad:{pleasure,arousal,dominance}}` (PAD-only #317, pull-over-push #289, cross-owner 404; never live-proven so untested). worldtree-dev foot-guns: persona.ocean = SINGLE-LETTER UPPERCASE `{O,C,E,A,N}` on /agents/define (spelled-out -> 422; the #348 mismatch) vs spelled-out lowercase on POST /characters; memory = `{embedder_version(==pinned else 422), tier3_dreaming}`, stm_* deprecated no-ops, allows_world_scope removed->422; only `valence` still 422s (layer_deferred). **LATEST (2026-07-06 cont.): #347 authored-history-write CONSUMER SIDE SHIPPED (`v0.19.6`) + OpenAPI re-vendored 2.2.0->2.3.0 (`75da676`).** worldtree-dev shipped #347 as spec 2.3.0 (deployed on personal b22 `879cefe`); ratatoskr built the consumer side via direct in-session TDD: `write_authored_history` (POST /sessions/{id}/history) + `get_session_messages` (un-deferred read-back) + a `--seed-first-message` one-shot probe (create session -> seed -> read-back), with **404-as-feature-absent per hide-existence** (`AuthoredHistoryUnavailable`, distinct from SessionApiFailed; caller never capability-probes). Contract #2 amended + TDD (19 new tests; suite 601 green; ruff clean; mypy only the sibling-consistent `resp.json()` no-any-return). Coverage-map re-converged: **REST 19/41** (#347 route + messages read-back close the one gap the re-vendor opened). **LIVE-PROVEN 2026-07-06 on personal :8081.** Vuong approved the `session.history.write` grant; worldtree-dev authored a **rule-based Heimdall allow** (the PDP is rule-based, NOT scope-on-key -- our key user_id=ratatoskr is unchanged; policy: user_id=ratatoskr->ALLOW, all others->DENY with hide-404 preserved), applied to personal's bind-mounted `policies.yaml` by infra-ops. Smoke: create mimir session -> seed -> **201** (seq=0, phase=seeded, turn_id=1798) -> GET /messages reads it back as a plain role=assistant turn (**model-invisible provenance confirmed**). The full #347 consumer side is now live-proven; hide-404 for ungranted stays unit+probe covered. **OPEN TAIL-2 (worldtree-dev `c9e59ec`, LOCAL not-yet-origin):** Tier-3 persona/memory/persona_state PROSE docs landed in `docs/conversation-api-spec.md` § "Tier 3" (they serialize as freeform `Any` in the OpenAPI JSON, hence prose-not-schema) -> (a) prose markdown re-vendor pending (tolerate_drift pin), (b) a **likely `set_persona_state` body-shape drift to align**: my `--set-persona-pad` sends `{pad:[list]}`, the doc's canonical is `{pad:{pleasure,arousal,dominance}}` (PAD-only #317, pull-over-push #289, cross-owner 404; never live-proven so untested). worldtree-dev foot-guns: persona.ocean = SINGLE-LETTER UPPERCASE `{O,C,E,A,N}` on /agents/define (spelled-out -> 422; the #348 mismatch) vs spelled-out lowercase on POST /characters; memory = `{embedder_version(==pinned else 422), tier3_dreaming}`, stm_* deprecated no-ops, allows_world_scope removed->422; only `valence` still 422s (layer_deferred).
**Prior arcs this session (2026-07-04 -> 07-06), both with worldtree-dev (a tooling script + proposal docs; the #347 CONSUMER work above is the new production code):** **Prior arcs this session (2026-07-04 -> 07-06), both with worldtree-dev (a tooling script + proposal docs; the #347 CONSUMER work above is the new production code):**
@@ -159,6 +159,12 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-07-06]` **OpenAPI re-vendored 2.2.0->2.3.0 (`75da676`, pin-only no bump).** worldtree-dev shipped #347 as spec 2.3.0 (`879cefe`, = the deployed personal b22 image); the SessionStart drift-check flagged our openapi pin STALE. `canonical_sync` pulled 2.3.0; updated the 4 pin-tracking files (`.corviduo-canonicals.toml`, vendored openapi.json, SPEC-PIN.md, pyproject `worldtree-spec-rev`->879cefe). #347 is OpenAPI-only (prose + server contract byte-unchanged, SSE unchanged=event-silent). The re-vendor re-opened the coverage-audit with one new in-scope path-group (the #347 route). - `[2026-07-06]` **OpenAPI re-vendored 2.2.0->2.3.0 (`75da676`, pin-only no bump).** worldtree-dev shipped #347 as spec 2.3.0 (`879cefe`, = the deployed personal b22 image); the SessionStart drift-check flagged our openapi pin STALE. `canonical_sync` pulled 2.3.0; updated the 4 pin-tracking files (`.corviduo-canonicals.toml`, vendored openapi.json, SPEC-PIN.md, pyproject `worldtree-spec-rev`->879cefe). #347 is OpenAPI-only (prose + server contract byte-unchanged, SSE unchanged=event-silent). The re-vendor re-opened the coverage-audit with one new in-scope path-group (the #347 route).
- `[2026-07-06]` **#347 authored-history-write CONSUMER SIDE SHIPPED (`v0.19.6`) — direct in-session TDD.** `write_authored_history(client, session_id, *, content, idempotency_key, author="assistant", effects=None, claimed_original_at=None) -> dict` (POST /sessions/{id}/history; body server-pinned `AuthoredWriteRequest` extra="forbid" so omit null effects/claimed_original_at; 200-replay/201-fresh both -> ack dict; **404 -> `AuthoredHistoryUnavailable`** NOT SessionApiFailed = the hide-existence "feature-absent, never probe" contract; 409/422->SessionApiFailed) + `get_session_messages` (un-deferred GET /sessions/{id}/messages, the seed read-back proving model-invisible provenance) + a `--seed-first-message "<c>" --agent <id>` one-shot probe (create session -> seed -> read-back; 404->benign feature-absent exit 0). Contract #2 amended (2 FNs, validated OK) + 19 tests (12 wrapper + 7 cli). Suite **601 green** (clean env; the 2 "fails" under `source env.sh` are the RATATOSKR_ADMIN_API_KEY env-leak into TestParseArgs, not a regression). Coverage: **REST 19/41** (`docs/coverage-map.md` re-converged). Patch bump (coverage tail; consistent w/ the Tier-2 v0.19.1 cadence). **Live-proof pending** the `session.history.write` grant (infra-ops `01KWW3KQEY`). heid-code-review NOT run (offered). - `[2026-07-06]` **#347 authored-history-write CONSUMER SIDE SHIPPED (`v0.19.6`) — direct in-session TDD.** `write_authored_history(client, session_id, *, content, idempotency_key, author="assistant", effects=None, claimed_original_at=None) -> dict` (POST /sessions/{id}/history; body server-pinned `AuthoredWriteRequest` extra="forbid" so omit null effects/claimed_original_at; 200-replay/201-fresh both -> ack dict; **404 -> `AuthoredHistoryUnavailable`** NOT SessionApiFailed = the hide-existence "feature-absent, never probe" contract; 409/422->SessionApiFailed) + `get_session_messages` (un-deferred GET /sessions/{id}/messages, the seed read-back proving model-invisible provenance) + a `--seed-first-message "<c>" --agent <id>` one-shot probe (create session -> seed -> read-back; 404->benign feature-absent exit 0). Contract #2 amended (2 FNs, validated OK) + 19 tests (12 wrapper + 7 cli). Suite **601 green** (clean env; the 2 "fails" under `source env.sh` are the RATATOSKR_ADMIN_API_KEY env-leak into TestParseArgs, not a regression). Coverage: **REST 19/41** (`docs/coverage-map.md` re-converged). Patch bump (coverage tail; consistent w/ the Tier-2 v0.19.1 cadence). **Live-proof pending** the `session.history.write` grant (infra-ops `01KWW3KQEY`). heid-code-review NOT run (offered).
- `[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.**
- `[2026-07-06]` **Web UI now RENDERS the seeded first-message (`v0.19.9`) — operator-reported "i don't see Sindra's greeting on the web ui".** Diagnosis: the auto-seed WORKED (greeting was in the ledger at seq-0), but the web SPA never fetched a session's EXISTING history — NO `/api/sessions/{id}/messages` route (GET /messages was originally deferred out-of-scope; sessions used to start empty so it never mattered) and `startSession()` went straight from create → persona/tools/admin hydration, so the transcript only filled from the live turn stream + user echoes. Fix: (1) NEW web proxy route `GET /api/sessions/{id}/messages``get_session_messages` (mirrors the tools/bifrost proxies; status-preserving `session_messages_unavailable` envelope); (2) SPA `loadTranscript(sessionId)` — fetches the route on open, renders assistant items as `.response .md-body` (markdownSafe, same escape-first path as appendResponse) + user items as `.prompt-echo` (textContent), called in `startSession` after the workspace opens; best-effort (swallows failures). Contract `web_debug_surface.contract.md` amended (server endpoint + loadTranscript entries). TDD (2 web route tests, suite 617 green) + **Playwright DOM check PROVED the render** (drove the real UI: pick sindra → open → her greeting bubble appears — the JS-render lens unit tests can't reach; [[feedback_debug_surface_uses_canonical_surface_only]] cousin lesson). Web restarted on the fix. **FOOT-GUN (self-inflicted): `pkill -f "ratatoskr-web --host"` SELF-MATCHES the bash command running it → exit 144, killed its own restart mid-flight — kill the web by PID, never `pkill -f` on a pattern your own command contains.** **FOOT-GUN: uvicorn hangs on SIGTERM with an open admin-events SSE → needed SIGKILL.** **Playwright: python module absent from the venv; use node + `executablePath=/opt/ms-playwright/chromium-1223/chrome-linux64/chrome` — the shared browser is build 1223, npm-latest playwright wants 1228 (version-mismatch), so pin executablePath instead of letting playwright resolve.**
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._ _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._ _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._
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ratatoskr" name = "ratatoskr"
version = "0.19.6" version = "0.19.9"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -60,7 +60,7 @@ Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
# Ratatoskr is built against Worldtree at this commit; the vendored # Ratatoskr is built against Worldtree at this commit; the vendored
# spec snapshot in docs/ reflects that SHA. # spec snapshot in docs/ reflects that SHA.
[tool.ratatoskr.spec-pin] [tool.ratatoskr.spec-pin]
worldtree-spec-rev = "879cefef02bbf442c2d01ff4214299fac7c89809" worldtree-spec-rev = "c9e59ec"
worldtree-version = "v1.0.0b22" worldtree-version = "v1.0.0b22"
pinned-on = "2026-07-06" pinned-on = "2026-07-06"
+16 -1
View File
@@ -17,6 +17,7 @@ from typing import Any, TextIO
import httpx import httpx
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.sessions import ( from ratatoskr.sessions import (
AgentNotFound, AgentNotFound,
AuthoredHistoryUnavailable, AuthoredHistoryUnavailable,
@@ -598,6 +599,11 @@ async def _amain(args: ParsedArgs) -> int:
sys.stderr.write( sys.stderr.write(
f". create_session: session_id={info.session_id} agent_id={info.agent_id}\n" 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 # Issue #17 bound-state indicator: plane + endpoint + status, so the
# operator sees WHICH identity/endpoint bound (not a bare boolean). # operator sees WHICH identity/endpoint bound (not a bare boolean).
if args.bifrost is not None: if args.bifrost is not None:
@@ -740,9 +746,18 @@ async def _set_persona_probe(args: ParsedArgs) -> int:
"(e.g. '0.4,0.1,-0.2')\n" "(e.g. '0.4,0.1,-0.2')\n"
) )
return 10 return 10
if len(pad) != 3:
sys.stderr.write(
"[usage_error] --set-persona-pad needs exactly 3 floats "
"(pleasure,arousal,dominance), e.g. '0.4,0.1,-0.2'\n"
)
return 10
# Canonical POST /sessions/{id}/persona_state body (#317): a named-key dict,
# NOT a bare list — {"pad": {"pleasure", "arousal", "dominance"}}.
snapshot = {"pad": {"pleasure": pad[0], "arousal": pad[1], "dominance": pad[2]}}
async with _probe_client(args) as client: async with _probe_client(args) as client:
try: try:
await set_persona_state(client, args.session_id, {"pad": pad}) await set_persona_state(client, args.session_id, snapshot)
except SessionApiFailed as exc: except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n") sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20 return 20
+83
View File
@@ -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
+6 -4
View File
@@ -508,10 +508,12 @@ async def set_persona_state(
) -> None: ) -> None:
"""POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection). """POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection).
The request body is FREEFORM: the frozen OpenAPI 2.2.0 declares no request The request body is FREEFORM on the wire (the OpenAPI declares no request
schema and the prose spec documents only the GET counterpart so the caller schema), but worldtree-dev's prose now pins the canonical shape (#317):
supplies the snapshot shape (e.g. `{pad: [p, a, d]}`, mirroring the GET `{"pad": {"pleasure": p, "arousal": a, "dominance": d}}` a named-key dict
`snapshot`). 204 No Content None; any other status SessionApiFailed. (each in [-1, 1]), NOT a bare list; PAD-only, session-scoped, pull-over-push
(#289). The caller supplies the snapshot. 204 No Content → None; any other
status SessionApiFailed.
""" """
assert client is not None assert client is not None
assert session_id and isinstance(session_id, str) assert session_id and isinstance(session_id, str)
+5 -3
View File
@@ -33,6 +33,7 @@ from textual.widgets import (
) )
from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage 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 ( from ratatoskr.sessions import (
AgentInfo, AgentInfo,
AgentNotAvailable, AgentNotAvailable,
@@ -414,7 +415,7 @@ class TuiPresenterState:
self, self,
event: Event, event: Event,
*, *,
transcript: "VerticalScroll", transcript: VerticalScroll,
tools_log: RichLog, tools_log: RichLog,
debug_log: RichLog, debug_log: RichLog,
thinking_log: RichLog, thinking_log: RichLog,
@@ -1324,7 +1325,6 @@ class RatatoskrApp(App[int]):
On 200: header populated, pane shows full detail, audit logged. On 200: header populated, pane shows full detail, audit logged.
""" """
assert self.client is not None and self.agent_id is not None assert self.client is not None and self.agent_id is not None
from rich.text import Text as RichText
try: try:
snapshot = await get_persona_state(self.client, self.agent_id) snapshot = await get_persona_state(self.client, self.agent_id)
self._update_persona_surfaces(snapshot) self._update_persona_surfaces(snapshot)
@@ -1899,6 +1899,8 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
) )
session_id = info.session_id session_id = info.session_id
agent_id: str | None = info.agent_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: else:
assert resolved_session_id is not None assert resolved_session_id is not None
session_id = resolved_session_id session_id = resolved_session_id
@@ -1914,7 +1916,7 @@ async def _cancel_via_sse(
turn_id: int, turn_id: int,
*, *,
transcript: VerticalScroll, transcript: VerticalScroll,
audit: "Callable[[str], None] | None" = None, audit: Callable[[str], None] | None = None,
) -> None: ) -> None:
"""Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009). """Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009).
+27 -1
View File
@@ -28,6 +28,7 @@ from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles from starlette.staticfiles import StaticFiles
from ratatoskr import local_agents as _local_agents from ratatoskr import local_agents as _local_agents
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.sessions import ( from ratatoskr.sessions import (
AgentNotAvailable, AgentNotAvailable,
AgentNotFound, AgentNotFound,
@@ -41,14 +42,15 @@ from ratatoskr.sessions import (
endpoint_for_plane, endpoint_for_plane,
get_persona_state, get_persona_state,
get_session_bifrost, get_session_bifrost,
get_session_messages,
get_session_tools, get_session_tools,
list_agents, list_agents,
) )
from ratatoskr.sse_client import ( from ratatoskr.sse_client import (
AdminEvent, AdminEvent,
CancelAlreadyCompleted, CancelAlreadyCompleted,
Cancelled,
CancelFailed, CancelFailed,
Cancelled,
CancelTurnNotFound, CancelTurnNotFound,
Done, Done,
Error, Error,
@@ -168,6 +170,9 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
bifrost=bifrost, bifrost=bifrost,
consumer_key=consumer_key if bifrost else None, 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: except AgentNotFound:
return JSONResponse({"error_code": "agent_not_found"}, status_code=404) return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
except BifrostConsumerKeyMissing: except BifrostConsumerKeyMissing:
@@ -444,6 +449,26 @@ async def _session_tools_endpoint(request: Request) -> JSONResponse:
return JSONResponse(info, status_code=200) return JSONResponse(info, status_code=200)
async def _session_messages_endpoint(request: Request) -> JSONResponse:
"""GET /api/sessions/{session_id}/messages → the session's message history.
Proxies get_session_messages so the SPA can render a session's EXISTING turns
on open notably a #347 authored first-message seeded at create-time (which
lives in the ledger, not the live turn stream). Any non-200 upstream a
status-preserving error envelope."""
session_id = request.path_params["session_id"]
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
data = await get_session_messages(client, session_id)
except SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_messages_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(data, status_code=200)
async def _session_bifrost_endpoint(request: Request) -> JSONResponse: async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
"""GET /api/sessions/{session_id}/bifrost → admin-scoped Bifrost dispatch state (#176). """GET /api/sessions/{session_id}/bifrost → admin-scoped Bifrost dispatch state (#176).
@@ -588,6 +613,7 @@ def create_app(
Route("/api/agents/{agent_id}/persona_state", _persona_state_endpoint), Route("/api/agents/{agent_id}/persona_state", _persona_state_endpoint),
Route("/api/affect/{agent_id}", _affect_state_endpoint), Route("/api/affect/{agent_id}", _affect_state_endpoint),
Route("/api/sessions/{session_id}/tools", _session_tools_endpoint), Route("/api/sessions/{session_id}/tools", _session_tools_endpoint),
Route("/api/sessions/{session_id}/messages", _session_messages_endpoint),
Route("/api/sessions/{session_id}/bifrost", _session_bifrost_endpoint), Route("/api/sessions/{session_id}/bifrost", _session_bifrost_endpoint),
Route("/api/admin/events", _admin_events_endpoint), Route("/api/admin/events", _admin_events_endpoint),
Route("/api/turns/{session_id}", _submit_turn_endpoint, methods=["POST"]), Route("/api/turns/{session_id}", _submit_turn_endpoint, methods=["POST"]),
+27
View File
@@ -1166,6 +1166,7 @@ async function startSession() {
setConn("idle", "connected"); setConn("idle", "connected");
$("setup").style.display = "none"; $("setup").style.display = "none";
$("workspace").classList.add("live"); $("workspace").classList.add("live");
await loadTranscript(state.sessionId);
await loadPersona(agentId); await loadPersona(agentId);
// Admin/debug surfaces — best-effort hydrate + live stream (all self-render on // Admin/debug surfaces — best-effort hydrate + live stream (all self-render on
// failure; the admin key is server-held, never sent from here). // failure; the admin key is server-held, never sent from here).
@@ -1197,6 +1198,32 @@ function finalizeResponse() {
const live = document.querySelector("#transcript .response.live"); const live = document.querySelector("#transcript .response.live");
if (live) live.classList.remove("live"); if (live) live.classList.remove("live");
} }
// Render a session's EXISTING ledger on open — notably a #347 authored
// first-message seeded at create-time (it lives in history, not the live turn
// stream, so without this the transcript is blank until the user speaks).
// Best-effort: a failed/empty fetch just leaves the transcript empty. A seed
// renders byte-identical to a lived assistant turn (model-invisible provenance).
async function loadTranscript(sessionId) {
try {
const r = await fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/messages");
if (r.status !== 200) return;
const data = await r.json();
for (const m of (data && data.items) || []) {
if (m.role === "assistant") {
const b = document.createElement("div");
b.className = "response md-body";
b.innerHTML = markdownSafe(m.content || "");
$("transcript").appendChild(b);
} else if (m.role === "user") {
const e = document.createElement("div");
e.className = "prompt-echo";
e.textContent = m.content || "";
$("transcript").appendChild(e);
}
}
$("transcript").scrollTop = $("transcript").scrollHeight;
} catch (_) { /* best-effort — a blank transcript is acceptable */ }
}
// Thinking: same live-Markdown treatment into the current turn's block. // Thinking: same live-Markdown treatment into the current turn's block.
function appendThinking(text) { function appendThinking(text) {
LIVE.think += text; LIVE.think += text;
+12 -1
View File
@@ -1692,7 +1692,18 @@ class TestTier2Probes:
) )
assert rc == 0 assert rc == 0
assert "persona_state set" in capsys.readouterr().out assert "persona_state set" in capsys.readouterr().out
assert _json.loads(route.calls[0].request.content) == {"pad": [0.4, 0.1, -0.2]} # canonical POST /sessions/{id}/persona_state body: named-key dict, NOT a list
assert _json.loads(route.calls[0].request.content) == {
"pad": {"pleasure": 0.4, "arousal": 0.1, "dominance": -0.2}
}
def test_set_persona_wrong_count(self) -> None:
"""set_persona_wrong_count [adversarial]: not exactly 3 floats → exit 10, no HTTP."""
rc = main(
["--set-persona-pad", "0.4,0.1", "--session", "s1",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 10
class TestSeedFirstMessageProbe: class TestSeedFirstMessageProbe:
+155
View File
@@ -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
+6 -2
View File
@@ -1182,9 +1182,13 @@ class TestSetPersonaState:
return_value=httpx.Response(204) return_value=httpx.Response(204)
) )
async with httpx.AsyncClient(base_url="https://w.example") as client: async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await set_persona_state(client, "s1", {"pad": [0.4, 0.1, -0.2]}) result = await set_persona_state(
client, "s1", {"pad": {"pleasure": 0.4, "arousal": 0.1, "dominance": -0.2}}
)
assert result is None assert result is None
assert _json.loads(route.calls[0].request.content) == {"pad": [0.4, 0.1, -0.2]} assert _json.loads(route.calls[0].request.content) == {
"pad": {"pleasure": 0.4, "arousal": 0.1, "dominance": -0.2}
}
@respx.mock @respx.mock
async def test_non_204_raises(self) -> None: async def test_non_204_raises(self) -> None:
+4
View File
@@ -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: async def fake_run_async(self) -> int:
return 0 return 0
+64
View File
@@ -175,6 +175,62 @@ class TestCreateSessionEndpoint:
resp = TestClient(app).post("/api/sessions", json={}) resp = TestClient(app).post("/api/sessions", json={})
assert resp.status_code == 400 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
class TestSessionMessagesEndpoint:
"""GET /api/sessions/{id}/messages — proxy session history (renders the #347 seed)."""
@respx.mock
def test_happy_returns_history(self) -> None:
"""happy [tracer]: proxies GET /sessions/{id}/messages → 200 with the items verbatim."""
payload = {
"session_id": "s-1",
"items": [{"seq": 0, "role": "assistant", "content": "Hey there."}],
"next_cursor": None,
}
respx.get("https://w.example/sessions/s-1/messages").mock(
return_value=httpx.Response(200, json=payload)
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/sessions/s-1/messages")
assert resp.status_code == 200
assert resp.json()["items"][0]["content"] == "Hey there."
@respx.mock
def test_non_200_status_preserved(self) -> None:
"""error: upstream 404 → status-preserving session_messages_unavailable envelope."""
respx.get("https://w.example/sessions/ghost/messages").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/sessions/ghost/messages")
assert resp.status_code == 404
assert resp.json()["error_code"] == "session_messages_unavailable"
_SNAPSHOT = { _SNAPSHOT = {
"agent_id": "mimir", "agent_id": "mimir",
@@ -821,6 +877,10 @@ class TestWebBifrostBind:
route = respx.post("https://w.example/sessions").mock( route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK) 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( app = create_app(
_mock_client_factory(), _mock_client_factory(),
bifrost_consumer_key="server-ck", bifrost_consumer_key="server-ck",
@@ -856,6 +916,10 @@ class TestWebBifrostBind:
route = respx.post("https://w.example/sessions").mock( route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK) 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( app = create_app(
_mock_client_factory(), _mock_client_factory(),
bifrost_consumer_key="server-ck", bifrost_consumer_key="server-ck",
Generated
+1 -1
View File
@@ -1052,7 +1052,7 @@ wheels = [
[[package]] [[package]]
name = "ratatoskr" name = "ratatoskr"
version = "0.19.6" version = "0.19.9"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },