Compare commits

...

11 Commits

Author SHA1 Message Date
vh 85143b866c fix(tui): disable RichLog min_width floor so wrap actually applies (v0.14.2)
The four right-column panes (tools/debug/thinking/persona) have all
carried `wrap=True` since their introduction, but long lines were
still horizontally scrolling instead of wrapping. Root cause: Textual's
RichLog defaults `min_width=78`, and the App's render path takes
`max(renderable_width, min_width)` after the shrink step. The right
column is 1fr against the left column's 2fr, so at common terminal
widths (≤120 cols) the panes are narrower than 78 cells — the 78-cell
floor was forcing content to render at 78 wide and horizontally scroll
instead of wrapping at the actual pane width.

Set `min_width=0` on all four right-column RichLog instances so
shrink-to-widget-width can actually shrink. `wrap=True` now takes
effect on long lines as expected.

Patch per SemVer discipline: bug fix to a long-standing visible-UX
defect; no public API change, no behavior change for callers, every
existing caller continues to work — the substrate is more correct.
2026-05-27 12:25:16 -07:00
vh 00854ce618 fix(cli): wire AffectUpdate + AwaitingLlmFirstToken into --send presenter (v0.14.1)
The CLI presenter at cli.py:201 carries its own isinstance check on
the Event union (mirroring the TUI presenter's same pattern). v0.11.0
+ v0.14.0 added AffectUpdate + AwaitingLlmFirstToken to the wire layer
but only updated the TUI presenter, leaving the CLI presenter stuck
on the pre-v0.11.0 event vocabulary.

Effect: `ratatoskr --send` crashes with AssertionError on any v0.28.0+
server emitting either of those events. Persona-enabled agents
(affect_update fires on every qualifying turn) and slow-first-token
turns (awaiting_llm_first_token heartbeats fire at 5s intervals) are
both reliably broken. Surfaced while running a wire-trace smoke test
against a Gemma4-based Tier 3 agent.

Patch-bump per SemVer discipline: corrects drift on the just-shipped
surface (v0.11.0 / v0.14.0 wire layer); no public signature change,
no new behavior, existing callers don't care — the bug fix lets them
keep working against current servers.

Routing additions in cli.py:
- AffectUpdate: stderr line with status + (for current) dominant_emotion
- AwaitingLlmFirstToken: stderr line with turn_id + elapsed (seconds)
2026-05-27 00:25:47 -07:00
vh 78bfcadb9e feat(sse,tui): bump spec pin to v0.29.0 + AwaitingLlmFirstToken (v0.14.0)
Spec pin moved da93ca7 (v0.28.0) → 562001a (v0.29.0); vendored
conversation-api-spec.md + conversation_api.contract.md re-snapshotted.
The only material delta is Worldtree #201's awaiting_llm_first_token
SSE heartbeat — a top-level event (NOT a worker_phase extension, per
INV-053's three-field stability) that fires at a configurable interval
(default 5s) during the BuildingPrompt → CallingLLM gap.

Wire layer (sse_client.py):
- New `AwaitingLlmFirstToken` dataclass: sse_id / turn_id /
  elapsed_ms_since_building_prompt (server-authoritative monotonic)
- Added to Event union + _envelope_for_type dispatch branch
- Without this, ratatoskr would crash on any slow-first-token turn
  from a v0.29.0 server (unknown SSE event type → ValueError)

TUI layer (tui.py):
- Audit pipeline: per-event debug-pane line with elapsed in seconds
- Live transcript indicator: first heartbeat mounts a Static
  ("awaiting first token · 5.0s"); subsequent heartbeats update it
  in place; any non-heartbeat event removes it (the gap closed)
- Turn-summary line now carries heartbeat count
- Indicator demoted via .awaiting-label CSS so it reads as ambient
  progress, not content

Tests: 2 wire-layer (single + monotonic sequence) + 3 presenter
(audit line shape, single-mount semantic, indicator removal on gap
close). Suite: 318 passing.
2026-05-25 22:57:33 -07:00
vh 44138590ad feat(tui): persona surface — sticky header + TabPane (v0.13.0)
Step 3 of the Worldtree #204 integration: visible persona-state UX.
Pairs with v0.11.0's AffectUpdate SSE event + v0.12.0's
get_persona_state HTTP client — together those gave the data; this
bump surfaces it.

Two surfaces (Option C: both):

Sticky persona-header (top of screen, dock=top, height=1):
- Shape: `agent_id · dominant_emotion · pad(P, A, D) · N emotions
  active` — concise enough for at-a-glance scan above the chat
- Starts hidden via `.empty` CSS class; height collapses to 0 when
  the agent has no persona surface
- Refreshes on AffectUpdate(status="current") snapshots

Persona TabPane (Ctrl+4):
- Full snapshot detail: dominant emotion, PAD axes with baseline +
  delta, mood drift, active emotions with intensity + decay
  (minutes-rounded), last_updated_at footer
- Replaced (not appended) on each new snapshot — snapshots are
  absolute state, not incremental

Lifecycle:
- on_mount spawns a Textual worker that calls get_persona_state to
  hydrate header + pane before turn 1
- PersonaNotConfigured (domari, muninn, Tier 3) → pane carries an
  italic placeholder, header stays empty
- AgentNotAvailable / AuthScopeDenied / network error → italic
  failure placeholder; audit-logged; never crashes
- Presenter's render() takes an optional `on_persona_snapshot`
  callback so AffectUpdate(current) refreshes both surfaces during
  a live turn (no widget coupling — App owns the callback)

Tests: 10 new (4 formatters, 3 presenter callback, 4 layout/binding/
hydration). Full suite: 313 passing.
2026-05-25 19:13:47 -07:00
vh d516537b08 feat(sessions): get_persona_state client + persona error taxonomy (v0.12.0)
Adds the read-side half of Worldtree #204's persona-state observability
surface. Pairs with v0.11.0's AffectUpdate SSE event — together they
let a consumer hydrate a persona pane on session-open (this GET) and
keep it live as turns fire (the SSE event).

Public surface:
- `get_persona_state(client, agent_id) -> dict[str, Any]` — GET
  /agents/{agent_id}/persona_state, returns the same `snapshot` dict
  shape as AffectUpdate.snapshot
- New exception types mapped from the spec's documented 4xx error_codes:
  - `PersonaNotConfigured` (404 persona_not_configured) — agent has
    no persona surface (domari, muninn, all Tier 3 in Phase 2.0)
  - `AgentNotAvailable` (404 agent_not_available) — unknown agent_id
  - `AuthScopeDenied` (403 auth_scope_denied) — key lacks the
    requested scope (persona.read here; reusable for future scoped
    endpoints)
- Other non-2xx falls through to the existing SessionApiFailed
  precedent so novel failure modes aren't silently absorbed

Tests: 6 new cases covering happy snapshot return, each typed 4xx
sub-code, unknown 404 fall-through, and 5xx SessionApiFailed parity.

Not yet consumed: TUI persona surface (Persona TabPane / sticky
header line). UX shape pending operator direction — step 3.
2026-05-25 18:55:12 -07:00
vh 92aa05c688 feat(sse,tui): bump spec pin to v0.28.0 + AffectUpdate event (v0.11.0)
Spec pin moved 55101e9 (v0.19.0) → da93ca7 (v0.28.0); vendored
conversation-api-spec.md + conversation_api.contract.md re-snapshotted
from Worldtree at the new SHA. The only material delta consumed in
this bump is Worldtree #204's affect_update SSE event surface.

Wire layer (sse_client.py):
- New AffectUpdate dataclass: sse_id / status / turn_id / snapshot
  (snapshot is None for status="scheduled")
- Added to Event union + _envelope_for_type dispatch branch
- Without this, ratatoskr would crash on any persona-enabled turn
  from a v0.28.0 server (unknown SSE event type → ValueError)

TUI layer (tui.py):
- AffectUpdate routes through the v0.10.0 audit pipeline only — one
  debug-pane line per arrival with dominant_emotion + PAD for
  status="current", lightweight status+turn_id for status="scheduled"
- No transcript / tools / thinking pane writes — the persona UX shape
  (Persona TabPane vs sticky header line) is deferred to a separate
  bump pending operator direction

Tests: 2 new wire-layer tests for current+scheduled parsing + 2 new
presenter audit tests for routing and audit-line shape.

Not yet consumed: GET /agents/{id}/persona_state endpoint (step 2 of
the integration plan).
2026-05-25 18:48:44 -07:00
vh 209427ab23 feat(tui): debug-pane audit logging surface (v0.10.0)
Adds wire-level visibility appropriate for a debugging TUI. Every
SSE event arrival now lands as one debug-pane line; token-rate Text
and Thinking deltas get aggregated counters surfaced in a per-turn
summary instead of per-delta spam.

Audit surfaces added (all routed to the debug pane):
- per-event arrival: timestamp + event type + sse_id + event-specific
  summary for WorkerPhase / ToolStart / ToolResult / TextBoundary /
  Done / Error / Cancelled
- turn-summary at terminal events: text_deltas / text_bytes /
  thinking_deltas / thinking_bytes / elapsed_ms
- app-level state-machine transitions via new RatatoskrApp._transition
  helper (idle → streaming → cancelling → idle, with reason)
- worker_spawn line at on_input_submitted with content_len
- ctrl_c / ctrl_d audit lines documenting action + exit code
- cancel POST lifecycle: _cancel_via_sse takes an optional audit
  callback and emits issued / ok / failed lines
- app_mounted bootstrap line at on_mount (server + agent + session
  tail + raw + end_user_id)
- wire-error exception class + body audit at _stream_turn_worker

Helpers:
- TuiPresenterState: text_delta_count / text_byte_count /
  thinking_delta_count / thinking_byte_count / turn_start_ts
- module-level _ts() + _audit_line() + RatatoskrApp._audit() /
  _transition()

Tests: 6 new test cases lock in audit-line shape, turn-summary
aggregation, cancel-POST lifecycle callback, and the silence of
per-Text-delta debug writes.
2026-05-25 01:36:35 -07:00
vh 139771c8d8 feat(tui): live Markdown rendering during text streaming (v0.9.0)
Replaces v0.8.2's drop-Markdown patch with proper in-place Markdown
rendering. The transcript becomes a VerticalScroll container; each
turn's response body lives as a single Static widget whose content
is updated as Text deltas arrive — Markdown is re-rendered in place
rather than re-printed on Done. Eliminates the v0.8.x double-print
without sacrificing rich formatting.

- transcript: RichLog → VerticalScroll (#transcript-scroll)
- Text deltas: mount Static(Markdown(buffer)) on first delta;
  Static.update(Markdown(buffer)) on subsequent deltas
- --raw mode: bypass Markdown, mount Static(plain_str) for the same
  in-place update semantics
- Terminal events (Done/Error/Cancelled) mount styled label Statics
- _cancel_via_sse: write → mount Static on the new container
- _write_turn_headers: transcript gets a styled RichText Static
  ("── turn N ──"); other panes still receive Rule renderables
- Test suite reshape: bulk rename `log` → `transcript` for the
  presenter contract, `_mounted_renderables` helper extracts
  Static.content for assertion, `_spy_writes` captures both
  RichLog.write and VerticalScroll.mount
2026-05-24 22:18:45 -07:00
vh 489cfee1f0 fix(tui): drop post-Done Markdown body re-render (v0.8.2)
Operator: "first turn double prints agent's turn."

Root cause: v0.8.1 wrote both the streamed Text lines AND the post-
Done `Markdown(event.response)` body into the transcript. Same
content rendered twice — once as plain streaming, once as a full
markdown re-render. The v0.8.1 commit message documented this as
"some duplication is acceptable" but the live UX read as a bug.

## Fix

Drop the post-Done `Rule + Markdown(response)` writes in non-raw
mode. The streamed text IS the response; whatever the model emitted
flows into the transcript line-by-line via coalesce-on-newline.
Markdown formatting (bold, lists, code blocks) renders as plain
text — a known regression from v0.8.1's polished output but the
right tradeoff vs the duplication bug.

## What this loses temporarily

Pre-v0.8.2 (after Done):
  [done] turn_id=... ───
  ─── (Rule separator) ───
  **Bold text** rendered bold, `code` highlighted, lists as bullets, etc.

v0.8.2 (after Done):
  [done] turn_id=... ───
  **Bold text** as plain asterisks, `code` as backticks, lists as plain dashes

## v0.9.0 plan

Restore markdown rendering via LIVE rendering during the stream
(not post-Done re-render). Replace `RichLog#transcript` with a
`VerticalScroll` container that mounts a fresh `Markdown` widget
per turn; Text deltas update the widget; markdown renders as
content arrives. No duplication, no snap, full formatting.
Operator-confirmed direction (2026-05-25 AskUserQuestion).

## Tests

287/287 GREEN; ruff clean. Two tests updated for the new shape:
- test_done_renders_markdown_after_label → renamed
  test_done_flushes_tail_and_writes_label; asserts NO Markdown, NO
  Rule (post-Done) in the writes.
- test_happy_text_done_renders_markdown → renamed
  test_happy_text_done_no_double_print; asserts NO Markdown in the
  spy.

Patch bump (v0.8.1 → v0.8.2): bug fix; no public API change.
2026-05-24 21:53:20 -07:00
vh 11ef6830ab fix(tui,sse): inline Text streaming + empty-id keepalive skip (v0.8.1)
Two related fixes for the same user-reported bug pattern from a
running session against ratatoskr:sindra (qwen3.6-35-a3b-heretic):

## 1. Streaming text overlapping the transcript

Operator: "new text comes at the bottom and overwrites the existing
pane information instead of pushing it up naturally."

Root cause: the v0.6.0 `#current-text` Static was `dock: bottom`
with `height: auto`, sitting between the transcript RichLog (1fr)
and the prompt Input (dock: bottom). As text streamed, the Static
grew UPWARD but Textual didn't dynamically resize the 1fr transcript
to accommodate — the growing Static visually OVERLAPPED the
transcript's bottom rows. On Done, `current_text.update("")` snapped
it to height 0 and the transcript re-laid-out — "boom, everything
updates."

Fix: remove `#current-text` Static entirely. Apply the same
coalesce-on-newline pattern v0.7.1 used for thinking — Text deltas
accumulate in `TuiPresenterState.text_chunk_buffer`, flushing whole
lines (each `\n` boundary) directly to `log` (transcript). On Done:
flush remaining tail, then [done] label + Rule + Markdown body.

Trade-off accepted: streamed lines + post-Done Markdown body are
both in the transcript (some content duplication). The Markdown
body re-renders the same content with proper formatting (lists,
bold, code blocks). Acceptable — operator gets both the live-progress
streaming AND the canonical rendered version.

## 2. MalformedSseId raw='' crashing every turn

Operator: "current session is erroring on every turn with
[malformed_sse_id] raw=''"

Worldtree's qwen3.6-35-a3b-heretic provider emits some events
without `id:` lines (observed 2026-05-25 mid-stream). When the FIRST
such event arrives before any prior id has been seen, httpx_sse's
`ServerSentEvent.id` is `""`. `_parse_sse_id('')` raised ValueError
→ MalformedSseId → turn worker bailed → operator saw the label
every turn.

Per SSE RFC, events without `id:` are legitimate (they just don't
update Last-Event-ID). Issue #7 already covered the empty-DATA
keepalive case with skip-silently semantics. Empty-id is the same
shape of wire weirdness; same fix shape:

  if sse.id == "":
      continue  # treat as keepalive

Ordered AFTER the empty-data branch so an empty-data + empty-id
event still gets skipped on the data check.

## Tests + smoke

287/287 GREEN (was 286, +1 for empty-id skip; +1 net Text-flow test
adjustments). Ruff clean.

Verified Worldtree alive when the user hit the empty-id bug
(/healthz returned ok in 18ms) — not a server-down issue, just
wire-format mid-stream.

## Caveats

The fix doesn't recover content from the dropped empty-id event.
If the event happened to carry meaningful data (not a true
keepalive), we silently lose it. Acceptable trade-off: pre-v0.8.1
EVERY turn died on the offending agent; post-v0.8.1 the turn
continues and any single dropped frame is recoverable from logs if
debugging. Worldtree-side fix (always emit ids) is the right
upstream answer; ratatoskr just stops panicking on wire weirdness.

Patch bump (v0.8.0 → v0.8.1) — both fixes are bug fixes; no public
API change. The `TuiPresenterState.render` signature loses the
`current_text` parameter (was added v0.6.0), but presenter is an
internal contract; no external callers.
2026-05-24 21:39:02 -07:00
vh 9fade55901 feat(local_agents): tier-3 index + picker merge (v0.8.0)
Worldtree's GET /agents doesn't return consumer-defined (tier-3)
agents — the public list excludes them by design. Confirmed live in
v0.7.0's smoke. Without server-side knowledge, ratatoskr's picker
couldn't show tier-3 agents the operator had defined; the workflow
was "remember the agent_id, pass --agent ratatoskr:<name>
explicitly." Friction grows with every tier-3 agent.

## Fix: client-side index, merged at picker time

New module `ratatoskr.local_agents` maintains a JSON-backed index at
$XDG_CONFIG_HOME/ratatoskr/local_agents.json (override via
$RATATOSKR_LOCAL_AGENTS). `tier3` CLI define / patch / delete update
the index as side-effects. `tui._resolve_then_run` loads the index
after `list_agents(client)` and appends entries not already in the
remote list (dedup by agent_id; remote wins on conflict).

Library-level `tier3.define_agent` / `patch_agent` / `delete_agent`
stay pure — local persistence lives in the CLI layer (`_run_define`
etc.), not in the library functions. Tests of the library don't
touch the filesystem.

## Public surface

  ratatoskr.local_agents:
    LocalAgentEntry (frozen dataclass)
    load_local_agents() -> list[LocalAgentEntry]
    add_local_agent(entry)
    update_local_agent(entry)  # same semantics as add (agent_id key)
    remove_local_agent(agent_id)
    make_description(system_prompt) -> str  # synthetic picker label

Failure modes are lenient: missing file → empty index; corrupt JSON
or schema mismatch → empty index (no crash). The picker continues
to show foundational agents either way; tier-3 surface degrades to
the pre-v0.8.0 workflow.

## Picker integration

Local entries convert to ratatoskr.sessions.AgentInfo with synthetic
fields:
  name        = agent_name (from LocalAgentEntry)
  description = "(tier 3) <first non-empty line of system prompt>"
  version, capabilities, supported_models, persona_traits, ui_hints
    = None / [] / [] / {} / {}

If Worldtree later starts returning tier-3 in GET /agents, this
module's role narrows to redundant local cache; can be removed
cleanly since the dedup-by-agent-id keeps remote-wins behavior.

## Tests

286/286 GREEN (was 265, +21: 20 local_agents + 1 picker-merge
integration). Ruff clean. Tests isolate the index via
$RATATOSKR_LOCAL_AGENTS pointed at pytest's tmp_path — no pollution
of operator's real ~/.config/ratatoskr/.

## Manual smoke

Sindra-like define against personal Worldtree:
  python -m ratatoskr.tier3 define --name foo --system-prompt "..." --model X
  cat ~/.config/ratatoskr/local_agents.json
  # ratatoskr --new picker now shows ratatoskr:foo alongside mimir et al.

Cross-machine: the file is per-host. Operator can sync via dotfiles
if needed; out of scope for this commit.

Minor bump (v0.7.1 → v0.8.0) — new public module + new picker
behavior (more agents shown). No caller-side breaking changes.
2026-05-24 21:13:30 -07:00
17 changed files with 2666 additions and 314 deletions
+13 -5
View File
@@ -7,11 +7,19 @@ documents the pin, the vendored artifacts, and the bump procedure.
| Field | Value |
|---|---|
| Worldtree git SHA | `55101e909abcd2219833266b6f905c5bc956e0f0` |
| Worldtree HEAD message | `memory: snapshot — #177 Vili v1 + persona async-decouple shipped as v0.19.0` |
| Pinned on | 2026-05-20 |
| Pinned by | brokkr-smithy-dev (initial scaffold) |
| Worldtree version at pin | `v0.19.0` |
| Worldtree git SHA | `562001af28d752c3a60d449c7ddd09f44fa9dc9a` |
| Worldtree HEAD message | `feat(#201): v0.29.0 — awaiting_llm_first_token SSE heartbeat` |
| Pinned on | 2026-05-26 |
| Pinned by | ratatoskr-dev (bump for #201 awaiting_llm_first_token SSE) |
| Worldtree version at pin | `v0.29.0` |
## Pin history
| Date | SHA | Version | Notable deltas consumed |
|---|---|---|---|
| 2026-05-26 | `562001a` | v0.29.0 | #201 — new SSE event `awaiting_llm_first_token` (heartbeat during BuildingPrompt → CallingLLM gap, default 5s interval) |
| 2026-05-25 | `da93ca7` | v0.28.0 | #204 — new SSE event `affect_update` (current/scheduled), new endpoint `GET /agents/{id}/persona_state`, auth-model doc edits |
| 2026-05-20 | `55101e9` | v0.19.0 | initial scaffold pin |
## Vendored artifacts
+122
View File
@@ -55,6 +55,69 @@ conversation_api:
---
## Authorization model — agent invocation
When you call `POST /sessions` against an agent, the authorization check that fires depends on **which kind of agent** you target. There are two distinct scope namespaces — the spelling differs by one character (`agent` vs `agents`) and the granting mechanism differs entirely. Confusing the two is a common source of bug reports.
### Tier 1 — foundational agents (no `:` in agent_id)
Agents bundled with Worldtree: `mimir`, `lofn`, `soong`, `forseti`, `domari`, `vili`, `actor`, `saga`, `bragi`, `leif`, `troi`, `cara`, `glados`, and any future Asgardian. The agent_id is a simple slug like `mimir` — no colon.
> **About tiers:** Your `tier` is set on the `users` table row your API key resolves to, assigned at key-mint time (see `POST /admin/keys`). Tiers are `anonymous` (dev-mode unauthenticated), `user` (default for newly-issued keys), `free`/`pro` (subscription-shaped, not actively differentiated), and `admin`. The tier you have is visible via `GET /me`'s `tier` field. Tier-derived scopes come from `config/policies.yaml > tiers.<tier>.scopes` — there is no per-key scope override.
**Authorization rule (singular `agent`):**
```yaml
- id: agent-call-baseline-allow
principal:
tiers: ["anonymous", "user", "free", "pro", "admin"]
action: "agent.call:*"
resource: "*"
effect: allow
```
This baseline rule lives at `config/policies.yaml`. Every authenticated tier — including the `user` tier that newly-issued keys default to — already passes this check for every Tier 1 agent. **There is no per-agent scope you can add to "grant" Tier 1 access; it's covered by tier.**
If you get a 422 calling a Tier 1 agent (e.g., `lofn` rejecting with `end_user_id_required`), that's a **request-body validation**, not a scope denial. Check the `error_code` in the response detail — `END_USER_ID_REQUIRED` means the agent requires an `end_user_id` field in the request body; `AUTH_SCOPE_DENIED` (403) would be the actual scope problem. They're not interchangeable.
### Tier 3 — consumer-defined agents (`:` in agent_id)
Agents created at runtime via `POST /agents/define`. The agent_id is `<owner_user_id>:<agent_name>`, e.g., `acme:support-bot`. The `:` in the path is the trigger that switches the auth model.
**Authorization is DB-backed per-resource, NOT policy-driven (plural `agents`):**
```
scope action checked: agents.call:<owner_user_id>:<agent_name>
^^^^^^
PLURAL — different namespace from Tier 1
```
There is **no blanket allow rule** for `agents.call:*` in policy. The grant comes from the live `consumer_agents` table:
- A non-soft-deleted row in `consumer_agents` owned by `ctx.user_id` IS the grant.
- Cascade soft-delete and owner-initiated `DELETE` revoke it.
- Missing row → policy defaults to deny (403 `auth_scope_denied`).
To "add the scope" for a Tier 3 agent, you don't amend any config or call an admin endpoint — you `POST /agents/define` to register it under your `user_id`. Owning the row IS the grant. You cannot call another user's Tier 3 agent; ownership is checked at session-create (`row.user_id == ctx.user_id`).
### Common pitfalls
- **Singular vs plural.** Tier 1 uses `agent.call:*` (singular `agent`). Tier 3 uses `agents.call:<owner>:<name>` (plural `agents`). One character difference, two completely different mechanisms. There is no Tier 1 scope named `agent.call:mimir` or `agents.call:mimir` — Tier 1 is granted by baseline rule, not per-agent name.
- **No scope-mutation API.** `POST /admin/keys` accepts `{user_id, label, tier}` only. There is no per-key scope override mechanism in the storage schema. To change a user's effective scopes, change their `tier`, not their key. Per-resource Tier 3 grants flow through `POST /agents/define` (and its DELETE counterpart), not through admin endpoints.
- **422 vs 403.** A 422 is body-validation (e.g., `end_user_id_required`); a 403 is auth-policy denial (`auth_scope_denied`). Different fix paths. Read the `error_code` in `detail`.
### Quick decision table for consumers
| Target | Auth requirement |
|---|---|
| Tier 1 agent (e.g., `mimir`) | Authenticated tier ≥ `user`. No additional body requirements |
| Tier 1 agent `lofn` (the default welcoming intermediary) | Authenticated tier ≥ `user` + `end_user_id` field required in request body. 422 `END_USER_ID_REQUIRED` if absent |
| Tier 3 agent (any agent_id containing `:`) | `end_user_id` field required in body. AND the row must be owner-matched: `POST /agents/define` first to create a row under your `user_id`, then session-create works against your existing key. Cross-user Tier 3 invocation is rejected with 403 |
> **Programmatic discovery of `end_user_id` requirements:** as of v0.22.x there is no field on `GET /agents` indicating which agents require `end_user_id` — the spec line above (lofn + Tier 3) is the authoritative list, and 422 `END_USER_ID_REQUIRED` is the fallback signal at request time. Adding a discoverable `requires_end_user_id` field on `AgentInfoResponse` is on the table as a small future capability; ping if you want to drive it.
---
## GET /me
Returns the authenticated principal's identity and key metadata. Lets a client verify its key on boot without triggering agent-config-loading side effects.
@@ -1878,6 +1941,65 @@ Tool-using turns cycle through `CallingLLM → ProcessingTools → CallingLLM
Clients that don't need phase events can filter on `event["type"] != "worker_phase"` client-side. Existing SSE consumers that switch on `event["type"]` ignore this event type without code changes.
### affect_update
Persona-state observability event (issue #204). Fires twice per turn for agents with `persona.enabled: true` on non-ephemeral sessions; suppressed entirely for persona-disabled agents (e.g. `domari`, `muninn`), Tier 3 consumer-defined agents (Phase 2.0), and ephemeral sessions.
**Start-of-turn — `status: "current"`:**
Emitted immediately at the start of each qualifying turn, before any `worker_phase` event. Carries the agent's current persona snapshot reflecting all prior turns' completed appraisals.
```json
{
"type": "affect_update",
"status": "current",
"turn_id": 42,
"snapshot": {
"agent_id": "mimir",
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
"dominant_emotion": "curiosity",
"emotions_active": [
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
],
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
"last_updated_at": "2026-05-25T22:30:18+00:00"
}
}
```
**End-of-turn — `status: "scheduled"`:**
Emitted after the post-turn appraisal task has been scheduled (per #177 Phase A's fire-and-forget discipline) and before `done`. Lightweight notification — no PAD numbers, since the appraisal is still running asynchronously. The result lands in the NEXT turn's `status: "current"` snapshot.
```json
{"type": "affect_update", "status": "scheduled", "turn_id": 42}
```
`scheduled` is skipped on turn failure/cancel paths (the appraisal was never reached); `current` still fires unconditionally for qualifying turns.
Bootstrap reads available via `GET /agents/{agent_id}/persona_state` (same `snapshot` shape, requires `persona.read` scope).
### awaiting_llm_first_token
Periodic heartbeat event (issue #201) emitted at a configurable interval during the gap between `worker_phase: phase="BuildingPrompt"` and `worker_phase: phase="CallingLLM"`. Solves the legitimate-slow first-token visibility gap: consumer TUIs can render a "thinking for Ns…" timer rather than a frozen line during heavy-CoT prompt warmup.
```json
{
"type": "awaiting_llm_first_token",
"turn_id": 42,
"elapsed_ms_since_building_prompt": 5012.3
}
```
`elapsed_ms_since_building_prompt` is the server-authoritative wall-clock milliseconds since `BuildingPrompt` was emitted. Independent of network latency or clock skew.
Heartbeats stop the moment the engine produces its first event (the `CallingLLM` marker). They do NOT re-fire during tool-roundtrip `CallingLLM` re-entries — the heartbeat is scoped to the FIRST `BuildingPrompt → CallingLLM` gap only.
**Configuration:** `conversation_api.awaiting_llm_first_token_heartbeat_s` (default `5.0`). Per-agent override via `agent.conversation.awaiting_llm_first_token_heartbeat_s`. Value `0.0` disables emission entirely.
Cancellation paths (stall watchdog, user-cancel) also stop the heartbeat — no `awaiting_llm_first_token` event appears after the terminal `cancelled` event.
### thinking
Incremental reasoning/thinking content (from thinking-enabled models).
+97
View File
@@ -1851,6 +1851,103 @@ SQLite `consumer_agents` table.
before any other processing; non-slug user_ids return 403
`tier3_user_id_unsupported`.
### Persona-state observability (issue #204)
- **INV-204-1 (affect_update event type)**: `affect_update` is a
top-level SSE event `type` discriminator, sibling to `worker_phase`
/ `tool_*` / `text` / `thinking` / `done`. Not a `worker_phase` sub-
phase. INV-061's "BuildingPrompt is the FIRST event" property is
scoped to `worker_phase` events only — `affect_update status="current"`
may precede BuildingPrompt for persona-enabled agents.
- **INV-204-2 (per-turn emission)**: For agents with persona enabled
on non-ephemeral sessions, `stream_turn` emits `status="current"`
before any other SSE event on a successful or failed turn, and
`status="scheduled"` after `update_after_turn` schedules the
appraisal task (success path only — skipped on cancel / error
before update_after_turn was reached). See contract
`docs/contracts/issues/204.contract.md`.
- **INV-204-3 (emission suppression)**: Persona-disabled agents and
ephemeral sessions emit ZERO `affect_update` events.
- **INV-204-6 / INV-204-7 (persona_state endpoint)**: New
`GET /agents/{agent_id}/persona_state` gated on Heimdall scope
`persona.read`. Route ordering: auth → Tier 3 short-circuit (404
`persona_not_configured`) → Tier 1/2 existence (404
`agent_not_available`) → persona-enabled check (404
`persona_not_configured`) → snapshot (200).
- **INV-204-9 (read-only registry primitive)**: `PersonaRegistry.get_state`
is mutex-free and never mutates `persona.emotions`. Eventual
consistency under concurrent `_appraisal_wrapper` mutations.
- **INV-204-14 (replay participation)**: `affect_update` events flow
through `_publish`, so SSE resume / replay handles them with no
special case.
## Amendment — AwaitingLLMFirstToken heartbeat (issue #201, INV-201-1..7)
Adds a periodic SSE heartbeat event during the gap between
`BuildingPrompt` and `CallingLLM` so consumers can distinguish
"engine is thinking" from "engine is wedged" without out-of-band
server inspection. Filed by ratatoskr-dev; ships in v0.29.0.
- **INV-201-1 (new top-level event type)**: `awaiting_llm_first_token`
is a new top-level SSE event type, sibling to `worker_phase` /
`tool_*` / `text` / `thinking` / `debug` / `done` / `affect_update`.
`_WORKER_PHASE_VOCAB` is NOT extended; INV-053 / INV-054 unchanged.
Same precedent as #204's `affect_update`.
- **INV-201-2 (config-gated emission)**: Heartbeat emission requires
`awaiting_llm_first_token_heartbeat_s > 0.0`. When the resolved
value is `0.0`, the heartbeat task is never started and zero
`awaiting_llm_first_token` events emit for the turn. When > 0.0,
the task starts immediately after `_publish_phase("BuildingPrompt")`
and emits an event every `interval` seconds until cancelled.
- **INV-201-3 (defense-in-depth cancellation)**: The heartbeat task
is cancelled at three sites (idempotent via the `_cancel_heartbeat`
helper): (a) immediately before `_publish_phase("CallingLLM")` on
the engine-first-event path; (b) inside the `cancelled`/`error`
handling that wraps `_handle_cancel` (covers stall + user-cancel
paths); (c) in the outer `finally` block alongside
`_clear_stall_timer`. After cancellation, no further
`awaiting_llm_first_token` events emit.
- **INV-201-4 (wire shape)**: Payload is exactly `{type:
"awaiting_llm_first_token", turn_id: <int>,
elapsed_ms_since_building_prompt: <float>}` plus the composite `id:
"<turn_id>:<seq>"` stamped by `_publish`. No additional fields.
`elapsed_ms_since_building_prompt` is `(time.monotonic() -
building_prompt_t) * 1000.0` where `building_prompt_t` is captured
immediately before `BuildingPrompt` is published.
- **INV-201-5 (first-gap-only scope)**: Heartbeat is scoped to the
FIRST `BuildingPrompt → CallingLLM` gap of the turn. Tool round-trip
`CallingLLM` re-entries (INV-058) emit ZERO
`awaiting_llm_first_token` events. Out-of-scope sub-phases
(`AwaitingToolResult`, `AwaitingNextLLMCall`) would be separate
follow-up features.
- **INV-201-6 (replay participation)**: Heartbeat events flow through
`_publish → _replay_buffer + queue` per INV-060 — same replay
semantics as worker_phase events. On `Last-Event-ID` reconnect,
prior heartbeats replay identically.
- **INV-201-7 (config resolution precedence)**: Per-agent
`agent.conversation.awaiting_llm_first_token_heartbeat_s` →
`api_cfg.awaiting_llm_first_token_heartbeat_s` → built-in `5.0`.
Negative values raise `ConfigurationError` at agent load; `0.0`
is valid and means "disabled." Mirrors the `_resolve_stall_timeout_s`
precedence pattern (INV-038).
### Mechanism note
The heartbeat task is a separate `asyncio.Task` (NOT `loop.call_later`,
because heartbeats repeat at an interval rather than fire once at a
timeout). An `asyncio.Queue` shared between the heartbeat task and the
generator carries events; the generator uses
`asyncio.wait(return_when=FIRST_COMPLETED)` to race the engine's
`__anext__` against the heartbeat queue's `get` ONLY during the first
iteration. After `CallingLLM` fires, the heartbeat task is cancelled
and subsequent iterations use the original non-race pattern.
### Storage extension
The `consumer_agents` table lives in `core/heimdall/storage/sqlite.py`
+6 -3
View File
@@ -32,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool.
## Current state / in-flight
_As of 2026-05-25 (post-v0.7.1 thinking coalesce-by-newline):_
_As of 2026-05-25 (post-v0.8.2 drop double-print; v0.9.0 live-md next):_
**Status: v0.7.1 shipped.** Ten core features complete (`sse_client`
**Status: v0.8.2 shipped.** Eleven core features complete (`sse_client`
#1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
startup error visibility #6, presenter contract semantics amendment
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
@@ -51,7 +51,10 @@ Static in the footer (static "Tools" v1; dynamic when more tabs
land). CLI mode (--send) unaffected by design — INV-018.
Last commits on `main`:
- v0.7.1 fix(tui): coalesce thinking deltas on `\n` — no more per-token newlines
- v0.8.2 fix(tui): drop post-Done Markdown body re-render (no double-print)
- `11ef683` fix(tui,sse): inline Text streaming + empty-id keepalive skip (v0.8.1)
- `9fade55` feat(local_agents): JSON-backed local tier-3 index + picker merge (v0.8.0)
- `9918c10` fix(tui): coalesce thinking deltas on `\n` (v0.7.1)
- `c086ae2` feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0)
- `d356990` refactor(tui): thinking streams into thinking-log (v0.6.5)
- `82437bd` style(tui): picker highlighted item → Aurora blue (v0.6.4)
+4 -4
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.7.1"
version = "0.14.2"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
@@ -42,9 +42,9 @@ Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
# Ratatoskr is built against Worldtree at this commit; the vendored
# spec snapshot in docs/ reflects that SHA.
[tool.ratatoskr.spec-pin]
worldtree-spec-rev = "55101e909abcd2219833266b6f905c5bc956e0f0"
worldtree-version = "v0.19.0"
pinned-on = "2026-05-20"
worldtree-spec-rev = "562001af28d752c3a60d449c7ddd09f44fa9dc9a"
worldtree-version = "v0.29.0"
pinned-on = "2026-05-26"
[tool.hatch.build.targets.wheel]
packages = ["src/ratatoskr"]
+25
View File
@@ -18,6 +18,8 @@ import httpx
from ratatoskr.sessions import AgentNotFound, SessionApiFailed, create_session
from ratatoskr.sse_client import (
AffectUpdate,
AwaitingLlmFirstToken,
CancelAlreadyCompleted,
CancelFailed,
Cancelled,
@@ -205,6 +207,7 @@ class CliPresenterState:
(
WorkerPhase, Thinking, Text, TextBoundary,
ToolStart, ToolResult, Done, Error, Cancelled,
AffectUpdate, AwaitingLlmFirstToken,
),
)
# Thinking events accumulate into the open run.
@@ -275,6 +278,28 @@ class CliPresenterState:
f". text_boundary: kind={event.kind} char_offset={event.char_offset}\n"
)
return
if isinstance(event, AffectUpdate):
# Worldtree #204 / v0.28.0. CLI surface is debug telemetry —
# one line to stderr with status + (for current) dominant_emotion.
if event.snapshot is not None:
dom = event.snapshot.get("dominant_emotion")
stderr.write(
f". affect_update: status={event.status} turn_id={event.turn_id} "
f"dominant_emotion={dom!r}\n"
)
else:
stderr.write(
f". affect_update: status={event.status} turn_id={event.turn_id}\n"
)
return
if isinstance(event, AwaitingLlmFirstToken):
# Worldtree #201 / v0.29.0. Heartbeat during BuildingPrompt →
# CallingLLM gap. Stderr surface, one line per heartbeat.
secs = event.elapsed_ms_since_building_prompt / 1000.0
stderr.write(
f". awaiting_llm_first_token: turn_id={event.turn_id} elapsed={secs:.1f}s\n"
)
return
async def _cancel_and_log(
+134
View File
@@ -0,0 +1,134 @@
"""Local index of tier-3 agents defined via `python -m ratatoskr.tier3`.
Workaround for Worldtree's ``GET /agents`` not returning consumer-defined
agents (the public list excludes tier-3 per-spec; see issue #15 smoke
findings). Local file maintains a list of agent_ids + display metadata so
the picker can show them alongside foundational agents.
Storage shape: JSON at ``$XDG_CONFIG_HOME/ratatoskr/local_agents.json``
(default ``~/.config/ratatoskr/local_agents.json``). Override via
``$RATATOSKR_LOCAL_AGENTS`` env var for tests / per-machine isolation.
If Worldtree later starts returning tier-3 agents in ``GET /agents``, this
module's role narrows to redundant local cache; can be removed cleanly
since the picker's dedup-by-agent-id keeps remote-wins behavior.
Failure modes are lenient: missing file empty index; corrupt JSON or
schema mismatch empty index (no crash). The picker continues to show
foundational agents either way; the local-tier-3 surface degrades to
"operator passes --agent ratatoskr:<name> explicitly" the
pre-v0.8.0 workflow.
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass
from pathlib import Path
_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class LocalAgentEntry:
"""One row in the local tier-3 agent index.
Schema:
- ``agent_id``: full "user_id:agent_name" string (Worldtree-owned).
- ``agent_name``: slug from define (display name).
- ``model``: provider model ID at last define/patch.
- ``description``: synthetic display string (typically derived from
the system_prompt's first line + a "(tier 3)" prefix; the picker
uses this in its ``{id} · {name} {description}`` rendering).
- ``defined_at``: ISO-8601 timestamp from the Tier3AgentInfo response.
"""
agent_id: str
agent_name: str
model: str
description: str
defined_at: str
def _local_agents_path() -> Path:
"""Resolve the local index file path with XDG + env-var override."""
override = os.environ.get("RATATOSKR_LOCAL_AGENTS")
if override:
return Path(override)
xdg = os.environ.get("XDG_CONFIG_HOME")
base = Path(xdg) if xdg else (Path.home() / ".config")
return base / "ratatoskr" / "local_agents.json"
def load_local_agents() -> list[LocalAgentEntry]:
"""Read the local index. Returns ``[]`` on missing file, corrupt JSON,
schema mismatch, or any read error never raises.
"""
path = _local_agents_path()
if not path.exists():
return []
try:
raw = json.loads(path.read_text())
except (json.JSONDecodeError, OSError):
return []
if not isinstance(raw, dict) or raw.get("version") != _SCHEMA_VERSION:
return []
agents = raw.get("agents", [])
if not isinstance(agents, list):
return []
out: list[LocalAgentEntry] = []
for item in agents:
if not isinstance(item, dict):
continue
try:
out.append(LocalAgentEntry(**item))
except TypeError:
# Malformed row (missing/extra fields) — skip silently.
continue
return out
def _save_local_agents(agents: list[LocalAgentEntry]) -> None:
"""Persist the index. Creates parent dir as needed."""
path = _local_agents_path()
path.parent.mkdir(parents=True, exist_ok=True)
payload = {"version": _SCHEMA_VERSION, "agents": [asdict(a) for a in agents]}
path.write_text(json.dumps(payload, indent=2))
def add_local_agent(entry: LocalAgentEntry) -> None:
"""Add (or replace) an agent in the local index. agent_id is the key."""
agents = [a for a in load_local_agents() if a.agent_id != entry.agent_id]
agents.append(entry)
_save_local_agents(agents)
def update_local_agent(entry: LocalAgentEntry) -> None:
"""Update an existing entry. Identical semantics to ``add_local_agent``
(agent_id is the dedup key), exposed separately so callers can
self-document intent.
"""
add_local_agent(entry)
def remove_local_agent(agent_id: str) -> None:
"""Remove an entry by agent_id. No-op if absent (idempotent)."""
agents = [a for a in load_local_agents() if a.agent_id != agent_id]
_save_local_agents(agents)
def make_description(system_prompt: str) -> str:
"""Synthesize a one-line description for the picker from a system prompt.
Strategy: first non-empty line, stripped of leading markdown heading
markers and whitespace, prefixed with "(tier 3) ", truncated to 80
chars. Falls back to "(tier 3) custom system prompt" if the prompt is
empty (defensive define rejects empty prompts at PRE-002).
"""
for line in system_prompt.splitlines():
stripped = line.lstrip("# ").strip()
if stripped:
label = f"(tier 3) {stripped}"
return label[:80] + ("" if len(label) > 80 else "")
return "(tier 3) custom system prompt"
+82
View File
@@ -89,6 +89,46 @@ class SessionApiFailed(Exception):
self.body = body
# Worldtree #204 / v0.28.0 — persona_state endpoint failure modes.
class PersonaNotConfigured(Exception):
"""Raised on HTTP 404 `persona_not_configured` from GET persona_state.
Agent exists but has no persona surface: persona-disabled Tier 1/2
agents (e.g. `domari`, `muninn`) and all Tier 3 consumer-defined
agents (Phase 2.0). Distinct from `AgentNotAvailable` which means the
agent_id is unknown entirely.
"""
def __init__(self, *, agent_id: str) -> None:
super().__init__(f"persona not configured for agent_id: {agent_id!r}")
self.agent_id = agent_id
class AgentNotAvailable(Exception):
"""Raised on HTTP 404 `agent_not_available` from GET persona_state.
The agent_id is unknown to the server. Distinct from
`PersonaNotConfigured` (agent exists but has no persona).
"""
def __init__(self, *, agent_id: str) -> None:
super().__init__(f"agent not available: {agent_id!r}")
self.agent_id = agent_id
class AuthScopeDenied(Exception):
"""Raised on HTTP 403 `auth_scope_denied` from a Heimdall-scoped endpoint.
The API key lacks the required scope (e.g. `persona.read` for
GET /agents/{id}/persona_state). User-tier keys carry `persona.read`
by default; this surfaces when a narrower key is in use.
"""
def __init__(self, *, scope: str) -> None:
super().__init__(f"auth scope denied: required={scope!r}")
self.scope = scope
async def list_sessions(
client: httpx.AsyncClient,
*,
@@ -200,3 +240,45 @@ async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
)
for item in body
]
async def get_persona_state(
client: httpx.AsyncClient, agent_id: str
) -> dict[str, Any]:
"""GET /agents/{agent_id}/persona_state — fetch current persona snapshot.
Worldtree #204 / v0.28.0. Returns the same `snapshot` dict shape as the
`affect_update` SSE event's `status="current"` emission: pad,
dominant_emotion, emotions_active, baseline_pad, mood_drift,
last_updated_at. Bootstrap read for clients that want to populate a
persona pane on session-open without waiting for turn-1's `affect_update`.
Auth: requires Heimdall `persona.read` scope (user-tier default).
Failure modes (mapped to typed exceptions per the spec error_codes):
- 404 `persona_not_configured` PersonaNotConfigured (persona-disabled
agents: domari / muninn, and all Tier 3 in Phase 2.0)
- 404 `agent_not_available` AgentNotAvailable (unknown agent_id)
- 403 `auth_scope_denied` AuthScopeDenied (key lacks persona.read)
- any other non-2xx SessionApiFailed (preserves the broader-error
precedent from list_agents / list_sessions / create_session)
"""
assert client is not None
assert agent_id and isinstance(agent_id, str)
resp = await client.get(f"/agents/{agent_id}/persona_state")
if resp.status_code == 200:
return resp.json()
# Discriminate the 4xx error_code sub-codes; everything else falls through.
try:
err = resp.json()
error_code = err.get("error_code") if isinstance(err, dict) else None
except ValueError:
error_code = None
if resp.status_code == 404 and error_code == "persona_not_configured":
raise PersonaNotConfigured(agent_id=agent_id)
if resp.status_code == 404 and error_code == "agent_not_available":
raise AgentNotAvailable(agent_id=agent_id)
if resp.status_code == 403 and error_code == "auth_scope_denied":
raise AuthScopeDenied(scope="persona.read")
raise SessionApiFailed(status=resp.status_code, body=resp.content)
+79
View File
@@ -111,6 +111,55 @@ class Cancelled:
partial_message_id: int | None
@dataclass(frozen=True)
class AwaitingLlmFirstToken:
"""SSE event `awaiting_llm_first_token`: heartbeat during slow first-token.
Fires at the configured interval (default 5s) during the gap between
`worker_phase` phase=BuildingPrompt and phase=CallingLLM. Lets clients
render a live "thinking for Ns…" indicator instead of a frozen line
during legitimate-slow first-token latency. Stops the moment CallingLLM
fires (defense-in-depth at three sites); no heartbeat after Cancelled
or stalled terminal events. Tool round-trip re-entries do NOT re-fire
heartbeats INV-201-5 scopes the mechanism to the FIRST gap only.
`elapsed_ms_since_building_prompt` is server-authoritative
`time.monotonic()`-based independent of network latency or clock
skew, monotonically increasing across the heartbeat sequence.
See docs/conversation-api-spec.md § awaiting_llm_first_token
(Worldtree #201, v0.29.0).
"""
sse_id: SseId
turn_id: int
elapsed_ms_since_building_prompt: float
@dataclass(frozen=True)
class AffectUpdate:
"""SSE event `affect_update`: persona-state observability snapshot.
Two emissions per qualifying turn (persona-enabled agent on non-
ephemeral session): `status="current"` at turn start carrying the full
snapshot, `status="scheduled"` after post-turn appraisal kicks off
(lightweight `snapshot` is None). Suppressed entirely for persona-
disabled agents (e.g. `domari`, `muninn`), Tier 3 consumer-defined
agents (Phase 2.0), and ephemeral sessions.
Bootstrap reads available via `GET /agents/{agent_id}/persona_state`
(same `snapshot` shape, requires `persona.read` scope).
See docs/conversation-api-spec.md § affect_update (Worldtree #204,
v0.28.0).
"""
sse_id: SseId
status: str # "current" | "scheduled"
turn_id: int
snapshot: dict[str, Any] | None # None when status="scheduled"
Event = (
WorkerPhase
| Thinking
@@ -121,6 +170,8 @@ Event = (
| Done
| Error
| Cancelled
| AffectUpdate
| AwaitingLlmFirstToken
)
@@ -284,6 +335,26 @@ def _envelope_for_type(body: dict[str, Any], sse_id: SseId) -> Event:
reason=body.get("reason"),
partial_message_id=body.get("partial_message_id"),
)
if t == "awaiting_llm_first_token":
# Worldtree #201 / v0.29.0: top-level heartbeat during BuildingPrompt
# → CallingLLM gap. Lets clients render live elapsed-time indicators
# instead of frozen lines on legitimate-slow first-token latency.
return AwaitingLlmFirstToken(
sse_id=sse_id,
turn_id=body["turn_id"],
elapsed_ms_since_building_prompt=body["elapsed_ms_since_building_prompt"],
)
if t == "affect_update":
# Worldtree #204 / v0.28.0: persona-state observability event.
# status="current" carries full snapshot at turn start;
# status="scheduled" omits snapshot (lightweight post-appraisal-
# kickoff notification).
return AffectUpdate(
sse_id=sse_id,
status=body["status"],
turn_id=body["turn_id"],
snapshot=body.get("snapshot"),
)
raise ValueError(f"unknown SSE event type: {t!r}")
@@ -309,6 +380,14 @@ async def _iter_events(
# with a bad id is still a keepalive). Don't reorder.
if sse.data == "":
continue
# v0.8.1: empty-id frames are also treated as keepalives. Worldtree
# SOMETIMES emits events without an `id:` line (observed mid-stream
# on the qwen3.6-35-a3b-heretic provider, 2026-05-25). Per the SSE
# RFC, events without ids are legitimate (they just don't update
# Last-Event-ID); the previous strict behavior crashed every turn
# on the offending agent. Treat same as empty-data: skip silently.
if sse.id == "":
continue
try:
sse_id = _parse_sse_id(sse.id)
except ValueError as exc:
+33
View File
@@ -309,6 +309,11 @@ def _resolve_auth(ns: argparse.Namespace) -> tuple[str, str]:
async def _run_define(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT
from ratatoskr.local_agents import (
LocalAgentEntry,
add_local_agent,
make_description,
)
async with httpx.AsyncClient(
base_url=server_url,
@@ -324,6 +329,16 @@ async def _run_define(ns: argparse.Namespace) -> int:
system_prompt=ns.system_prompt,
model=ns.model,
)
# v0.8.0: persist to local index so the picker can show it.
add_local_agent(
LocalAgentEntry(
agent_id=info.agent_id,
agent_name=info.agent_name,
model=info.model,
description=make_description(info.system_prompt),
defined_at=info.created_at,
)
)
print(f"defined {info.agent_id} ({info.model})")
return 0
@@ -331,6 +346,11 @@ async def _run_define(ns: argparse.Namespace) -> int:
async def _run_patch(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT
from ratatoskr.local_agents import (
LocalAgentEntry,
make_description,
update_local_agent,
)
if ns.system_prompt is None and ns.model is None:
raise _Tier3UsageError(
@@ -350,6 +370,16 @@ async def _run_patch(ns: argparse.Namespace) -> int:
system_prompt=ns.system_prompt,
model=ns.model,
)
# v0.8.0: refresh local index with the post-patch state.
update_local_agent(
LocalAgentEntry(
agent_id=info.agent_id,
agent_name=info.agent_name,
model=info.model,
description=make_description(info.system_prompt),
defined_at=info.updated_at,
)
)
print(f"patched {info.agent_id}")
return 0
@@ -357,6 +387,7 @@ async def _run_patch(ns: argparse.Namespace) -> int:
async def _run_delete(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT
from ratatoskr.local_agents import remove_local_agent
async with httpx.AsyncClient(
base_url=server_url,
@@ -367,6 +398,8 @@ async def _run_delete(ns: argparse.Namespace) -> int:
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
) as client:
await delete_agent(client, ns.agent_id)
# v0.8.0: drop from local index so the picker stops listing it.
remove_local_agent(ns.agent_id)
print(f"deleted {ns.agent_id}")
return 0
+686 -112
View File
File diff suppressed because it is too large Load Diff
+187
View File
@@ -0,0 +1,187 @@
"""Tests for ratatoskr.local_agents.
Use ``$RATATOSKR_LOCAL_AGENTS`` env-var override + pytest tmp_path to
isolate from the operator's real ``~/.config/ratatoskr/local_agents.json``.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from ratatoskr.local_agents import (
LocalAgentEntry,
_local_agents_path,
add_local_agent,
load_local_agents,
make_description,
remove_local_agent,
update_local_agent,
)
@pytest.fixture
def local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point $RATATOSKR_LOCAL_AGENTS at a fresh tmp file for the test."""
path = tmp_path / "local_agents.json"
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(path))
return path
def _entry(
agent_id: str = "ratatoskr:wizard",
agent_name: str = "wizard",
model: str = "qwen3.6-35-a3b",
description: str = "(tier 3) test agent",
defined_at: str = "2026-05-25T00:00:00+00:00",
) -> LocalAgentEntry:
return LocalAgentEntry(
agent_id=agent_id,
agent_name=agent_name,
model=model,
description=description,
defined_at=defined_at,
)
class TestPathResolution:
def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", "/tmp/custom-agents.json")
assert _local_agents_path() == Path("/tmp/custom-agents.json")
def test_xdg_config_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("RATATOSKR_LOCAL_AGENTS", raising=False)
monkeypatch.setenv("XDG_CONFIG_HOME", "/tmp/xdg-config")
assert (
_local_agents_path()
== Path("/tmp/xdg-config/ratatoskr/local_agents.json")
)
def test_default_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("RATATOSKR_LOCAL_AGENTS", raising=False)
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
path = _local_agents_path()
assert path == Path.home() / ".config" / "ratatoskr" / "local_agents.json"
class TestLoadEmpty:
def test_missing_file_returns_empty(self, local_path: Path) -> None:
assert not local_path.exists()
assert load_local_agents() == []
def test_corrupt_json_returns_empty(self, local_path: Path) -> None:
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text("not json at all")
assert load_local_agents() == []
def test_wrong_schema_version_returns_empty(self, local_path: Path) -> None:
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text(json.dumps({"version": 999, "agents": []}))
assert load_local_agents() == []
def test_missing_version_key_returns_empty(self, local_path: Path) -> None:
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text(json.dumps({"agents": []}))
assert load_local_agents() == []
def test_malformed_row_skipped(self, local_path: Path) -> None:
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text(
json.dumps(
{
"version": 1,
"agents": [
{"agent_id": "incomplete"}, # missing required fields
{
"agent_id": "ratatoskr:good",
"agent_name": "good",
"model": "m",
"description": "d",
"defined_at": "t",
},
],
}
)
)
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:good"
class TestAdd:
def test_add_one(self, local_path: Path) -> None:
add_local_agent(_entry())
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:wizard"
def test_add_two_different(self, local_path: Path) -> None:
add_local_agent(_entry(agent_id="ratatoskr:a", agent_name="a"))
add_local_agent(_entry(agent_id="ratatoskr:b", agent_name="b"))
ids = {e.agent_id for e in load_local_agents()}
assert ids == {"ratatoskr:a", "ratatoskr:b"}
def test_add_replaces_same_id(self, local_path: Path) -> None:
add_local_agent(_entry(model="old-model"))
add_local_agent(_entry(model="new-model"))
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].model == "new-model"
def test_creates_parent_dirs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
nested = tmp_path / "deep" / "nested" / "path" / "agents.json"
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(nested))
add_local_agent(_entry())
assert nested.exists()
class TestUpdate:
def test_update_changes_existing(self, local_path: Path) -> None:
add_local_agent(_entry(model="v1"))
update_local_agent(_entry(model="v2"))
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].model == "v2"
class TestRemove:
def test_remove_existing(self, local_path: Path) -> None:
add_local_agent(_entry())
remove_local_agent("ratatoskr:wizard")
assert load_local_agents() == []
def test_remove_missing_is_noop(self, local_path: Path) -> None:
add_local_agent(_entry())
remove_local_agent("ratatoskr:doesnotexist")
assert len(load_local_agents()) == 1
class TestMakeDescription:
def test_first_nonempty_line(self) -> None:
prompt = "\n\n# IDENTITY\nYou are a test agent..."
desc = make_description(prompt)
assert desc.startswith("(tier 3) IDENTITY")
def test_strips_heading_markers(self) -> None:
prompt = "# A nice heading\nMore prompt..."
desc = make_description(prompt)
assert "(tier 3) A nice heading" == desc
def test_truncates_long(self) -> None:
prompt = "x" * 200
desc = make_description(prompt)
# 80 char cap including the prefix
assert len(desc) == 81 # 80 + ellipsis char
assert desc.endswith("")
def test_empty_prompt_fallback(self) -> None:
desc = make_description("")
assert desc == "(tier 3) custom system prompt"
def test_whitespace_only_fallback(self) -> None:
desc = make_description(" \n\n ")
assert desc == "(tier 3) custom system prompt"
+109
View File
@@ -6,11 +6,15 @@ import respx
from ratatoskr.sessions import (
AgentInfo,
AgentNotAvailable,
AgentNotFound,
AuthScopeDenied,
InvalidCursor,
PersonaNotConfigured,
SessionApiFailed,
SessionPage,
create_session,
get_persona_state,
list_agents,
list_sessions,
)
@@ -556,3 +560,108 @@ class TestListAgents:
with pytest.raises(SessionApiFailed) as excinfo:
await list_agents(client)
assert excinfo.value.status == 401
class TestGetPersonaState:
"""Worldtree #204 / v0.28.0 — GET /agents/{agent_id}/persona_state.
Bootstrap read for the persona snapshot same shape as `affect_update`'s
`current` snapshot. Auth via `persona.read` scope (user-tier default).
"""
@respx.mock
async def test_happy_full_snapshot(self) -> None:
"""happy_full_snapshot [happy,tracer]: 200 → snapshot dict with pad +
dominant_emotion + emotions_active + baseline_pad + mood_drift.
"""
snapshot = {
"agent_id": "mimir",
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
"dominant_emotion": "curiosity",
"emotions_active": [
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
],
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
"last_updated_at": "2026-05-25T22:30:18+00:00",
}
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(200, json=snapshot)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await get_persona_state(client, "mimir")
assert result == snapshot
@respx.mock
async def test_persona_not_configured_404(self) -> None:
"""persona_not_configured_404 [error]: 404 with error_code
persona_not_configured PersonaNotConfigured. Agent exists but has
no persona surface (e.g. domari, muninn, Tier 3).
"""
respx.get("https://w.example/agents/domari/persona_state").mock(
return_value=httpx.Response(
404, json={"error_code": "persona_not_configured", "message": "no persona"}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(PersonaNotConfigured) as exc_info:
await get_persona_state(client, "domari")
assert exc_info.value.agent_id == "domari"
@respx.mock
async def test_agent_not_available_404(self) -> None:
"""agent_not_available_404 [error]: 404 with error_code
agent_not_available AgentNotAvailable. Distinct from
persona_not_configured the agent_id itself is unknown.
"""
respx.get("https://w.example/agents/bogus/persona_state").mock(
return_value=httpx.Response(
404, json={"error_code": "agent_not_available", "message": "unknown agent"}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AgentNotAvailable) as exc_info:
await get_persona_state(client, "bogus")
assert exc_info.value.agent_id == "bogus"
@respx.mock
async def test_auth_scope_denied_403(self) -> None:
"""auth_scope_denied_403 [error]: 403 with error_code auth_scope_denied
AuthScopeDenied. Key lacks `persona.read` scope.
"""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(
403,
json={"error_code": "auth_scope_denied", "message": "missing persona.read"},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AuthScopeDenied) as exc_info:
await get_persona_state(client, "mimir")
assert exc_info.value.scope == "persona.read"
@respx.mock
async def test_404_unknown_error_code_falls_through(self) -> None:
"""404_unknown_error_code_falls_through [adversarial]: 404 without the
two known error codes SessionApiFailed (don't swallow novel failure
modes as something more specific than they are).
"""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(404, json={"error_code": "novel_404"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await get_persona_state(client, "mimir")
assert exc_info.value.status == 404
@respx.mock
async def test_500_unexpected_status(self) -> None:
"""500_unexpected_status [error]: 5xx → SessionApiFailed (matches the
list_agents / list_sessions / create_session precedent)."""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(500, content=b"boom")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await get_persona_state(client, "mimir")
assert exc_info.value.status == 500
+204
View File
@@ -5,6 +5,8 @@ import pytest
import respx
from ratatoskr.sse_client import (
AffectUpdate,
AwaitingLlmFirstToken,
CancelAlreadyCompleted,
Cancelled,
CancelResult,
@@ -711,6 +713,47 @@ def _sse_raw_chunk(sse_id: str, raw_data: str) -> bytes:
return f"id: {sse_id}\ndata: {raw_data}\n\n".encode()
def _sse_no_id_chunk(data: str) -> bytes:
"""SSE frame with NO id line + arbitrary data (v0.8.1: keepalive shape)."""
return f"data: {data}\n\n".encode()
class TestEmptyIdSkipped:
@respx.mock
async def test_empty_id_on_first_event_skipped(self) -> None:
"""empty_id_on_first_event_skipped [v0.8.1]: stream starts with an
event carrying NO `id:` line httpx_sse exposes sse.id == ''
(no prior id to inherit). Pre-v0.8.1: MalformedSseId raw='' crashed
the turn. v0.8.1: treat same as empty-data keepalive skip silently.
Observed 2026-05-25 on Worldtree's qwen3.6-35-a3b-heretic provider:
the first stream frame had no id line, every turn died with
`[malformed_sse_id] raw=''`.
"""
from ratatoskr.sse_client import Done as _Done
from ratatoskr.sse_client import Text as _Text
# First frame: no id line (httpx_sse → sse.id = ""). Skip it.
# Subsequent frames have ids; normal processing resumes.
stream = (
_sse_no_id_chunk('{"type":"keepalive"}') # ← skipped (sse.id == "")
+ _sse_chunk("42:1", {"type": "text", "content": "first"})
+ _sse_chunk("42:2", _DONE_42_6)
)
respx.post("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=stream
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_turn(client, "s1", "hi")]
# 2 events — the no-id frame is invisible (no MalformedSseId crash).
assert len(events) == 2
assert isinstance(events[0], _Text)
assert events[0].content == "first"
assert isinstance(events[1], _Done)
class TestEmptyDataSkipped:
@respx.mock
async def test_empty_data_skipped(self) -> None:
@@ -836,3 +879,164 @@ class TestEmptyDataSkipped:
assert exc_info.value.raw == "x" * 200
# Exception message also only contains the truncated form
assert "x" * 5000 not in str(exc_info.value)
class TestAffectUpdate:
"""Worldtree #204 / v0.28.0 — persona-state observability SSE event.
Two emissions per qualifying turn (persona-enabled agent, non-ephemeral
session): `status: "current"` at turn start with full snapshot, then
`status: "scheduled"` near turn end (lightweight, no snapshot).
See docs/conversation-api-spec.md § affect_update.
"""
@respx.mock
async def test_current_status_parsed_with_snapshot(self) -> None:
"""current_status_parsed_with_snapshot [tracer]: status=current carries
the full snapshot dict; AffectUpdate.snapshot is populated with the
nested PAD / dominant_emotion / emotions_active fields.
"""
snapshot = {
"agent_id": "mimir",
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
"dominant_emotion": "curiosity",
"emotions_active": [
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
],
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
"last_updated_at": "2026-05-25T22:30:18+00:00",
}
stream = _sse_chunk(
"42:1",
{
"type": "affect_update",
"status": "current",
"turn_id": 42,
"snapshot": snapshot,
},
) + _sse_chunk("42:2", _DONE_42_6)
respx.post("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=stream
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_turn(client, "s1", "hi")]
affect = events[0]
assert isinstance(affect, AffectUpdate)
assert affect.status == "current"
assert affect.turn_id == 42
assert affect.snapshot == snapshot
assert affect.sse_id == SseId(42, 1)
@respx.mock
async def test_scheduled_status_parsed_no_snapshot(self) -> None:
"""scheduled_status_parsed_no_snapshot [trace]: status=scheduled carries
no snapshot field; AffectUpdate.snapshot is None.
"""
stream = (
_sse_chunk("42:1", {"type": "text", "content": "x"})
+ _sse_chunk(
"42:2",
{"type": "affect_update", "status": "scheduled", "turn_id": 42},
)
+ _sse_chunk("42:3", _DONE_42_6)
)
respx.post("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=stream
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_turn(client, "s1", "hi")]
affect = next(e for e in events if isinstance(e, AffectUpdate))
assert affect.status == "scheduled"
assert affect.turn_id == 42
assert affect.snapshot is None
assert affect.sse_id == SseId(42, 2)
class TestAwaitingLlmFirstToken:
"""Worldtree #201 / v0.29.0 — `awaiting_llm_first_token` SSE heartbeat.
Top-level event (not a worker_phase extension) fired during the
BuildingPrompt CallingLLM gap at the configured interval (default
5s). Server-authoritative elapsed_ms is time.monotonic()-based and
monotonically increasing across the heartbeat sequence.
See docs/conversation-api-spec.md § awaiting_llm_first_token.
"""
@respx.mock
async def test_single_heartbeat_parsed(self) -> None:
"""single_heartbeat_parsed [tracer]: type=awaiting_llm_first_token →
AwaitingLlmFirstToken(turn_id, elapsed_ms_since_building_prompt).
"""
stream = _sse_chunk(
"42:1",
{
"type": "awaiting_llm_first_token",
"turn_id": 42,
"elapsed_ms_since_building_prompt": 5012.3,
},
) + _sse_chunk("42:2", _DONE_42_6)
respx.post("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=stream
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_turn(client, "s1", "hi")]
beat = events[0]
assert isinstance(beat, AwaitingLlmFirstToken)
assert beat.turn_id == 42
assert beat.elapsed_ms_since_building_prompt == 5012.3
assert beat.sse_id == SseId(42, 1)
@respx.mock
async def test_heartbeat_sequence_monotonic(self) -> None:
"""heartbeat_sequence_monotonic [scenario]: three consecutive heartbeats
in one turn elapsed_ms_since_building_prompt monotonically increases,
all carry the same turn_id.
"""
stream = (
_sse_chunk(
"42:1",
{
"type": "awaiting_llm_first_token",
"turn_id": 42,
"elapsed_ms_since_building_prompt": 5000.0,
},
)
+ _sse_chunk(
"42:2",
{
"type": "awaiting_llm_first_token",
"turn_id": 42,
"elapsed_ms_since_building_prompt": 10005.4,
},
)
+ _sse_chunk(
"42:3",
{
"type": "awaiting_llm_first_token",
"turn_id": 42,
"elapsed_ms_since_building_prompt": 15011.8,
},
)
+ _sse_chunk("42:4", _DONE_42_6)
)
respx.post("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=stream
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_turn(client, "s1", "hi")]
beats = [e for e in events if isinstance(e, AwaitingLlmFirstToken)]
assert len(beats) == 3
elapsed = [b.elapsed_ms_since_building_prompt for b in beats]
assert elapsed == sorted(elapsed) # monotonically increasing
assert all(b.turn_id == 42 for b in beats)
+60 -6
View File
@@ -1,5 +1,7 @@
"""Tests for ratatoskr.tier3 per docs/contracts/issues/15.contract.md."""
from pathlib import Path
import httpx
import pytest
import respx
@@ -315,12 +317,29 @@ class TestDeleteAgent:
assert exc.value.status == 500
@pytest.fixture
def _isolated_local_agents(
tmp_path: "Path", monkeypatch: pytest.MonkeyPatch
) -> "Path":
"""Isolate the v0.8.0 local-tier-3 index from the operator's real file."""
path = tmp_path / "local_agents.json"
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(path))
return path
class TestCli:
@respx.mock
def test_cli_define_happy(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
self,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
_isolated_local_agents: "Path",
) -> None:
"""cli_define_happy [happy]: argv → 201 mock → stdout confirmation."""
"""cli_define_happy [happy]: argv → 201 mock → stdout confirmation;
local index updated with the new entry (v0.8.0 hook).
"""
from ratatoskr.local_agents import load_local_agents
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.post("https://w.example/agents/define").mock(
@@ -335,12 +354,24 @@ class TestCli:
out = capsys.readouterr()
assert rc == 0
assert out.out.strip() == "defined ratatoskr:wizard (qwen3.6-35-a3b)"
# v0.8.0: local index now has the new entry.
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:wizard"
assert entries[0].model == "qwen3.6-35-a3b"
@respx.mock
def test_cli_patch_happy(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
self,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
_isolated_local_agents: "Path",
) -> None:
"""cli_patch_happy [happy]: argv → 200 mock → stdout confirmation."""
"""cli_patch_happy [happy]: argv → 200 mock → stdout confirmation;
local index refreshed with the post-patch state.
"""
from ratatoskr.local_agents import load_local_agents
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
@@ -350,12 +381,34 @@ class TestCli:
out = capsys.readouterr()
assert rc == 0
assert out.out.strip() == "patched ratatoskr:wizard"
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:wizard"
@respx.mock
def test_cli_delete_happy(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
self,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
_isolated_local_agents: "Path",
) -> None:
"""cli_delete_happy [happy]: argv → 204 mock → stdout confirmation."""
"""cli_delete_happy [happy]: argv → 204 mock → stdout confirmation;
local index entry removed (v0.8.0 hook).
"""
from ratatoskr.local_agents import (
LocalAgentEntry,
add_local_agent,
load_local_agents,
)
# Pre-populate so we can verify removal.
add_local_agent(LocalAgentEntry(
agent_id="ratatoskr:wizard",
agent_name="wizard",
model="m",
description="d",
defined_at="t",
))
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.delete("https://w.example/agents/ratatoskr:wizard").mock(
@@ -365,6 +418,7 @@ class TestCli:
out = capsys.readouterr()
assert rc == 0
assert out.out.strip() == "deleted ratatoskr:wizard"
assert load_local_agents() == []
def test_cli_missing_auth(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
+824 -183
View File
File diff suppressed because it is too large Load Diff
Generated
+1 -1
View File
@@ -968,7 +968,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.7.1"
version = "0.14.2"
source = { editable = "." }
dependencies = [
{ name = "httpx" },