Compare commits

...

5 Commits

Author SHA1 Message Date
vh 9918c10acf fix(tui): coalesce thinking deltas on \n (v0.7.1)
Operator: "thinking tokens seem to be split by token — each on a
newline, is that correct? We don't want that."

Root cause: v0.6.5 wrote each Thinking SSE delta as its own
`thinking_log.write(event.content)` call. Worldtree emits Thinking
events at token granularity (per-token or per-few-tokens), so EACH
token became its own RichLog line — visually choppy, one short
fragment per visual row. Wrong UX.

## Fix: coalesce-on-newline

Thinking deltas accumulate in `TuiPresenterState.thinking_chunk_buffer`
(new str field). On each Thinking event:

  1. Append delta content to buffer.
  2. Flush every COMPLETE line (chars before each `\n`) as one
     thinking_log.write(line) call.
  3. Leave the post-final-`\n` tail in the buffer for the next delta.

On any non-thinking event (run close):
  1. Flush remaining buffer tail (if any) as one final line.
  2. Write Rule(end).

Empty lines (blank paragraph separators in the model's `\n\n` flow)
are skipped — they'd render as no-content RichLog entries which
just add vertical noise. Natural paragraph breaks become single
visible lines; multi-paragraph thinking renders top-to-bottom.

## Verified live (tier-3 smoke against personal Worldtree)

Defined a `thinky-smoke` agent via `python -m ratatoskr.tier3 define`,
asked "What is 12 times 13?". Thinking pane rendered with natural
paragraph chunks:

  ── turn N · thinking #1 start ──
  Thinking Process:
  1.  **Analyze the Request:** The user wants to know the result of $12 \times 13$.
  2.  **Calculate:**
      *   Method 1: Standard multiplication.
          $$12 \times 10 = 120$$
          $$12 \times 3 = 36$$
          $$120 + 36 = 156$$
      *   Method 2: $(10 + 2)(10 + 3) = 100 + 30 + 20 + 6 = 156$.
  ── turn N · thinking #1 end ──

Each line = one natural paragraph or list item. No per-token fragments.

## Edge cases noted

- Long-running thinking with NO `\n` at all stays buffered until run
  close → operator sees nothing until close. Possible follow-up: add
  a length-threshold flush (e.g., > 500 chars → flush at the last
  space). For now this is acceptable; thinking content typically has
  `\n` breaks every few sentences.
- Empty deltas (`""`) are ignored implicitly — no buffer growth, no
  flush.
- `\n` at the very start of a delta flushes whatever was buffered
  before, then leaves the empty post-`\n` tail (empty string) in the
  buffer, which doesn't show up as an empty line because of the
  `if line:` guard.

## Contract amendment

docs/contracts/issues/13.contract.md INV-022 amended for v0.7.1
coalesce semantics. Drift-check clean.

## Tests

265/265 GREEN; ruff clean. Two updated tests:

- `test_thinking_streams_into_thinking_log` → renamed
  `test_thinking_coalesces_until_newline`: 3 token-shaped deltas
  with no `\n` → only Rule(start) writes, buffer holds accumulated.
- NEW `test_thinking_flushes_on_newline`: delta carrying `\n` →
  Rule(start) + accumulated line + clear buffer.
- `test_thinking_closes_to_thinking_log`: 2 deltas "a", "b" +
  close → Rule(start) + tail-flush "ab" + Rule(end) = 3 writes
  (was 4 with per-delta).

Patch bump (v0.7.0 → v0.7.1) — internal presenter routing change;
no public-API or layout change.
2026-05-24 20:39:55 -07:00
vh c086ae2b32 feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0)
Issue #15. Worldtree Phase 2.0 ships Tier 3 (consumer-defined)
agents at `<user_id>:<agent_name>`; ratatoskr now exposes their
lifecycle via a dedicated module + CLI tool. The picker handles
the colon-containing agent_id generically (per issue #8 out-of-
scope clause); session creation works unchanged. What was missing
was a way to DEFINE / PATCH / DELETE these agents from ratatoskr
itself — operators previously had to curl the API directly.

## Public surface (ratatoskr.tier3)

  Tier3AgentInfo (frozen dataclass)
  define_agent (client, *, agent_name, system_prompt, model) → Info
  patch_agent  (client, agent_id, *, system_prompt?, model?) → Info
  delete_agent (client, agent_id) → None

  Tier3QuotaExceeded     — 429 agent_quota_exceeded (50-agent cap)
  Tier3UserIdUnsupported — 403 tier3_user_id_unsupported
  Tier3FieldNotMutable   — 422 field_not_mutable (PATCH)
  Tier3LayerDeferred     — 422 layer_deferred (define, defense-only)
  Tier3AgentNotFound     — 404
  SessionApiFailed (reused) — all other non-2xx

Caller-owned httpx.AsyncClient posture (same as ratatoskr.sessions).
Module is standalone — does NOT import sessions/sse_client/tui/cli
beyond reusing the USER_AGENT constant from cli.

## CLI (python -m ratatoskr.tier3 <subcommand>)

  define --name <slug> --system-prompt <str> --model <id>
  patch  <agent_id> [--system-prompt <str>] [--model <id>]
  delete <agent_id>

Auth resolution mirrors ratatoskr.cli verbatim — --api-key flag >
$WORLDTREE_API_KEY > exit 11. Server URL via --server >
$WORLDTREE_API_URL > http://localhost:8000. Exit codes follow the
cli.py matrix: 0 / 10 (usage) / 11 (auth) / 20 (api-failure) / 21
(network).

## Real-world finding from live smoke

Tier-3 agents do NOT appear in `GET /agents` — the public list
filters them out. The picker won't surface tier-3 agents; operators
bypass it via `ratatoskr --send "..." --new --agent ratatoskr:<n>`
directly. This contradicts the contract's acceptance assumption
("the new tier-3 agent should appear in the list") — caught at
smoke time. The picker integration was hopeful; the real shape is
"you know your tier-3 agent_id because you defined it." Adding a
ratatoskr-side `tier3 list` subcommand would need a Worldtree
endpoint that doesn't exist today; surfacing to worldtree-dev as a
followup.

## Live lifecycle smoke (personal Worldtree v0.16.2)

  $ python -m ratatoskr.tier3 define --name smoke-tier3 \
      --system-prompt "..." --model qwen3.6-35-a3b
  → defined ratatoskr:smoke-tier3 (qwen3.6-35-a3b)

  $ ratatoskr --send "hello via tier-3" --new --agent ratatoskr:smoke-tier3
  → [done] turn_id=286 model=qwen3.6-35-a3b duration=14.2s
    usage 44 in → 390 out (434 total, 0 cached)

  $ python -m ratatoskr.tier3 delete ratatoskr:smoke-tier3
  → deleted ratatoskr:smoke-tier3

  $ python -m ratatoskr.tier3 delete ratatoskr:smoke-tier3
  → [agent_not_found] ratatoskr:smoke-tier3 (exit 20)

The colon-containing agent_id flowed transparently through
ratatoskr.sessions.create_session, the SSE stream's text +
worker_phase + done events all rendered correctly, and the
ratatoskr.sessions module needed zero changes.

## Contract

docs/contracts/issues/15.contract.md — new module spec; drift-check
clean. Acceptance criterion about "appears in GET /agents" should be
amended in a follow-up to reflect the empirical finding.

## Tests

+26 tests (264 total GREEN, was 238). Covers all error paths via
respx mocking — quota, user_id, layer_deferred, field_not_mutable,
404, 5xx — plus CLI happy + error paths. ruff clean.

Minor bump (v0.6.5 → v0.7.0) per SemVer etiquette: new public
module + CLI surface; new caller-visible behavior.
2026-05-24 20:31:10 -07:00
vh d3569904bc refactor(tui): thinking streams into whole pane (v0.6.5)
Operator: "Why does the thinking scroll a little section at the
bottom of the thinking pane instead of scrolling the whole pane?"

Root cause: v0.6.1's thinking-current Static was docked to the
bottom of the Thinking pane and rendered the last 200 chars of
streaming content. As deltas arrived, the displayed 200-char tail
shifted — old text fell off the left, new text appeared on the
right — visually reading as "a little section scrolling at the
bottom" while the larger thinking-log RichLog above showed only
the previous run's closed content (or nothing on first turn).

## Fix: stream directly into thinking-log

The Static is gone. Thinking deltas now write straight to the
`thinking-log` RichLog (one delta = one line in the scrollable
log). The whole pane scrolls naturally as content arrives —
operator can switch to Ctrl+3 and see streaming content fill
the pane top-to-bottom.

Routing pattern:

  First Thinking delta of run:
    → write Rule(title="turn N · thinking #K start") to thinking_log
    → write delta content as a line
    → set thinking_open = True
  Subsequent Thinking deltas:
    → write delta content as a line
  Non-thinking event (closes the run):
    → write Rule(title="turn N · thinking #K end") to thinking_log
    → reset thinking_open

The Rule(start) at the top of an in-progress run is now the
"thinking is happening" indicator. No more separate live-preview
widget required.

## Trade-off: no markdown re-render

Pre-v0.6.5 closed runs got a Markdown(full_content) render between
the start/end Rules. v0.6.5 drops that — the streamed deltas ARE
the content; re-rendering as Markdown would either need to wait
for run-end (no streaming) OR re-render incrementally per delta
(bad UX). Streaming wins for "live observability" framing.

The downside: if model thinking has Markdown structure (lists,
code), it renders as raw text. Acceptable per operator's "stream
in line" framing.

## Removed widgets

- `Static#thinking-current` (right column / Thinking pane bottom)
- `TuiPresenterState.render` no longer takes a `thinking_widget` param
- `TuiPresenterState.thinking_buffer` field dropped (no accumulation)
- `_stream_turn_worker` no longer queries `#thinking-current`
- `on_mount` no longer hides `#thinking-current`
- DEFAULT_CSS `#thinking-current` block removed

## Contract amendment

INV-022 amended: thinking now streams as raw delta lines, not
Markdown-rendered on close. INV-024 amended: thinking-current
Static removed entirely (was relocated v0.6.1, removed v0.6.5).
Drift-check clean.

## Tests

238/238 GREEN (was 241 — 3 obsolete widget tests deleted:
test_thinking_widget_truncation, test_thinking_widget_visibility_lifecycle,
test_terminal_events_belt_and_braces_widget_cleanup). 5 routing tests
rewritten for the new streaming shape (test_thinking_streams_into_thinking_log,
test_thinking_closes_to_thinking_log, test_multiple_thinking_runs_...,
test_render_exception_fallback, test_cancelled_mid_thinking_closes,
test_left_column_content_only).

ruff clean. Manual injection test confirms routing: Rule(start) +
delta lines write to thinking_log; transcript untouched.

Patch bump (v0.6.4 → v0.6.5) — internal restructure within Thinking
pane; presenter signature narrowed; no caller-visible public API
change (RatatoskrApp + AgentPickerApp surfaces identical).
2026-05-24 19:00:58 -07:00
vh 82437bd4b9 style(tui): picker highlighted item → Aurora blue (v0.6.4)
Operator request: the agent picker's highlighted selection should
get the brand-color treatment — Aurora blue background — instead of
the v0.6.1 dark-30 muted bg.

## Two-fix landing

**The selector**: v0.6.1's `ListView > ListItem.--highlight` (double
dash) never actually matched. Textual's class is `-highlight` (single
dash). The v0.6.1 "fix" silently fell through to Textual's defaults,
which happened to be invisible because $block-cursor-background was
configured but the selector path didn't reach the rendered widget.

Probed live: `item.classes = frozenset({'-highlight'})`. Selector
corrected, plus dropped the `>` combinator since Textual's internal
DOM puts wrappers between `ListView` and `ListItem`.

**The background**: explicit `#agent-list:focus ListItem.-highlight
{ background: $primary }` — Aurora blue (#6388D8) for the focused-
list highlight band.

**The contrast**: bright-blue id-line text on Aurora-blue background
would be unreadable. Highlighted-state child overrides:

  .agent-id-line  →  $au-bright-white (#cce7ec) + bold
  .agent-desc     →  $au-bright-80    (#b3cbcf)

Non-highlighted items keep their default colors (bright-blue id +
bright-70 desc on App bg).

## Verified live

13 fill rects of `#6388d8` in the picker SVG export (was 0
before this commit). Other Australis brand colors intact:
chrome surface #373b46, dark-50 #6e7882, dark-30 #414751.

## Tests

241/241 GREEN; ruff clean. No test rewrites needed — picker tests
assert structure (widget tree, key bindings), not colors.

Patch bump (v0.6.3 → v0.6.4) — cosmetic; no public-API change.
2026-05-24 18:33:53 -07:00
vh ac690c11d5 style(tui): restore Australis, $background → pure black (v0.6.3)
Reverts v0.6.2's over-correction. The operator clarified: the
complaint was specifically about the APP BACKGROUND going from
black to a shade of blue, not about the cumulative cast across
all Australis dark surfaces. v0.6.2 globally neutralized Ice + Sea
darks → too far.

## v0.6.3 = v0.6.1 palette + $background override only

Restored verbatim from v0.6.1:

  $foreground       #a9bcc3 (Ice white)
  $surface          #373b46 (Sea bright-black, chrome bg)
  $panel            #414751 (Sea dark 30, borders)
  $au-dark-30..60   Australis Sea palette
  $au-bright-70/80  Australis Sea brights
  $au-bright-white  #cce7ec (Ice highlight)
  Aurora accents    bright-blue/cyan/green — verbatim
  Dawn accents      red/yellow — verbatim
  _AU_DEMOTED       #86929d (Sea dark 60)
  _AU_DEMOTED_FAINT #6e7882 (Sea dark 50)

ONE deviation from Australis spec:

  $background  #222531 (Ice black) → #000000 (pure black)

Rationale: Ice black is RGB(34, 37, 49) — blue +44% over red. At
App-wide scale (the dominant fill across the entire screen) the
cumulative cast reads as "the app is blue" even though no single
rect is in the conventional-blue range. Other dark surfaces are
smaller chrome bands where the cool lean reads as character not
background; only $background gets the override.

## What stayed Australis

Every cosmetic element where the operator hasn't pushed back:
identity widget (Aurora bright-blue), pane-name (Aurora bright-cyan),
[done]/[error]/[cancelled] labels (Aurora green / Dawn red/yellow),
focus borders (Aurora blue / accent cyan), demoted telemetry text
(Sea dark-60), placeholder lines (Sea dark-50), Header/Footer chrome
(Sea bright-black bg + Ice white-blue fg), separators (Sea dark-30).

Brand fidelity preserved; only the dominant background surface
neutralized.

## Tests + smoke

241/241 GREEN; ruff clean. Live screenshot export:
- $background = #000000 (230 fill rects — dominant surface)
- $surface = #373b46 (29 fill rects — Australis Sea bright-black)
- Sea panels + dark-50 still present in chrome
- Aurora #6388D8 still primary

Patch bump (v0.6.2 → v0.6.3) — cosmetic refinement; no public-API
change.
2026-05-24 18:20:57 -07:00
9 changed files with 1396 additions and 316 deletions
+2 -2
View File
@@ -161,9 +161,9 @@ New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` w
- **INV-019** *(amended v0.6.0)*: Three TabPanes in the right column: `Tools` (id `tools-tab`, contains `#tools-log`) + `Debug` (id `debug-tab`, contains `#debug-log`) + `Thinking` (id `thinking-tab`, contains `#thinking-log`). Ctrl+1/Ctrl+2/Ctrl+3 activate respective tabs. `pane-name` Static reflects active tab name dynamically.
- **INV-020** *(amended v0.6.0)*: Render-exception fallback (INV-009) preserves routing per event class: `ToolStart` / `ToolResult``tools_log`; `Thinking``thinking_log`; `WorkerPhase` / `TextBoundary``debug_log`; everything else → `log`.
- **INV-021** *(new v0.6.0)*: `Text` events do NOT route to `log` per-delta. They accumulate into `TuiPresenterState.text_buffer` and update a single `current_text` Static (docked above the prompt). On terminal event (`Done`/`Error`/`Cancelled`), `current_text` is cleared and (raw mode) accumulated text or (non-raw) post-Done `Markdown(response)` is written to `log`. The pre-v0.6.0 per-token RichLog spam is retired.
- **INV-022** *(new v0.6.0)*: Closed thinking runs route to `thinking_log`, NOT `debug_log`. Each closed run writes three entries: `Rule(title=f"turn N · thinking #K start")`, `Markdown(content)`, `Rule(title=f"turn N · thinking #K end")` — the model's chain-of-thought is presented as rendered Markdown (model reasoning often has lists / code / structure) wrapped in operator-visible start/end markers. `thinking_run_index` increments per-run within a turn.
- **INV-022** *(amended v0.7.1)*: Thinking deltas COALESCE on `\n` boundaries before writing to `thinking_log`. The first delta of a run writes `Rule(title=f"turn N · thinking #K start")`; subsequent deltas accumulate in `TuiPresenterState.thinking_chunk_buffer`; whenever the buffer contains `\n`, the leading line(s) flush as RichLog entries (one entry per natural paragraph). The run closes on the next non-thinking event: any tail in the buffer flushes as a final line, then `Rule(title=f"turn N · thinking #K end")`. Pre-v0.7.1 per-delta-per-line caused token-spam (Worldtree emits thinking at token granularity); coalescing produces one log line per natural paragraph, not per token.
- **INV-023** *(new v0.6.0)*: Turn-ID header `Rule(title=f"turn N")` is written to all four log panes (`log`, `tools_log`, `debug_log`, `thinking_log`) by `_stream_turn_worker` on the first event of each turn — enables cross-pane visual correlation during multi-turn debugging.
- **INV-024** *(amended v0.6.1)*: `thinking-current` Static lives INSIDE the Thinking TabPane (docked bottom, below `thinking-log`) — co-located with closed thinking runs so the operator sees streaming + history in one pane. Pre-v0.6.1 it sat above the TabbedContent (right-column header) which created a top/bottom discontinuity; the co-located shape resolves that. Trade-off: live thinking is now visible only when the Thinking tab is active (Ctrl+3). Prefix `thinking… ` self-identifies the widget contents.
- **INV-024** *(amended v0.6.5)*: `thinking-current` Static REMOVED. v0.6.1 placed it inside the Thinking pane (docked bottom); operators reported the bottom-docked Static "scrolling a little section at the bottom" (its 200-char tail acting as a scroll-window) instead of letting the whole pane scroll. v0.6.5 deletes the Static entirely and streams Thinking deltas directly into `thinking_log` (the scrollable RichLog) — the whole pane scrolls naturally as content arrives. The Rule(start) at the first delta of a run is now the live "thinking is happening" indicator.
## TESTS (additions / changes to test_tui.py)
+290
View File
@@ -0,0 +1,290 @@
---
contract_version: "2.1"
target_module: "ratatoskr.tier3"
scope: "New module `ratatoskr.tier3` exposing Worldtree's Tier 3 (consumer-defined) agent lifecycle: `define_agent` (POST /agents/define), `patch_agent` (PATCH /agents/<id>), `delete_agent` (DELETE /agents/<id>), plus `Tier3AgentInfo` frozen dataclass. Plus a thin CLI entry point (`python -m ratatoskr.tier3 <define|patch|delete>`) that mirrors `ratatoskr.cli`'s env-var posture (`WORLDTREE_API_URL`, `WORLDTREE_API_KEY`). Convention-aligned with `ratatoskr.sessions` (issue #2): caller-owned httpx.AsyncClient, no Worldtree imports, response parsing into frozen dataclass, exception `.body` truncated to `[:1024]`. Picker stays generic — agents with `:` in agent_id show in the list like any other per issue #8's out-of-scope clause. Goal: ratatoskr operators can define, mutate, and delete Tier 3 agents from the command line, then exercise the full session flow against them to observe how Tier 3 agent_ids (colon-containing) flow through the picker / session-create / SSE stream."
depends_on:
- "httpx"
used_by: []
language: "python"
complexity: "low"
estimated_loc: 250
confidence: 0.9
assumptions:
- "Tier 3 endpoints land at the same `WORLDTREE_API_URL` as the rest of the Conversation API — no separate hostname / port. Auth via the same bearer key. The caller's user_id is derived server-side from the API key's owner; the agent's `agent_id` is constructed as `<auth_user_id>:<agent_name>`. Live probe against personal Worldtree (2026-05-25) confirmed: POST with `{agent_name: 'smoke-test', ...}` and `Authorization: Bearer <key>` returned `agent_id=ratatoskr:smoke-test`, `user_id=ratatoskr`."
- "Per Worldtree spec §2576-2750: `agent_name` is a strict slug `[a-z][a-z0-9-]{2,63}` and immutable after definition. `user_id` is derived from the auth, must be slug-safe (`[a-z][a-z0-9-]{2,63}` per Phase 2.0 gate). PATCH accepts ONLY `system_prompt` and/or `model`; any other key (including the immutable `agent_name`, `user_id`, or layer fields `persona`/`motivational`/`valence`/`memory` — even with `null` value) returns 422 `field_not_mutable` BEFORE the DB lookup."
- "**Layer fields are explicitly null** on define. Phase 2.0 ships baseline addressing + ownership + lifecycle only; `persona` / `motivational` / `valence` / `memory` are schema-reserved. Non-null on these → 422 `layer_deferred`. The module's `define_agent` does NOT expose these as parameters at all — sending them would require an amendment when a future Phase enables them."
- "**`model` field is a provider model ID, not a profile alias.** Live probe found: `model='default'` (an llm_profiles profile name) returns 422 `model_not_available`; `model='qwen3.6-35-a3b'` (an actual provider model ID) returns 201. The CLI / module take the string verbatim and pass through — validation is server-side. Operators discover valid IDs via the model `metadata` on existing sessions or out-of-band."
- "**Quota: 50 Tier 3 agents per Heimdall key.** 51st define → 429 `agent_quota_exceeded` with `Retry-After: 0`. The module raises `Tier3QuotaExceeded(retry_after=0)` — the retry_after field captures the header value verbatim for forward-compat if Worldtree later returns a non-zero throttle."
- "**Key-revocation cascade is server-side.** When an API key is revoked (`DELETE /admin/keys/{key_id}`), every Tier 3 agent with `owner_key_hash` equal to the revoked key's hash is soft-deleted in the same SQL transaction. Active sessions on those agents return 401 `auth_revoked` on next message. The ratatoskr module doesn't track or simulate this — operators discover it via runtime 401s and the admin-side audit log."
- "**Picker integration is implicit** — no changes to `ratatoskr.tui.AgentPickerApp` for this issue. Tier 3 agents appear in `GET /agents` if defined and the picker's existing format `{agent_id} · {name} — {description}` renders the colon-containing agent_id without special-casing. Per issue #8 out-of-scope clause, ratatoskr does not visually distinguish Tier 1 vs Tier 3 in the picker — same UX surface."
- "**Session-create with colon-containing agent_id works unchanged.** Issue #5 already routes `end_user_id` into the POST /sessions body, which Tier 3 session-create requires from Phase 2.0 (per spec §2649-2664). No `ratatoskr.sessions` change needed."
- "**CLI uses argparse with subparsers** (define / patch / delete). The subparsers entry point lives at `python -m ratatoskr.tier3` via `__main__.py`. Output on success: prints a one-line summary (`defined ratatoskr:wizard (qwen3.6-35-a3b)` / `patched ratatoskr:wizard` / `deleted ratatoskr:wizard`). Output on error: `[<error_code>] <message>` to stderr + non-zero exit. Exit codes mirror `ratatoskr.cli`: 0 happy / 10 usage / 11 auth / 20 api-failure / 21 network."
- "**No `list` subcommand in v1.** A `tier3 list` operation would have to filter `GET /agents` by prefix-matching the caller's user_id, but that prefix isn't exposed in the response — only the agent_id is, and you'd have to introspect the auth's user_id. Operators discover their own Tier 3 agents by reading the `GET /agents` list (which the picker already surfaces) and looking for `<their-user-id>:*` entries. Add `list` in a follow-up if operators report friction."
- "**Module is standalone**: does NOT import or interact with `ratatoskr.sessions` / `ratatoskr.sse_client` / `ratatoskr.tui` / `ratatoskr.cli` beyond reusing the `USER_AGENT` constant from `ratatoskr.cli`. Cross-module use is one-way (cli supplies the user-agent string; tier3 does not import sessions). This keeps the module surface minimal and testable in isolation."
- "**The CLI's `python -m ratatoskr.tier3` entry point uses sys.argv handling that mirrors `ratatoskr.cli`** — a top-level `main(argv: list[str] | None = None) -> int` function that argparse-dispatches to subcommand handlers. Each subcommand handler is an async coroutine wrapped by `asyncio.run(...)`. Auth resolution: `--api-key` flag > `$WORLDTREE_API_KEY` env > `_AuthError` (exit 11). Server URL: `--server` > `$WORLDTREE_API_URL` > default `http://localhost:8000` (same default as `ratatoskr.cli`)."
- "**Tests use `respx` for HTTP mocking** (same pattern as `tests/test_sessions.py`). New test file: `tests/test_tier3.py`. Cover all success + error response codes per the ERROR_ROUTING matrix below. No live network in unit tests — the live smoke is in the acceptance criteria, not the unit tests."
open_questions:
- "Should `define_agent` accept an optional `bifrost` parameter for Bifrost-bound Tier 3 sessions? The spec §2658 shows `bifrost` as a session-create field (not define-time). Draft: no — Bifrost binding is per-session; if a Tier 3 agent needs Bifrost on every session, that's an orthogonal feature on POST /sessions, not POST /agents/define. Issue #5's `--end-user-id` already covers the session-create-side parameters."
- "Should the CLI also offer `--end-user-id` for sessions created via tier3 + ratatoskr-cli composition? Draft: no — once an agent is defined, operators use the main `ratatoskr --new --agent <id> --end-user-id <eid>` flow; tier3 CLI is define/patch/delete only."
- "Should `delete_agent` support a `--force` flag for 'really delete even if active sessions exist'? Per spec §2634-2639, `DELETE` already cancels active sessions and revokes the per-resource scope grant on the owner — there's no soft fail. Draft: no — the spec's hard-delete-with-cascade behavior is the right shape; ratatoskr doesn't need to wrap it."
prd:
issue: 15
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/15"
body_sha256_16: "03367d7b451ab17f"
lock_in_comment_id: null
lock_in_sha256_16: null
lock_in_at: null
pinned_at: "2026-05-25T03:21:38+00:00"
dependencies:
- issue: 2
path: "src/ratatoskr/sessions.py"
reason: "Convention dependency, not a code dependency. Issue #2 (`ratatoskr.sessions`) is the posture template: caller-owned httpx client, async-native, no Worldtree imports, response-parsing into frozen dataclasses, exception body truncation to [:1024]. `ratatoskr.tier3` follows the same shape verbatim."
- issue: 3
path: "src/ratatoskr/cli.py"
reason: "Convention dependency only. `ratatoskr.tier3.__main__` mirrors `ratatoskr.cli`'s argparse + env-fallback + exit-code shape. Imports `USER_AGENT` from `ratatoskr.cli` so outbound HTTP carries the same identity string."
---
# Tier 3 — Consumer-defined agent lifecycle module
## Context
Worldtree's Tier 3 (Phase 2.0, spec §2576-2750) lets the consumer define their own agents at `<user_id>:<agent_name>`. The agent's `user_id` is the auth's user identity (derived from the API key's owner); the `agent_name` is supplied at define-time. The lifecycle is owner-only — only the key that defined an agent can patch / delete it (modulo the key-revocation cascade).
`ratatoskr.tier3` exposes this lifecycle as a Python module + small CLI tool. Picker integration is implicit (Tier 3 agents already appear in `GET /agents` per issue #8). Session-create works unchanged through `ratatoskr.sessions.create_session` since the colon-containing agent_id is opaque to that layer.
## Public surface
```python
@dataclass(frozen=True)
class Tier3AgentInfo:
"""Worldtree Tier 3 agent envelope returned by define / patch."""
agent_id: str # f"{user_id}:{agent_name}"
user_id: str
agent_name: str
system_prompt: str
model: str
created_at: str # ISO 8601 with offset
updated_at: str # ISO 8601 with offset
async def define_agent(
client: httpx.AsyncClient,
*,
agent_name: str,
system_prompt: str,
model: str,
) -> Tier3AgentInfo:
"""POST /agents/define → 201 with Tier3AgentInfo. See FN define_agent."""
async def patch_agent(
client: httpx.AsyncClient,
agent_id: str,
*,
system_prompt: str | None = None,
model: str | None = None,
) -> Tier3AgentInfo:
"""PATCH /agents/<id> → 200 with updated Tier3AgentInfo. See FN patch_agent."""
async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None:
"""DELETE /agents/<id> → 204. See FN delete_agent."""
```
## Exception classes
```python
class Tier3QuotaExceeded(Exception):
"""429 agent_quota_exceeded — 50-agent cap reached on the Heimdall key."""
def __init__(self, *, retry_after: int) -> None: ...
retry_after: int
class Tier3UserIdUnsupported(Exception):
"""403 tier3_user_id_unsupported — auth's user_id not slug-safe."""
class Tier3FieldNotMutable(Exception):
"""422 field_not_mutable — PATCH carrying an immutable key."""
def __init__(self, *, field: str | None) -> None: ...
field: str | None
class Tier3LayerDeferred(Exception):
"""422 layer_deferred — define carrying non-null layer field."""
def __init__(self, *, field: str | None) -> None: ...
field: str | None
class Tier3AgentNotFound(Exception):
"""404 — patch/delete on non-existent agent."""
def __init__(self, *, agent_id: str) -> None: ...
agent_id: str
# Reused from ratatoskr.sessions (one-way import — sessions doesn't depend on tier3):
# SessionApiFailed(status, body) for all other non-2xx responses.
```
## Functions
### FN define_agent
```
FN define_agent(
client: httpx.AsyncClient,
*, agent_name: str, system_prompt: str, model: str,
) -> Tier3AgentInfo
BRIEF: POST /agents/define → 201 with Tier3AgentInfo.
PRE-001: agent_name matches `[a-z][a-z0-9-]{2,63}` (slug guard — client-side
assert; the server enforces too, but this prevents wire round-trip
for trivially-bad input).
PRE-002: system_prompt is non-empty.
PRE-003: model is non-empty.
STEPS:
1. assert PRE-001/002/003.
2. body = {
"agent_name": agent_name,
"system_prompt": system_prompt,
"model": model,
}
3. resp = await client.post("/agents/define", json=body)
4. ROUTE response status:
201 → parse body into Tier3AgentInfo, return.
422 → inspect error_code:
layer_deferred → raise Tier3LayerDeferred(field=err.get("field"))
(others) → raise SessionApiFailed(status=422, body=resp.content)
403 + tier3_user_id_unsupported → raise Tier3UserIdUnsupported
429 → raise Tier3QuotaExceeded(retry_after=int(resp.headers.get("Retry-After", 0)))
other → raise SessionApiFailed(status, body)
POST-001: returned Tier3AgentInfo has agent_id of shape "<user_id>:<agent_name>".
```
### FN patch_agent
```
FN patch_agent(
client: httpx.AsyncClient, agent_id: str,
*, system_prompt: str | None = None, model: str | None = None,
) -> Tier3AgentInfo
BRIEF: PATCH /agents/<id> → 200 with updated Tier3AgentInfo.
PRE-001: agent_id contains `:` (Tier 3 shape).
PRE-002: at least one of system_prompt or model is non-None (no-op patches
are still server-accepted but client-side assert avoids the round-trip).
STEPS:
1. assert PRE-001/002.
2. body = {}; if system_prompt is not None: body["system_prompt"] = system_prompt;
if model is not None: body["model"] = model.
3. resp = await client.patch(f"/agents/{agent_id}", json=body)
4. ROUTE response status:
200 → parse, return.
404 → raise Tier3AgentNotFound(agent_id=agent_id)
422 + field_not_mutable → raise Tier3FieldNotMutable(field=err.get("field"))
other → raise SessionApiFailed(status, body)
```
### FN delete_agent
```
FN delete_agent(client: httpx.AsyncClient, agent_id: str) -> None
BRIEF: DELETE /agents/<id> → 204.
PRE-001: agent_id contains `:` (Tier 3 shape).
STEPS:
1. assert PRE-001.
2. resp = await client.delete(f"/agents/{agent_id}")
3. ROUTE response status:
204 → return None.
404 → raise Tier3AgentNotFound(agent_id=agent_id)
other → raise SessionApiFailed(status, body)
```
## CLI surface (`python -m ratatoskr.tier3`)
```
$ python -m ratatoskr.tier3 define --name wizard \
--system-prompt "You are a guided-elicitation wizard..." \
--model qwen3.6-35-a3b
defined ratatoskr:wizard (qwen3.6-35-a3b)
$ python -m ratatoskr.tier3 patch ratatoskr:wizard --system-prompt "New prompt"
patched ratatoskr:wizard
$ python -m ratatoskr.tier3 delete ratatoskr:wizard
deleted ratatoskr:wizard
```
Auth + server URL: same env-var fallback as `ratatoskr.cli`. Exit codes: 0 / 10 (usage) / 11 (auth) / 20 (api-failure) / 21 (network).
## Invariants
- **INV-001**: `define_agent` request body carries exactly `{agent_name, system_prompt, model}` — no layer fields, no `bifrost`, no `metadata`. Phase 2.0 baseline shape only.
- **INV-002**: `patch_agent` request body carries ONLY `system_prompt` and/or `model` — every other key is omitted. Server-side 422 `field_not_mutable` is the safety net; client-side body-construction is the first line.
- **INV-003**: `delete_agent` is fire-and-confirm — no body, no retry, no soft-delete. Cascade handling is server-side; ratatoskr doesn't track it.
- **INV-004**: All exceptions carry a `[:1024]` body cap (when applicable) per the issue #2 convention.
- **INV-005**: CLI auth resolution mirrors `ratatoskr.cli`: `--api-key` flag > `$WORLDTREE_API_KEY` > exit 11.
- **INV-006**: CLI server URL resolution mirrors `ratatoskr.cli`: `--server` > `$WORLDTREE_API_URL` > `http://localhost:8000`.
- **INV-007**: Module never imports `ratatoskr.sessions` / `ratatoskr.sse_client` / `ratatoskr.tui` (one-way: only `cli.USER_AGENT` is imported, and only by `__main__.py` for the outbound User-Agent header).
- **INV-008**: All HTTP through caller-owned `httpx.AsyncClient` — module never constructs its own client. (`__main__` constructs one for the CLI entry point per ratatoskr.cli's pattern.)
## TESTS (tests/test_tier3.py — new file)
```
- test_define_happy: 201 + full response shape → Tier3AgentInfo populated.
- test_define_quota_exceeded: 429 + Retry-After header → Tier3QuotaExceeded(retry_after=N).
- test_define_user_id_unsupported: 403 tier3_user_id_unsupported → Tier3UserIdUnsupported.
- test_define_layer_deferred_persona: 422 layer_deferred → Tier3LayerDeferred (would only fire if the body sent a layer field; the module never sends one, so this asserts server-side defense but reflecting a 422 we don't actually generate. Test exercises the response path, not the request).
- test_define_bad_slug: PRE-001 assertion fires before HTTP for agent_name="X" (uppercase) or "ab" (too short).
- test_define_empty_prompt: PRE-002 assertion fires for empty system_prompt.
- test_define_other_5xx: 503 → SessionApiFailed(status=503).
- test_patch_happy_both_fields: 200 + updated body → Tier3AgentInfo.
- test_patch_happy_single_field: 200 with only system_prompt set; body omits model.
- test_patch_field_not_mutable: 422 field_not_mutable → Tier3FieldNotMutable.
- test_patch_404: 404 → Tier3AgentNotFound(agent_id=...).
- test_patch_no_args: PRE-002 assertion fires (both None).
- test_patch_non_tier3_id: PRE-001 assertion fires for agent_id without `:`.
- test_delete_happy: 204 → returns None.
- test_delete_404: 404 → Tier3AgentNotFound.
- test_delete_non_tier3_id: PRE-001 assertion fires.
- test_delete_other_5xx: 500 → SessionApiFailed.
- test_cli_define_happy: argv → 201 mock → stdout="defined ratatoskr:wizard (qwen3.6-35-a3b)" + exit 0.
- test_cli_patch_happy: argv → 200 mock → stdout="patched ratatoskr:wizard" + exit 0.
- test_cli_delete_happy: argv → 204 mock → stdout="deleted ratatoskr:wizard" + exit 0.
- test_cli_missing_auth: no API key → stderr "[auth_error]" + exit 11.
- test_cli_api_failed: 500 mock → stderr "[api_failed]" + exit 20.
```
## ERROR_ROUTING (module + CLI)
| HTTP shape | error_code | Exception (module) | CLI label | Exit |
|---|---|---|---|---|
| 201 / 200 / 204 | — | (none — happy) | one-line confirmation on stdout | 0 |
| 429 | agent_quota_exceeded | `Tier3QuotaExceeded(retry_after=N)` | `[quota_exceeded] retry_after=N` | 20 |
| 403 | tier3_user_id_unsupported | `Tier3UserIdUnsupported` | `[user_id_unsupported]` | 20 |
| 404 | — | `Tier3AgentNotFound(agent_id=...)` | `[agent_not_found] <id>` | 20 |
| 422 | field_not_mutable | `Tier3FieldNotMutable(field=...)` | `[field_not_mutable] field=...` | 20 |
| 422 | layer_deferred | `Tier3LayerDeferred(field=...)` | `[layer_deferred] field=...` | 20 |
| any other non-2xx | — | `SessionApiFailed(status, body)` | `[api_failed] status=N body=...` | 20 |
| httpx.ConnectError / ReadTimeout / TransportError | — | propagates | `[network_error] T: M` | 21 |
| PRE-001/002/003 assertion violation | — | `AssertionError` | `[usage_error] <msg>` | 10 |
| no auth | — | `_AuthError` (reused from cli) | `[auth_error] no API key` | 11 |
## Layout after this module lands
```
src/ratatoskr/
__init__.py
cli.py (existing, unchanged)
sessions.py (existing, unchanged)
sse_client.py (existing, unchanged)
tui.py (existing, unchanged)
tier3.py NEW
__main__/ (no change — main cli still entry-point)
# CLI invocation:
$ python -m ratatoskr.tier3 define --name wizard ...
$ python -m ratatoskr.tier3 patch ratatoskr:wizard ...
$ python -m ratatoskr.tier3 delete ratatoskr:wizard
```
+8 -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-24 (post-v0.6.2 neutral-dark palette):_
_As of 2026-05-25 (post-v0.7.1 thinking coalesce-by-newline):_
**Status: v0.6.2 shipped.** Nine core issues complete (`sse_client`
**Status: v0.7.1 shipped.** Ten 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,12 @@ 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.6.2 style(tui): neutralize Australis dark palette — bg no longer blue-tinted
- v0.7.1 fix(tui): coalesce thinking deltas on `\n` — no more per-token newlines
- `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)
- `ac690c1` style(tui): restore Australis palette, only $background → pure black (v0.6.3)
- `d845b20` style(tui): neutralize Australis dark palette (v0.6.2, reverted)
- `8463eb2` style(tui): kill remaining blue + thinking-current into pane (v0.6.1)
- `cfee89a` refactor(tui): streaming + turn headers + Thinking pane (v0.6.0)
- `7106af5` style(tui): UI polish pass — terminal label colors, placeholders (v0.5.1)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.6.2"
version = "0.7.1"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
+438
View File
@@ -0,0 +1,438 @@
"""Worldtree Tier 3 (consumer-defined) agent lifecycle client.
Implements docs/contracts/issues/15.contract.md. Caller-owned httpx.AsyncClient
posture (same as ratatoskr.sessions). Exposes three lifecycle operations:
- ``define_agent`` — POST /agents/define
- ``patch_agent`` — PATCH /agents/<id>
- ``delete_agent`` — DELETE /agents/<id>
Plus a frozen ``Tier3AgentInfo`` dataclass for the response shape. The picker
already handles colon-containing agent_ids generically (issue #8); session
creation works unchanged via ``ratatoskr.sessions.create_session``.
Spec reference: ``docs/conversation-api-spec.md`` §2576-2750 (Phase 2.0).
"""
from __future__ import annotations
import argparse
import re
from dataclasses import dataclass
import httpx
from ratatoskr.sessions import SessionApiFailed
# Per spec §2627: agent_name + user_id slugs are `[a-z][a-z0-9-]{2,63}`.
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{2,63}$")
@dataclass(frozen=True)
class Tier3AgentInfo:
"""Worldtree Tier 3 agent envelope returned by define / patch.
INV-001: ``agent_id`` is always shape ``"<user_id>:<agent_name>"`` —
constructed server-side from the auth's user_id + the supplied agent_name.
"""
agent_id: str
user_id: str
agent_name: str
system_prompt: str
model: str
created_at: str
updated_at: str
class Tier3QuotaExceeded(Exception):
"""Raised on HTTP 429 ``agent_quota_exceeded`` — 50-agent cap reached
on the Heimdall key. ``retry_after`` captures the Retry-After header
verbatim (defaults to 0 per spec §2675; forward-compat for non-zero)."""
def __init__(self, *, retry_after: int) -> None:
super().__init__(f"Tier 3 agent quota exceeded (retry_after={retry_after})")
self.retry_after = retry_after
class Tier3UserIdUnsupported(Exception):
"""Raised on HTTP 403 ``tier3_user_id_unsupported`` — auth's user_id
is not slug-safe per Phase 2.0 gate (spec §2626)."""
def __init__(self) -> None:
super().__init__("tier3 caller user_id is not slug-safe")
class Tier3FieldNotMutable(Exception):
"""Raised on HTTP 422 ``field_not_mutable`` — PATCH request body
carried a key that's immutable post-define (``agent_name``, ``user_id``,
or any layer field). Server rejects BEFORE the DB lookup (spec §2644)."""
def __init__(self, *, field: str | None) -> None:
super().__init__(f"field not mutable on Tier 3 patch: {field!r}")
self.field = field
class Tier3LayerDeferred(Exception):
"""Raised on HTTP 422 ``layer_deferred`` — define request carried a
non-null layer field (``persona`` / ``motivational`` / ``valence`` /
``memory``). Phase 2.0 ships baseline only; layers are schema-reserved.
Note: ``define_agent`` never sends layer fields, so this exception is
defense-against-server-side-changes / forward-compat. INV-001 in the
request body construction is the first line of defense.
"""
def __init__(self, *, field: str | None) -> None:
super().__init__(f"tier3 layer field deferred: {field!r}")
self.field = field
class Tier3AgentNotFound(Exception):
"""Raised on HTTP 404 — PATCH or DELETE on a non-existent agent_id
(spec §2634 + §2641)."""
def __init__(self, *, agent_id: str) -> None:
super().__init__(f"tier3 agent not found: {agent_id!r}")
self.agent_id = agent_id
def _extract_error_code(resp: httpx.Response) -> str | None:
"""Pluck the ``detail.error_code`` from a Worldtree error envelope.
Worldtree wraps API errors in ``{"detail": {"error_code": "...", ...}}``
per the spec. Returns None on shape mismatch (so callers fall through
to the generic ``SessionApiFailed`` branch).
"""
try:
body = resp.json()
except ValueError:
return None
detail = body.get("detail") if isinstance(body, dict) else None
if isinstance(detail, dict):
code = detail.get("error_code")
if isinstance(code, str):
return code
return None
def _extract_error_field(resp: httpx.Response) -> str | None:
"""Pluck ``detail.field`` from a Worldtree error envelope (used for
``field_not_mutable`` and ``layer_deferred`` to surface which field
triggered the rejection). Returns None on shape mismatch.
"""
try:
body = resp.json()
except ValueError:
return None
detail = body.get("detail") if isinstance(body, dict) else None
if isinstance(detail, dict):
field = detail.get("field")
if isinstance(field, str):
return field
return None
def _parse_tier3_agent_info(body: dict) -> Tier3AgentInfo:
"""Parse a Worldtree Tier 3 agent JSON body into the frozen dataclass."""
return Tier3AgentInfo(
agent_id=body["agent_id"],
user_id=body["user_id"],
agent_name=body["agent_name"],
system_prompt=body["system_prompt"],
model=body["model"],
created_at=body["created_at"],
updated_at=body["updated_at"],
)
async def define_agent(
client: httpx.AsyncClient,
*,
agent_name: str,
system_prompt: str,
model: str,
) -> Tier3AgentInfo:
"""POST /agents/define — create a Tier 3 agent.
See contract FN define_agent. Validates the agent_name slug client-side
before the network round-trip; server-side validation is the safety net.
Returns a fully populated Tier3AgentInfo on 201. Routes documented error
codes to typed exceptions; unknown non-2xx → SessionApiFailed.
"""
assert client is not None
assert _SLUG_RE.match(agent_name), (
f"agent_name must match [a-z][a-z0-9-]{{2,63}}: {agent_name!r}"
)
assert system_prompt, "system_prompt must be non-empty"
assert model, "model must be non-empty"
body = {
"agent_name": agent_name,
"system_prompt": system_prompt,
"model": model,
}
resp = await client.post("/agents/define", json=body)
if resp.status_code == 201:
return _parse_tier3_agent_info(resp.json())
if resp.status_code == 429:
# Spec §2675: 51st define → 429 with Retry-After: 0.
try:
retry_after = int(resp.headers.get("Retry-After", "0"))
except (TypeError, ValueError):
retry_after = 0
raise Tier3QuotaExceeded(retry_after=retry_after)
if resp.status_code == 403:
if _extract_error_code(resp) == "tier3_user_id_unsupported":
raise Tier3UserIdUnsupported()
if resp.status_code == 422:
code = _extract_error_code(resp)
if code == "layer_deferred":
raise Tier3LayerDeferred(field=_extract_error_field(resp))
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def patch_agent(
client: httpx.AsyncClient,
agent_id: str,
*,
system_prompt: str | None = None,
model: str | None = None,
) -> Tier3AgentInfo:
"""PATCH /agents/<id> — mutate system_prompt and/or model.
See contract FN patch_agent. Per spec §2641: only system_prompt + model
are mutable in Phase 2.0; any other key returns 422 field_not_mutable.
"""
assert client is not None
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
assert system_prompt is not None or model is not None, (
"patch requires at least one of system_prompt or model"
)
body: dict[str, str] = {}
if system_prompt is not None:
body["system_prompt"] = system_prompt
if model is not None:
body["model"] = model
resp = await client.patch(f"/agents/{agent_id}", json=body)
if resp.status_code == 200:
return _parse_tier3_agent_info(resp.json())
if resp.status_code == 404:
raise Tier3AgentNotFound(agent_id=agent_id)
if resp.status_code == 422:
code = _extract_error_code(resp)
if code == "field_not_mutable":
raise Tier3FieldNotMutable(field=_extract_error_field(resp))
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None:
"""DELETE /agents/<id> — owner hard-delete (cancels active sessions
server-side per spec §2636).
See contract FN delete_agent. 204 on success; 404 if the agent_id
doesn't exist; other non-2xx → SessionApiFailed.
"""
assert client is not None
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
resp = await client.delete(f"/agents/{agent_id}")
if resp.status_code == 204:
return
if resp.status_code == 404:
raise Tier3AgentNotFound(agent_id=agent_id)
raise SessionApiFailed(status=resp.status_code, body=resp.content)
# ---- CLI (`python -m ratatoskr.tier3 <subcommand>`) ------------------------
#
# Auth + server URL resolution mirrors ratatoskr.cli verbatim. Exit codes
# mirror ratatoskr.cli: 0 happy / 10 usage / 11 auth / 20 api-failure /
# 21 network. Outbound requests carry the same User-Agent string.
class _Tier3UsageError(Exception):
"""Argparse usage violation → exit 10."""
class _Tier3AuthError(Exception):
"""No API key resolvable → exit 11."""
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="python -m ratatoskr.tier3",
description="Worldtree Tier 3 (consumer-defined) agent lifecycle.",
)
parser.add_argument("--api-key", dest="api_key", default=None)
parser.add_argument("--server", dest="server", default=None)
sub = parser.add_subparsers(dest="cmd", required=True)
p_define = sub.add_parser("define", help="Create a Tier 3 agent.")
p_define.add_argument("--name", required=True, help="agent_name (slug).")
p_define.add_argument(
"--system-prompt", dest="system_prompt", required=True,
help="System prompt the agent ships with.",
)
p_define.add_argument(
"--model", required=True,
help="Provider model ID (NOT a profile alias; e.g., qwen3.6-35-a3b).",
)
p_patch = sub.add_parser("patch", help="Mutate system_prompt and/or model.")
p_patch.add_argument("agent_id", help='Full "<user_id>:<agent_name>" form.')
p_patch.add_argument("--system-prompt", dest="system_prompt", default=None)
p_patch.add_argument("--model", default=None)
p_delete = sub.add_parser("delete", help="Hard-delete a Tier 3 agent.")
p_delete.add_argument("agent_id", help='Full "<user_id>:<agent_name>" form.')
return parser
def _resolve_auth(ns: argparse.Namespace) -> tuple[str, str]:
"""Resolve API key + server URL with the same env-var fallback as cli.py."""
import os
api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or ""
if not api_key:
raise _Tier3AuthError("no API key (set --api-key or WORLDTREE_API_KEY)")
server_url = (
ns.server or os.environ.get("WORLDTREE_API_URL") or "http://localhost:8000"
)
return api_key, server_url
async def _run_define(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT
async with httpx.AsyncClient(
base_url=server_url,
headers={
"Authorization": f"Bearer {api_key}",
"User-Agent": USER_AGENT,
},
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
) as client:
info = await define_agent(
client,
agent_name=ns.name,
system_prompt=ns.system_prompt,
model=ns.model,
)
print(f"defined {info.agent_id} ({info.model})")
return 0
async def _run_patch(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT
if ns.system_prompt is None and ns.model is None:
raise _Tier3UsageError(
"patch requires at least one of --system-prompt or --model"
)
async with httpx.AsyncClient(
base_url=server_url,
headers={
"Authorization": f"Bearer {api_key}",
"User-Agent": USER_AGENT,
},
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
) as client:
info = await patch_agent(
client,
ns.agent_id,
system_prompt=ns.system_prompt,
model=ns.model,
)
print(f"patched {info.agent_id}")
return 0
async def _run_delete(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT
async with httpx.AsyncClient(
base_url=server_url,
headers={
"Authorization": f"Bearer {api_key}",
"User-Agent": USER_AGENT,
},
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
) as client:
await delete_agent(client, ns.agent_id)
print(f"deleted {ns.agent_id}")
return 0
def main(argv: list[str] | None = None) -> int:
"""Sync entry point — argparse + dispatch + error → exit-code mapping.
Mirrors ratatoskr.cli.main()'s error-routing matrix:
0 happy
10 usage error
11 auth error
20 api-failure (typed exception or generic SessionApiFailed)
21 network error
"""
import asyncio
import sys
parser = _build_parser()
try:
ns = parser.parse_args(argv)
except SystemExit as exc:
return int(exc.code) if exc.code is not None else 0
handler = {
"define": _run_define,
"patch": _run_patch,
"delete": _run_delete,
}[ns.cmd]
try:
return asyncio.run(handler(ns))
except _Tier3UsageError as exc:
sys.stderr.write(f"[usage_error] {exc}\n")
return 10
except _Tier3AuthError as exc:
sys.stderr.write(f"[auth_error] {exc}\n")
return 11
except AssertionError as exc:
sys.stderr.write(f"[usage_error] {exc}\n")
return 10
except Tier3QuotaExceeded as exc:
sys.stderr.write(f"[quota_exceeded] retry_after={exc.retry_after}\n")
return 20
except Tier3UserIdUnsupported:
sys.stderr.write("[user_id_unsupported]\n")
return 20
except Tier3AgentNotFound as exc:
sys.stderr.write(f"[agent_not_found] {exc.agent_id}\n")
return 20
except Tier3FieldNotMutable as exc:
sys.stderr.write(f"[field_not_mutable] field={exc.field}\n")
return 20
except Tier3LayerDeferred as exc:
sys.stderr.write(f"[layer_deferred] field={exc.field}\n")
return 20
except SessionApiFailed as exc:
sys.stderr.write(
f"[api_failed] status={exc.status} body={exc.body!r}\n"
)
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
if __name__ == "__main__":
import sys
sys.exit(main())
+112 -115
View File
@@ -61,31 +61,33 @@ from ratatoskr.sse_client import (
stream_turn,
)
# ---- Australis-derived theme (https://github.com/lkraven/australis) ----------
# ---- Australis theme (https://github.com/lkraven/australis) ------------------
#
# v0.6.2 deliberate deviation from spec: the Australis Dark theme's design
# principle "all colors are cooler than neutral" bakes a blue cast into
# every dark surface (Ice black #222531 = RGB 34/37/49 — blue dominant).
# Operators reported the whole-app chrome reading as blue. Ratatoskr keeps
# the Australis ACCENTS (Aurora blue/cyan/green, Dawn red/yellow) — those
# are the brand signature — but **neutralizes the Ice/Sea dark palette to
# true grays** so the chrome reads as neutral dark, not blue-leaning.
# The Australis Dark color theme, inspired by the Southern Lights. 16 cool-tone
# terminal colors with medium contrast. Preference order: blue > cyan > green
# for primary surfaces; Dawn accents (red/yellow/magenta) used sparingly for
# terminal-event labels (error/cancelled).
#
# **v0.6.3 single deviation from spec**: `$background` is `#000000` (pure
# black), NOT Australis Ice black `#222531`. The Ice black is RGB(34,37,49)
# — blue dominant — and at App-wide scale the cumulative cast reads as
# "the whole app is blue" to operators (even though no single surface is
# "blue" in the strict-color sense). Pure black for the App background
# kills that perception. EVERY OTHER Australis value — Aurora accents,
# Sea darks for chrome (surface/panel), Ice white foreground, Dawn
# accents — stays verbatim per spec.
#
# Mapping to Textual's Theme semantic tokens:
# primary = Aurora blue (#6388D8) — focus rings, active selection (KEEP).
# secondary = Aurora cyan (#00b1a8) — secondary highlights (KEEP).
# accent = Aurora bright cyan (#42dcd1) — bright accents (KEEP).
# success = Aurora green (#16B866) — [done] label (KEEP).
# warning = Dawn yellow (#e1c631) — [cancelled] label (KEEP).
# error = Dawn red (#ff491a) — [error] label (KEEP).
# foreground = neutral light gray (#bdbdbd) — default text (was Ice white).
# background = neutral near-black (#1a1a1a) — App bg (was Ice black).
# surface = neutral dark gray (#2a2a2a) — chrome (was Sea bright-black).
# panel = neutral mid gray (#3a3a3a) — borders (was Sea dark-30).
#
# Dark contrast steps preserve Australis's LAB lightness levels but drop the
# blue/green tint. Variables renamed mentally but kept under $au-* slugs so
# downstream CSS doesn't change.
# primary = Aurora blue (#6388D8) — focus rings, active selection.
# secondary = Aurora cyan (#00b1a8) — secondary highlights.
# accent = Aurora bright cyan (#42dcd1) — bright accents.
# success = Aurora green (#16B866) — [done] label.
# warning = Dawn yellow (#e1c631) — [cancelled] label.
# error = Dawn red (#ff491a) — [error] label.
# foreground = Ice white (#a9bcc3) — default text.
# background = pure black (#000000) — App background (v0.6.3 deviation).
# surface = Sea bright black (#373b46) — raised chrome.
# panel = Sea dark 30 (#414751) — borders, separators.
AUSTRALIS_THEME = Theme(
name="australis",
@@ -95,22 +97,20 @@ AUSTRALIS_THEME = Theme(
success="#16B866",
warning="#e1c631",
error="#ff491a",
foreground="#bdbdbd",
background="#1a1a1a",
surface="#2a2a2a",
panel="#3a3a3a",
foreground="#a9bcc3",
background="#000000",
surface="#373b46",
panel="#414751",
dark=True,
variables={
# Neutral dark contrast palette (LAB-matched to Australis Sea spec
# but with R=G=B, no cool cast). $au-dark-N accessible via TCSS.
"au-dark-30": "#3a3a3a",
"au-dark-40": "#4f4f4f",
"au-dark-50": "#6b6b6b",
"au-dark-60": "#878787",
"au-bright-70": "#9e9e9e",
"au-bright-80": "#bdbdbd",
"au-bright-white": "#e0e0e0",
# Accents — KEEP Australis Aurora brights for brand signature.
# Sea contrast palette — usable via $au-dark-50 etc. in TCSS.
"au-dark-30": "#414751",
"au-dark-40": "#565f69",
"au-dark-50": "#6e7882",
"au-dark-60": "#86929d",
"au-bright-70": "#9daeb6",
"au-bright-80": "#b3cbcf",
"au-bright-white": "#cce7ec",
"au-bright-blue": "#a4c4ff",
"au-bright-cyan": "#42dcd1",
"au-bright-green": "#51e08a",
@@ -124,9 +124,9 @@ AUSTRALIS_THEME = Theme(
_AU_SUCCESS = "#16B866"
_AU_ERROR = "#ff491a"
_AU_WARNING = "#e1c631"
_AU_USER_ECHO = "#42dcd1" # bright cyan — operator's voice
_AU_DEMOTED = "#878787" # neutral dark-60 — demoted telemetry
_AU_DEMOTED_FAINT = "#6b6b6b" # neutral dark-50 — empty-state placeholders
_AU_USER_ECHO = "#42dcd1" # Aurora bright cyan — operator's voice
_AU_DEMOTED = "#86929d" # Sea dark 60 — demoted telemetry
_AU_DEMOTED_FAINT = "#6e7882" # Sea dark 50 — empty-state placeholders
# ---- Issue #12 presenter contract semantics amendment -------------------------
@@ -185,20 +185,24 @@ class TuiPresenterState:
See `docs/contracts/issues/12.contract.md` for the full spec.
"""
thinking_buffer: list[str] = field(default_factory=list)
thinking_open: bool = False
# v0.6.0: per-turn streaming text buffer. Text deltas accumulate here
# and update `current_text` Static in place — no per-token RichLog spam.
text_buffer: list[str] = field(default_factory=list)
# v0.6.0: thinking-run counter for turn-scoped start/end markers.
# Thinking-run counter for turn-scoped start/end markers.
thinking_run_index: int = 0
# v0.7.1: thinking-content accumulator. Worldtree emits Thinking deltas
# at token granularity; flushing each delta as its own RichLog line
# produces per-token-per-newline visual spam. Buffer here and flush
# only on `\n` boundaries (one written line per natural paragraph) or
# when the run closes (any leftover tail).
thinking_chunk_buffer: str = ""
def render(
self,
event: Event,
*,
log: RichLog,
thinking_widget: Static,
current_text: Static,
tools_log: RichLog,
debug_log: RichLog,
@@ -207,16 +211,18 @@ class TuiPresenterState:
) -> None:
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
v0.6.0 routing:
v0.6.5 routing:
- `log` (transcript) = content only: user-prompt echo (written
outside the presenter), terminal labels, post-Done Markdown body.
- `current_text` (Static below transcript) = live-streaming Text
deltas accumulated into one growing line; cleared on terminal.
- `tools_log` = ToolStart + ToolResult.
- `debug_log` = WorkerPhase + TextBoundary.
- `thinking_log` = closed thinking runs (Markdown + start/end
Rule markers); `thinking_widget` continues to receive live
per-delta updates.
- `thinking_log` = streaming Thinking deltas inline (each chunk =
one line in the scrollable log). Rule(start)/Rule(end) markers
wrap each run. The whole pane scrolls naturally — no separate
tail-scrolling Static at the bottom (v0.6.5 removed
`thinking-current`).
Exceptions caught at the presenter boundary (INV-009 fallback).
"""
@@ -230,56 +236,52 @@ class TuiPresenterState:
from rich.text import Text as RichText
def _dim(s: str) -> RichText:
"""Wrap a demoted-telemetry line in Australis dark-60 grey.
v0.4.1 retheme: was `style="dim"` (terminal-dim filter, varies by
emulator); now explicit Australis Sea dark-60 (#86929d) so the
shade renders consistently across terminals and stays anchored to
the brand palette.
"""
"""Wrap a demoted-telemetry line in Australis Sea dark-60 grey."""
return RichText(s, style=_AU_DEMOTED)
try:
# Thinking events: accumulate into buffer, update widget per delta.
# v0.7.1: Thinking deltas coalesce by newline before flushing.
# Worldtree emits Thinking events at token granularity; per-delta
# RichLog writes produce one visual line per token (per-token-per-
# newline spam). Buffer the deltas and flush only on `\n` (one
# written line per natural paragraph) or run close.
if isinstance(event, Thinking):
from rich.rule import Rule
if not self.thinking_open:
thinking_widget.display = True
self.thinking_run_index += 1
turn_id = event.sse_id.turn_id
thinking_log.write(Rule(
title=f"turn {turn_id} · thinking #{self.thinking_run_index} start",
style=_AU_DEMOTED,
))
self.thinking_open = True
self.thinking_buffer.append(event.content)
acc = "".join(self.thinking_buffer)
# v0.5.1 polish: prefix the live widget with "thinking… " so
# operators recognize what the streaming content is (otherwise
# the static-content under Header reads like uncontextualized
# spillover). Truncate display to last 200 chars + ellipsis.
tail = ("" + acc[-200:]) if len(acc) > 200 else acc
thinking_widget.update(f"thinking… {tail}")
self.thinking_chunk_buffer += event.content
# Flush every complete line in the buffer. Whatever's after
# the final `\n` stays buffered for the next delta or close.
while "\n" in self.thinking_chunk_buffer:
line, _, rest = self.thinking_chunk_buffer.partition("\n")
if line: # skip empty lines (blank paragraph separators)
thinking_log.write(line)
self.thinking_chunk_buffer = rest
return
# Non-thinking event: close any open thinking run.
# v0.6.0: closed thinking runs route to thinking_log (Thinking
# pane) wrapped in `── turn N · thinking start/end ──` Rule
# markers, with the content itself rendered as Markdown (model
# reasoning often has lists, code, structure).
# Non-thinking event: close any open thinking run with Rule(end).
if self.thinking_open:
full_thinking = "".join(self.thinking_buffer)
from rich.rule import Rule
# Flush the tail (content with no trailing `\n`) before the
# end rule so nothing gets lost on close.
if self.thinking_chunk_buffer:
thinking_log.write(self.thinking_chunk_buffer)
self.thinking_chunk_buffer = ""
turn_id = event.sse_id.turn_id if hasattr(event, "sse_id") else (
event.turn_id if hasattr(event, "turn_id") else "?"
)
self.thinking_run_index += 1
from rich.markdown import Markdown
from rich.rule import Rule
thinking_log.write(Rule(
title=f"turn {turn_id} · thinking #{self.thinking_run_index} start",
style=_AU_DEMOTED,
))
thinking_log.write(Markdown(full_thinking))
thinking_log.write(Rule(
title=f"turn {turn_id} · thinking #{self.thinking_run_index} end",
style=_AU_DEMOTED,
))
self.thinking_buffer.clear()
self.thinking_open = False
thinking_widget.update("")
thinking_widget.display = False
# Now render the non-thinking event itself.
if isinstance(event, Text):
# v0.6.0: streaming text accumulates into current_text Static
@@ -327,10 +329,6 @@ class TuiPresenterState:
f"partial_message_id={event.partial_message_id}",
style=_AU_WARNING,
))
# Belt-and-braces (Volva F3): ensure widget cleared+hidden on EVERY
# terminal event, even if thinking_open was False — per STEPS 5-6.
thinking_widget.update("")
thinking_widget.display = False
return
if isinstance(event, WorkerPhase):
# v0.5.0: telemetry → Debug pane, not transcript.
@@ -425,22 +423,34 @@ class AgentPickerApp(App[str | None]):
background: $background;
}
/* Multi-line agent items. Each ListItem is auto-height so the full
description wraps below the agent_id/name line — no truncation. */
description wraps below the agent_id/name line — no truncation.
v0.6.4: lock bg to $background so Textual's auto background-tint on
focus doesn't bleed through unwanted color into the non-highlighted
items. */
#agent-list > ListItem {
height: auto;
padding: 1 1;
background: $background;
}
/* v0.6.1: override Textual's default ListView:focus highlight, which
defaults to $primary (Aurora blue) and made the picker unreadable.
Both selectors needed — focused state has higher specificity in
Textual's defaults. */
ListView > ListItem.--highlight,
ListView:focus > ListItem.--highlight {
background: $au-dark-30;
/* v0.6.4: highlighted item gets Aurora blue background (Textual's
default $block-cursor-background = $primary). Override only the
text-color descendants so id-line/desc stay readable on blue. The
background itself comes from Textual's default ListItem.-highlight
rule — we removed our previous overriding selectors.
Textual's class is `-highlight` (single dash). Use plain descendant
combinator to bypass internal DOM wrappers. */
#agent-list:focus ListItem.-highlight {
background: $primary;
}
/* Children of highlighted items keep their colors — the dark-30 bg
provides enough contrast for bright-blue id + dark-60 desc text. */
#agent-list:focus ListItem.-highlight .agent-id-line {
color: $au-bright-white;
text-style: bold;
}
#agent-list:focus ListItem.-highlight .agent-desc {
color: $au-bright-80;
}
/* Default (unhighlighted) item text styling. */
.agent-id-line {
color: $au-bright-blue;
text-style: bold;
@@ -552,18 +562,8 @@ class RatatoskrApp(App[int]):
#right-column {
width: 1fr;
}
/* v0.6.1: thinking-current Static moved INTO the Thinking pane (below
thinking-log) so streaming + closed runs co-locate. Docked bottom of
its TabPane so it acts as the live "tail" of the chronological log
above. Empty (height:0) when no thinking is active. */
#thinking-current {
dock: bottom;
height: auto;
color: $au-dark-60;
padding: 0 1;
text-style: italic;
background: $background;
}
/* v0.6.5: thinking-current Static removed; thinking now streams
directly into thinking-log so the whole pane scrolls naturally. */
#transcript {
height: 1fr;
background: $background;
@@ -691,14 +691,15 @@ class RatatoskrApp(App[int]):
id="debug-log", wrap=True, markup=False, highlight=False
)
with TabPane("Thinking", id="thinking-tab"):
# v0.6.5: thinking streams directly into this
# RichLog (no separate bottom Static). Each delta
# writes a line; Rule(start)/Rule(end) mark run
# boundaries. The whole pane scrolls naturally
# as content arrives — no more "200-char tail
# window scrolling at the bottom".
yield RichLog(
id="thinking-log", wrap=True, markup=False, highlight=False
)
# v0.6.1: live thinking lives INSIDE the Thinking
# pane (docked bottom) — co-located with the closed
# runs in thinking-log above. No more top/bottom
# discontinuity across the right column.
yield Static("", id="thinking-current")
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
# pane-name widget displays current side-pane name.
yield Static("", id="identity")
@@ -717,8 +718,6 @@ class RatatoskrApp(App[int]):
identity = f"{agent_slot} · …{self.session_id[-8:]}"
self.sub_title = identity # mirror to Header subtitle for redundancy
self.query_one("#identity", Static).update(identity)
# Issue #12: thinking widget hidden until a thinking event fires.
self.query_one("#thinking-current", Static).display = False
# v0.5.1 polish: empty-state placeholder lines so the operator sees
# the pane is intentionally empty (not broken) before any turn fires.
# Wrapped in Australis dark-50 italic so they read distinctly as
@@ -799,7 +798,6 @@ class RatatoskrApp(App[int]):
assert self.client is not None
assert content
log = self.query_one("#transcript", RichLog)
thinking_widget = self.query_one("#thinking-current", Static)
current_text = self.query_one("#current-text", Static)
tools_log = self.query_one("#tools-log", RichLog)
debug_log = self.query_one("#debug-log", RichLog)
@@ -816,7 +814,6 @@ class RatatoskrApp(App[int]):
presenter.render(
event,
log=log,
thinking_widget=thinking_widget,
current_text=current_text,
tools_log=tools_log,
debug_log=debug_log,
+437
View File
@@ -0,0 +1,437 @@
"""Tests for ratatoskr.tier3 per docs/contracts/issues/15.contract.md."""
import httpx
import pytest
import respx
from ratatoskr.sessions import SessionApiFailed
from ratatoskr.tier3 import (
Tier3AgentInfo,
Tier3AgentNotFound,
Tier3FieldNotMutable,
Tier3LayerDeferred,
Tier3QuotaExceeded,
Tier3UserIdUnsupported,
define_agent,
delete_agent,
main,
patch_agent,
)
_FULL_AGENT_RESP = {
"agent_id": "ratatoskr:wizard",
"user_id": "ratatoskr",
"agent_name": "wizard",
"system_prompt": "You are a wizard.",
"model": "qwen3.6-35-a3b",
"created_at": "2026-05-25T03:20:09.703601+00:00",
"updated_at": "2026-05-25T03:20:09.703601+00:00",
}
class TestDefineAgent:
@respx.mock
async def test_happy_define(self) -> None:
"""happy_define [happy,tracer]: 201 → fully populated Tier3AgentInfo."""
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await define_agent(
client,
agent_name="wizard",
system_prompt="You are a wizard.",
model="qwen3.6-35-a3b",
)
assert isinstance(info, Tier3AgentInfo)
assert info.agent_id == "ratatoskr:wizard"
assert info.user_id == "ratatoskr"
assert info.agent_name == "wizard"
assert info.model == "qwen3.6-35-a3b"
@respx.mock
async def test_request_body_shape(self) -> None:
"""request_body_shape [trace]: outbound JSON is exactly the three keys."""
import json as _json
route = respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await define_agent(
client,
agent_name="wizard",
system_prompt="You are a wizard.",
model="qwen3.6-35-a3b",
)
body = _json.loads(route.calls[0].request.content)
# INV-001: exactly these three keys — no layer fields, no metadata.
assert body == {
"agent_name": "wizard",
"system_prompt": "You are a wizard.",
"model": "qwen3.6-35-a3b",
}
@respx.mock
async def test_quota_exceeded(self) -> None:
"""quota_exceeded [error]: 429 + Retry-After → Tier3QuotaExceeded."""
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(
429,
headers={"Retry-After": "0"},
json={"detail": {"error_code": "agent_quota_exceeded"}},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3QuotaExceeded) as exc:
await define_agent(
client,
agent_name="overflow",
system_prompt="x",
model="m",
)
assert exc.value.retry_after == 0
@respx.mock
async def test_user_id_unsupported(self) -> None:
"""user_id_unsupported [error]: 403 + error_code → Tier3UserIdUnsupported."""
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(
403, json={"detail": {"error_code": "tier3_user_id_unsupported"}}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3UserIdUnsupported):
await define_agent(
client, agent_name="wizard", system_prompt="x", model="m"
)
@respx.mock
async def test_layer_deferred(self) -> None:
"""layer_deferred [error]: 422 + layer_deferred → Tier3LayerDeferred(field)."""
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(
422,
json={"detail": {"error_code": "layer_deferred", "field": "persona"}},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3LayerDeferred) as exc:
await define_agent(
client, agent_name="wizard", system_prompt="x", model="m"
)
assert exc.value.field == "persona"
@respx.mock
async def test_bad_slug_assert(self) -> None:
"""bad_slug_assert [adversarial]: agent_name with uppercase → AssertionError, no HTTP."""
route = respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await define_agent(
client, agent_name="Wizard", system_prompt="x", model="m"
)
assert route.call_count == 0
@respx.mock
async def test_short_slug_assert(self) -> None:
"""short_slug_assert [adversarial]: agent_name len < 3 → AssertionError."""
route = respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await define_agent(
client, agent_name="ab", system_prompt="x", model="m"
)
assert route.call_count == 0
@respx.mock
async def test_empty_prompt_assert(self) -> None:
"""empty_prompt_assert [adversarial]: empty system_prompt → AssertionError."""
route = respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await define_agent(
client, agent_name="wizard", system_prompt="", model="m"
)
assert route.call_count == 0
@respx.mock
async def test_other_5xx(self) -> None:
"""other_5xx [error]: 503 → SessionApiFailed(status=503)."""
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(503, content=b"upstream out")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await define_agent(
client, agent_name="wizard", system_prompt="x", model="m"
)
assert exc.value.status == 503
class TestPatchAgent:
@respx.mock
async def test_happy_patch_both_fields(self) -> None:
"""happy_patch_both_fields: both fields set → request body has both."""
import json as _json
updated = {
**_FULL_AGENT_RESP,
"system_prompt": "new prompt",
"model": "different-model",
}
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(200, json=updated)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await patch_agent(
client,
"ratatoskr:wizard",
system_prompt="new prompt",
model="different-model",
)
body = _json.loads(route.calls[0].request.content)
assert body == {"system_prompt": "new prompt", "model": "different-model"}
assert info.system_prompt == "new prompt"
assert info.model == "different-model"
@respx.mock
async def test_happy_patch_single_field(self) -> None:
"""happy_patch_single_field: omit model → body has system_prompt only."""
import json as _json
updated = {**_FULL_AGENT_RESP, "system_prompt": "only this"}
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(200, json=updated)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await patch_agent(client, "ratatoskr:wizard", system_prompt="only this")
body = _json.loads(route.calls[0].request.content)
# INV-002: body omits the None-valued field entirely
assert body == {"system_prompt": "only this"}
@respx.mock
async def test_field_not_mutable(self) -> None:
"""field_not_mutable [error]: 422 + error_code → Tier3FieldNotMutable(field)."""
respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(
422,
json={
"detail": {"error_code": "field_not_mutable", "field": "agent_name"}
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3FieldNotMutable) as exc:
await patch_agent(
client, "ratatoskr:wizard", system_prompt="x"
)
assert exc.value.field == "agent_name"
@respx.mock
async def test_404(self) -> None:
"""404 [error]: PATCH on non-existent agent → Tier3AgentNotFound."""
respx.patch("https://w.example/agents/ratatoskr:ghost").mock(
return_value=httpx.Response(404, content=b"")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3AgentNotFound) as exc:
await patch_agent(
client, "ratatoskr:ghost", system_prompt="x"
)
assert exc.value.agent_id == "ratatoskr:ghost"
@respx.mock
async def test_no_fields_assert(self) -> None:
"""no_fields_assert [adversarial]: both None → AssertionError, no HTTP."""
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(200, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await patch_agent(client, "ratatoskr:wizard")
assert route.call_count == 0
@respx.mock
async def test_non_tier3_id_assert(self) -> None:
"""non_tier3_id_assert [adversarial]: agent_id without `:` → AssertionError."""
route = respx.patch("https://w.example/agents/mimir").mock(
return_value=httpx.Response(200, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await patch_agent(client, "mimir", system_prompt="x")
assert route.call_count == 0
class TestDeleteAgent:
@respx.mock
async def test_happy_delete(self) -> None:
"""happy_delete [happy,tracer]: 204 → returns None."""
respx.delete("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(204)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await delete_agent(client, "ratatoskr:wizard")
assert result is None
@respx.mock
async def test_404(self) -> None:
"""404 [error]: DELETE on non-existent agent → Tier3AgentNotFound."""
respx.delete("https://w.example/agents/ratatoskr:ghost").mock(
return_value=httpx.Response(404)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3AgentNotFound) as exc:
await delete_agent(client, "ratatoskr:ghost")
assert exc.value.agent_id == "ratatoskr:ghost"
@respx.mock
async def test_non_tier3_id_assert(self) -> None:
"""non_tier3_id_assert [adversarial]: agent_id without `:` → AssertionError."""
route = respx.delete("https://w.example/agents/mimir").mock(
return_value=httpx.Response(204)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await delete_agent(client, "mimir")
assert route.call_count == 0
@respx.mock
async def test_other_5xx(self) -> None:
"""other_5xx [error]: 500 → SessionApiFailed."""
respx.delete("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(500, content=b"oops")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await delete_agent(client, "ratatoskr:wizard")
assert exc.value.status == 500
class TestCli:
@respx.mock
def test_cli_define_happy(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""cli_define_happy [happy]: argv → 201 mock → stdout confirmation."""
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
rc = main([
"define",
"--name", "wizard",
"--system-prompt", "You are a wizard.",
"--model", "qwen3.6-35-a3b",
])
out = capsys.readouterr()
assert rc == 0
assert out.out.strip() == "defined ratatoskr:wizard (qwen3.6-35-a3b)"
@respx.mock
def test_cli_patch_happy(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""cli_patch_happy [happy]: argv → 200 mock → stdout confirmation."""
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(200, json=_FULL_AGENT_RESP)
)
rc = main(["patch", "ratatoskr:wizard", "--system-prompt", "new"])
out = capsys.readouterr()
assert rc == 0
assert out.out.strip() == "patched ratatoskr:wizard"
@respx.mock
def test_cli_delete_happy(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""cli_delete_happy [happy]: argv → 204 mock → stdout confirmation."""
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.delete("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(204)
)
rc = main(["delete", "ratatoskr:wizard"])
out = capsys.readouterr()
assert rc == 0
assert out.out.strip() == "deleted ratatoskr:wizard"
def test_cli_missing_auth(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""cli_missing_auth [error]: no api-key → stderr [auth_error] + exit 11."""
monkeypatch.delenv("WORLDTREE_API_KEY", raising=False)
rc = main([
"define",
"--name", "wizard",
"--system-prompt", "x",
"--model", "m",
])
err = capsys.readouterr().err
assert rc == 11
assert "[auth_error]" in err
@respx.mock
def test_cli_api_failed(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""cli_api_failed [error]: 500 → stderr [api_failed] + exit 20."""
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(500, content=b"upstream out")
)
rc = main([
"define",
"--name", "wizard",
"--system-prompt", "x",
"--model", "m",
])
err = capsys.readouterr().err
assert rc == 20
assert "[api_failed]" in err
@respx.mock
def test_cli_quota_exceeded(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""cli_quota_exceeded [error]: 429 → stderr [quota_exceeded] + exit 20."""
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(
429,
headers={"Retry-After": "0"},
json={"detail": {"error_code": "agent_quota_exceeded"}},
)
)
rc = main([
"define",
"--name", "wizard",
"--system-prompt", "x",
"--model", "m",
])
err = capsys.readouterr().err
assert rc == 20
assert "[quota_exceeded]" in err
def test_cli_patch_no_fields(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""cli_patch_no_fields [error]: patch with no flags → [usage_error] + exit 10."""
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
rc = main(["patch", "ratatoskr:wizard"])
err = capsys.readouterr().err
assert rc == 10
assert "[usage_error]" in err
+107 -194
View File
@@ -11,7 +11,6 @@ from ratatoskr.cli import ParsedArgs
from ratatoskr.sse_client import (
Cancelled,
Done,
Error,
SseId,
Text,
Thinking,
@@ -116,56 +115,66 @@ SID = SseId(42, 5)
class TestTuiPresenterState:
"""Tests for the new TuiPresenterState — per issue #12 contract."""
def test_thinking_coalesce_single_widget_update(self) -> None:
"""thinking_coalesce_single_widget_update [happy,tracer]:
3 Thinking events → thinking_widget.update called 3 times with cumulative content;
RichLog has 0 thinking entries (closure hasn't fired yet).
def test_thinking_coalesces_until_newline(self) -> None:
"""thinking_coalesces_until_newline [happy,tracer, v0.7.1]:
Per-token deltas accumulate in the buffer; flush only on `\\n`.
Three short token-shaped deltas without `\\n` → thinking_log gets
ONLY Rule(start); content stays buffered.
"""
from rich.rule import Rule
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
thinking_log = MagicMock()
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="a"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
state.render(
Thinking(sse_id=SID, content="b"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
state.render(
Thinking(sse_id=SID, content="c"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
# Widget updated 3 times — once per delta — with cumulative content
assert widget.update.call_count == 3
# Latest call shows the full accumulated content (under 200 chars so no truncation).
# v0.5.1 polish: widget text is prefixed with "thinking… " for self-explanation.
assert widget.update.call_args_list[-1][0][0] == "thinking… abc"
# Widget became visible at first delta
assert widget.display is True
# No RichLog write yet — closure hasn't fired
for chunk in ("Let", " me", " think"):
state.render(
Thinking(sse_id=SID, content=chunk),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(),
thinking_log=thinking_log,
raw=False,
)
writes = [c[0][0] for c in thinking_log.write.call_args_list]
# Only Rule(start) — content stays buffered (no `\n` seen).
assert len(writes) == 1
assert isinstance(writes[0], Rule)
assert state.thinking_chunk_buffer == "Let me think"
assert log.write.call_count == 0
def test_thinking_closes_to_thinking_log(self) -> None:
"""thinking_closes_to_thinking_log [happy, v0.6.0]: 2x Thinking + WorkerPhase →
thinking_log gets Rule(start) + Markdown + Rule(end); debug_log gets worker_phase;
transcript and tools_log untouched. Widget cleared+hidden.
def test_thinking_flushes_on_newline(self) -> None:
"""thinking_flushes_on_newline [happy, v0.7.1]:
Delta carrying `\\n` flushes the accumulated buffer as ONE line.
"""
from ratatoskr.tui import TuiPresenterState
thinking_log = MagicMock()
state = TuiPresenterState()
for chunk in ("Hello", " world", "\n"):
state.render(
Thinking(sse_id=SID, content=chunk),
log=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(),
thinking_log=thinking_log,
raw=False,
)
writes = [c[0][0] for c in thinking_log.write.call_args_list]
# Rule(start) + "Hello world" (one coalesced line) = 2 writes
assert len(writes) == 2
assert writes[1] == "Hello world"
assert state.thinking_chunk_buffer == ""
def test_thinking_closes_to_thinking_log(self) -> None:
"""thinking_closes_to_thinking_log [happy, v0.7.1]: 2x Thinking + WorkerPhase →
v0.7.1 coalesces "a"+"b" into one buffered string; the close flushes
"ab" as a single line before Rule(end). Result: Rule(start) + "ab" +
Rule(end) = 3 writes. debug_log gets worker_phase; transcript untouched.
"""
from rich.markdown import Markdown
from rich.rule import Rule
from ratatoskr.tui import TuiPresenterState
@@ -173,13 +182,11 @@ class TestTuiPresenterState:
log = MagicMock()
debug_log = MagicMock()
thinking_log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
for content in ("a", "b"):
state.render(
Thinking(sse_id=SID, content=content),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
current_text=MagicMock(),
@@ -189,87 +196,31 @@ class TestTuiPresenterState:
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
current_text=MagicMock(),
thinking_log=thinking_log,
raw=False,
)
# v0.6.0: closure writes Rule(start) + Markdown + Rule(end) to thinking_log.
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
assert any(isinstance(w, Rule) for w in thinking_writes), thinking_writes
assert any(isinstance(w, Markdown) for w in thinking_writes), thinking_writes
# worker_phase still goes to debug_log; transcript still untouched.
assert debug_log.write.called
# v0.7.1: 1 Rule(start) + 1 coalesced "ab" tail-flush + 1 Rule(end) = 3 writes
assert len(thinking_writes) == 3
assert isinstance(thinking_writes[0], Rule)
assert thinking_writes[1] == "ab"
assert isinstance(thinking_writes[2], Rule)
# worker_phase still goes to debug_log; transcript untouched.
assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[-1][0][0])
assert not log.write.called
# Widget cleared + hidden
widget.update.assert_called_with("")
assert widget.display is False
def test_thinking_widget_truncation(self) -> None:
"""thinking_widget_truncation [trace]: buffer 500 chars → widget shows "" + last 200."""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
# Push 500 chars across multiple deltas.
long = "x" * 500
state.render(
Thinking(sse_id=SID, content=long),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
last_update = widget.update.call_args_list[-1][0][0]
# v0.5.1 polish: widget gets a "thinking… " prefix + ellipsis-truncated tail.
assert last_update.startswith("thinking… ")
# tail is "…" + last-200 = 201 chars; prefix is 10 chars ("thinking… ")
assert len(last_update) == len("thinking… ") + 201
assert "" in last_update
def test_thinking_widget_visibility_lifecycle(self) -> None:
"""thinking_widget_visibility_lifecycle [trace]: hidden at start; visible during thinking;
hidden after closing event.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
widget.display = False # initial state (composed hidden)
state = TuiPresenterState()
# First thinking delta → widget visible
state.render(
Thinking(sse_id=SID, content="x"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
assert widget.display is True
# Closure (WorkerPhase) → widget hidden
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
assert widget.display is False
# v0.6.5: thinking-current Static removed; test_thinking_widget_truncation
# and test_thinking_widget_visibility_lifecycle deleted (no longer apply).
def test_multiple_thinking_runs_each_get_thinking_log_section(self) -> None:
"""multiple_thinking_runs_each_get_thinking_log_section [scenario, v0.6.0]:
Thinking → Text → Thinking → Done → TWO start/end Rule + Markdown sections
in thinking_log. Text goes to current_text Static (buffered). Transcript
receives [done] label + Markdown body only.
"""multiple_thinking_runs_each_get_section [scenario, v0.6.5]:
Thinking → Text → Thinking → Done → TWO start/end Rule pairs in
thinking_log, each wrapping their delta lines. Text goes to
current_text (buffered). Transcript: [done] + Markdown body.
"""
from rich.markdown import Markdown
from rich.rule import Rule
from ratatoskr.tui import TuiPresenterState
@@ -277,7 +228,6 @@ class TestTuiPresenterState:
log = MagicMock()
thinking_log = MagicMock()
current_text = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
for evt in (
Thinking(sse_id=SID, content="first"),
@@ -285,22 +235,23 @@ class TestTuiPresenterState:
Thinking(sse_id=SID, content="second"),
):
state.render(
evt, log=log, thinking_widget=widget,
evt, log=log,
tools_log=MagicMock(), debug_log=MagicMock(),
current_text=current_text, thinking_log=thinking_log, raw=False,
)
state.render(
_make_tui_done(),
log=log, thinking_widget=widget,
log=log,
tools_log=MagicMock(), debug_log=MagicMock(),
current_text=current_text, thinking_log=thinking_log, raw=False,
)
# v0.6.0: thinking_log holds (Rule(start) + Markdown + Rule(end)) x2.
# v0.6.5: thinking_log holds 4 Rules (start + end per run) + 2 delta lines.
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
rules = [w for w in thinking_writes if isinstance(w, Rule)]
markdowns = [w for w in thinking_writes if isinstance(w, Markdown)]
delta_strs = [w for w in thinking_writes if isinstance(w, str)]
assert len(rules) == 4, f"expected 4 Rules (2 start + 2 end), got {len(rules)}"
assert len(markdowns) == 2, f"expected 2 Markdown sections, got {len(markdowns)}"
assert "first" in delta_strs
assert "second" in delta_strs
# Text "hi" went to current_text (buffered), not the transcript directly.
current_text.update.assert_any_call("hi")
# Transcript: [done] label + Markdown(response) (raw=False).
@@ -308,22 +259,25 @@ class TestTuiPresenterState:
assert any(w.startswith("[done]") for w in log_writes if isinstance(w, str))
def test_render_exception_fallback(self) -> None:
"""render_exception_fallback [adversarial, v0.6.0]:
widget.update raises → thinking_log gets the plain-label fallback for
Thinking (per v0.6.0 routing — Thinking now routes to thinking_log,
not debug_log). render_error line follows. transcript untouched.
"""render_exception_fallback [adversarial, v0.6.5]:
thinking_log.write raises → catch in presenter, write plain-label
fallback + render_error line via INV-009 fallback path (routing
preservation: thinking events still route to thinking_log).
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
thinking_log = MagicMock()
widget = MagicMock()
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
# First call (Rule write) raises; subsequent calls succeed for fallback.
thinking_log.write.side_effect = [
AttributeError("rule write failed (msg should NOT leak)"),
None,
None,
]
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="x"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(),
@@ -333,7 +287,7 @@ class TestTuiPresenterState:
writes = [c[0][0] for c in thinking_log.write.call_args_list if isinstance(c[0][0], str)]
assert any(w.startswith("[thinking]") for w in writes), writes
assert any(w == "[render_error] AttributeError" for w in writes), writes
assert not any("widget gone" in w for w in writes), writes
assert not any("rule write failed" in w for w in writes), writes
assert not log.write.called
def test_state_reset_per_worker(self) -> None:
@@ -344,10 +298,11 @@ class TestTuiPresenterState:
s1.render(
Thinking(sse_id=SID, content="x"),
log=MagicMock(),
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
current_text=MagicMock(),
thinking_log=MagicMock(),
raw=False,
)
s2 = TuiPresenterState()
assert s1.thinking_open is True
@@ -361,32 +316,29 @@ class TestTuiPresenterState:
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
debug_log = MagicMock()
widget = MagicMock()
thinking_log = MagicMock()
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="partial"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
)
state.render(
Cancelled(
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=None
),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
)
# v0.6.0: closed thinking lands in thinking_log (Markdown body wrapped in
# Rule start/end). terminal [cancelled] still in transcript.
# v0.6.5: streamed thinking + Rule(end) in thinking_log; [cancelled] in transcript.
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
assert any(w.startswith("[cancelled]") for w in log_writes)
assert widget.display is False
# thinking_log got at least Rule(start) + "partial" delta + Rule(end)
assert thinking_log.write.call_count >= 3
def test_done_renders_markdown_after_label(self) -> None:
"""done_renders_markdown_after_label [happy, v0.6.0]:
@@ -401,12 +353,10 @@ class TestTuiPresenterState:
log = MagicMock()
current_text = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hi"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=current_text,
@@ -418,7 +368,6 @@ class TestTuiPresenterState:
state.render(
_make_tui_done(),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=current_text,
@@ -440,12 +389,10 @@ class TestTuiPresenterState:
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hi"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
@@ -453,7 +400,6 @@ class TestTuiPresenterState:
state.render(
_make_tui_done(),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
@@ -477,7 +423,6 @@ class TestTuiPresenterState:
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=debug_log,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
@@ -494,36 +439,10 @@ class TestTuiPresenterState:
assert text.startswith("· worker_phase:")
assert "[worker_phase]" not in text
def test_terminal_events_belt_and_braces_widget_cleanup(self) -> None:
"""terminal_events_belt_and_braces_widget_cleanup [trace]:
Done / Error / Cancelled MUST clear+hide the thinking widget even when
thinking_open is False (Volva F3 fix; POST-005 + STEPS 5-6).
"""
from ratatoskr.tui import TuiPresenterState
for terminal in (
_make_tui_done(),
Error(sse_id=SID, phase="failed", message="boom", error_code="x"),
Cancelled(
sse_id=SID, phase="cancelled", turn_id=42, reason="r", partial_message_id=None
),
):
log = MagicMock()
widget = MagicMock()
widget.display = True # pre-set to non-default to detect the clear
state = TuiPresenterState()
# thinking_open is False (state just constructed).
state.render(
terminal,
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
)
# Belt-and-braces: widget cleared + hidden on EVERY terminal event.
widget.update.assert_called_with("")
assert widget.display is False, type(terminal).__name__
# v0.6.5: test_terminal_events_belt_and_braces_widget_cleanup deleted.
# The thinking-current Static is gone, so there's no widget to clean up
# on terminal events. The corresponding Volva F3 invariant is obsoleted
# by the streaming-into-thinking_log architecture.
def test_tool_start_routes_to_tools_log(self) -> None:
"""tool_start_routes_to_tools_log [INV-014]: ToolStart writes to tools_log, NOT transcript.
@@ -540,7 +459,6 @@ class TestTuiPresenterState:
state.render(
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
@@ -561,7 +479,6 @@ class TestTuiPresenterState:
state.render(
ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
@@ -583,7 +500,6 @@ class TestTuiPresenterState:
state.render(
Text(sse_id=SID, content="hello"),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
debug_log=MagicMock(),
current_text=current_text,
@@ -606,7 +522,6 @@ class TestTuiPresenterState:
state.render(
Text(sse_id=SID, content=tok),
log=MagicMock(),
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=current_text,
@@ -625,7 +540,6 @@ class TestTuiPresenterState:
state.render(
_make_tui_done(duration_ms=5467),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
@@ -653,7 +567,6 @@ class TestTuiPresenterState:
state.render(
_make_tui_done(usage=usage),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
@@ -815,27 +728,28 @@ class TestLayoutShape:
assert row is not None
async def test_left_column_content_only(self) -> None:
"""left_column_content_only [v0.5.0]: left column = transcript + prompt ONLY.
thinking-current Static moved to right column so the left column is
genuinely content-only (transcript + prompt input).
"""left_column_content_only [v0.6.5]: left column = transcript + prompt
+ current-text (streaming text Static). thinking-current Static
removed entirely as of v0.6.5.
"""
from textual.containers import Vertical
from textual.widgets import Input, RichLog, Static
from textual.widgets import Input, RichLog
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
left = app.query_one("#left-column", Vertical)
right = app.query_one("#right-column", Vertical)
transcript = app.query_one("#transcript", RichLog)
prompt = app.query_one("#prompt", Input)
thinking = app.query_one("#thinking-current", Static)
assert transcript in left.walk_children()
assert prompt in left.walk_children()
# v0.5.0: thinking-current is now under the right column, NOT left.
assert thinking not in left.walk_children()
assert thinking in right.walk_children()
# v0.6.5: thinking-current Static removed; no longer in DOM at all.
from textual.css.query import NoMatches
try:
app.query_one("#thinking-current")
raise AssertionError("thinking-current should not exist in v0.6.5")
except NoMatches:
pass # expected
async def test_right_column_has_tabbed_content_with_tools_tab(self) -> None:
"""right_column_has_tabbed_content_with_tools_tab: #side-panes + TabPane#tools-tab."""
@@ -940,7 +854,6 @@ class TestLayoutShape:
state.render(
_make_tui_done(),
log=log,
thinking_widget=app.query_one("#thinking-current"),
tools_log=app.query_one("#tools-log", RichLog),
debug_log=app.query_one("#debug-log", RichLog),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
Generated
+1 -1
View File
@@ -968,7 +968,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.6.2"
version = "0.7.1"
source = { editable = "." }
dependencies = [
{ name = "httpx" },