Compare commits

...

15 Commits

Author SHA1 Message Date
vh 489cfee1f0 fix(tui): drop post-Done Markdown body re-render (v0.8.2)
Operator: "first turn double prints agent's turn."

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

## Fix

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

## What this loses temporarily

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

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

## v0.9.0 plan

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

## Tests

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

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

## 1. Streaming text overlapping the transcript

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

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

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

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

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

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

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

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

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

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

## Tests + smoke

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

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

## Caveats

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

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

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

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

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

## Public surface

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

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

## Picker integration

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

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

## Tests

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

## Manual smoke

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

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

Minor bump (v0.7.1 → v0.8.0) — new public module + new picker
behavior (more agents shown). No caller-side breaking changes.
2026-05-24 21:13:30 -07:00
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
vh d845b20efd style(tui): neutralize Australis dark palette (v0.6.2)
Operator-flagged third pass: "overall background for the whole app is
blue." The previous "zero blue rects" investigations missed the
structural cause — Australis'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 +44% over red
  Sea bright   #373b46 = RGB(55, 59, 70)   — blue +27% over red
  Sea dark-30  #414751 = RGB(65, 71, 81)   — blue +25% over red
  Sea dark-60  #86929d = RGB(134,146,157)  — blue +17% over red

Every chrome surface inherits the lean. The user reading "the whole
app is blue" is correct — the SVG export just rendered hex values
that aren't named "blue" but ARE measurably blue-tinted.

## Fix: keep accents, neutralize darks

Australis brand signature lives in the ACCENTS — Aurora blue, cyan,
green; Dawn red, yellow. Those are unchanged. The Ice/Sea dark
palette is replaced with LAB-matched neutral grays (R=G=B) so the
chrome reads truly neutral:

  $background  #222531 → #1a1a1a   (neutral near-black)
  $surface     #373b46 → #2a2a2a   (neutral dark gray)
  $panel       #414751 → #3a3a3a   (neutral mid gray)
  $foreground  #a9bcc3 → #bdbdbd   (neutral light gray)
  $au-dark-30  #414751 → #3a3a3a
  $au-dark-40  #565f69 → #4f4f4f
  $au-dark-50  #6e7882 → #6b6b6b
  $au-dark-60  #86929d → #878787
  $au-bright-70 #9daeb6 → #9e9e9e
  $au-bright-80 #b3cbcf → #bdbdbd
  $au-bright-white #cce7ec → #e0e0e0

`_AU_DEMOTED` and `_AU_DEMOTED_FAINT` constants (Rich Text styling
for demoted telemetry + placeholders) updated to the neutral
equivalents. The Aurora bright variants (`$au-bright-blue`,
`$au-bright-cyan`, `$au-bright-green`) stay verbatim — those are
where the brand voice lives.

## What this preserves vs sacrifices

**Preserved**:
- Aurora accents: focus borders, active-tab indicator, pane-name
  widget, user-prompt echo all still render in cyan/blue/green.
- Done/Error/Cancelled labels still tinted in Aurora green / Dawn
  red / Dawn yellow.
- Identity widget still Aurora bright-blue.
- The "Australis" theme name + variable slugs ($au-*) — downstream
  CSS rules don't have to change.

**Sacrificed**:
- The "all colors cooler than neutral" Australis design principle.
  Deliberate per-operator-feedback deviation; documented in the
  AUSTRALIS_THEME docstring as a v0.6.2 conscious break with spec.

## Tests + smoke

241/241 GREEN; ruff clean. Live screenshot exports:
- Main app: chrome colors are #1a1a1a / #2a2a2a / #3a3a3a / #6b6b6b
  / #bdbdbd — all neutral grays. Aurora accents preserved as
  textual highlights.
- Agent picker: same — neutral chrome, Aurora accents intact for
  highlighted item border + agent-id-line.

Patch bump (v0.6.1 → v0.6.2): purely cosmetic palette adjustment;
no public-API change.
2026-05-24 18:15:54 -07:00
vh 8463eb22ff docs(contract): amend issue #13 INV-024 for v0.6.1 thinking-current relocation 2026-05-24 15:47:02 -07:00
vh eb93e6d5f0 style(tui): kill remaining blue + thinking-current into pane (v0.6.1)
Three operator-flagged issues:

## 1. "Background is still blue" — Header sub-widgets + scrollbar

Two surviving blue sources after v0.6.0:

- **Header sub-widgets** (HeaderIcon, HeaderTitle, HeaderClock) each
  carry their own `$primary` tint that the parent
  `Header { background }` rule alone doesn't override. Sub-selectors
  added: `Header, HeaderIcon, HeaderTitle, HeaderClock { background:
  $surface; color: $au-bright-blue; }`.
- **Scrollbar gutter** uses Textual's `$primary-tint` (#32436a) by
  default. Per-widget scrollbar overrides: `ListView` (picker) and
  `RichLog` (every pane) get explicit Sea darks for gutter + thumb.

Live verification: both AgentPickerApp and RatatoskrApp now render
ZERO instances of `#6388d8` (Aurora blue) or `#32436a` (its dark
derivative) in the export-screenshot SVG.

## 2. "Picker is bright cyan with unreadable text" — ListView focus

Textual's default `ListView:focus > ListItem.--highlight { background:
$primary }` was overriding my v0.6.0 `#agent-list > ListItem.--highlight
{ background: $au-dark-30 }` because `:focus` carries higher
specificity. The highlighted item was rendering with Aurora-blue
background + bright-blue text = unreadable.

Fix: both selectors targeted explicitly with sufficient specificity:
`ListView > ListItem.--highlight, ListView:focus > ListItem.--highlight
{ background: $au-dark-30 }`. Description text bumped to Sea bright-70
for better contrast against the dark-30 highlight.

## 3. "Streaming everywhere, should just stream in line"

User flagged the disconnect: live thinking rendered above the
TabbedContent in the right-column header, then on closure the content
"moved" to thinking-log inside the Thinking pane. Read as jarring
discontinuity.

Fix: `thinking-current` Static moved INTO the Thinking TabPane (docked
bottom), below `thinking-log`. Both surfaces co-located now — live
streaming + closed runs share the same pane. Operator switches to
Ctrl+3 (Thinking) to see chronological closed runs ABOVE + live
streaming line BELOW. Same pattern as the transcript: closed history
+ inline streaming tail.

Trade-off: live thinking is now visible only when on the Thinking
tab. Pre-v0.6.1 it was always visible above the tabs. The user
explicitly prefers the co-located shape; this is the right call.

## Contract amendment

docs/contracts/issues/13.contract.md INV-024 amended: thinking-current
now docks bottom of the Thinking TabPane (was right-column header).
v0.6.0 layout-spec snapshot updated to reflect the new shape. Drift-
check clean.

## Tests + smoke

241/241 GREEN; ruff clean. Live smoke against personal Worldtree
confirmed:
- thinking-current AND thinking-log both inside thinking-tab.walk_children().
- Post-Done state: 23 closed-run lines in thinking-log, thinking-current
  cleared to empty.
- Picker exports zero blue rects; main App exports zero blue rects.

Patch bump (v0.6.0 → v0.6.1): purely cosmetic + layout adjustment
within the existing pane structure; no public-API change.
2026-05-24 15:46:26 -07:00
vh cfee89ac1c refactor(tui): streaming + turn headers + Thinking pane + picker fix (v0.6.0)
Operator-driven big-batch polish + restructure:

## 1. Streaming text — no more per-token RichLog spam

Pre-v0.6.0, every Text SSE delta wrote its own RichLog line, so
"Let me read the..." became 4+ separate lines (a Worldtree-style
sentence-by-sentence reveal that read as broken). v0.6.0 adds a
`#current-text` Static docked above the prompt; TuiPresenterState
buffers Text deltas in `text_buffer` and updates the Static in
place. On terminal event the Static clears and the transcript
gets:
  - raw=False: post-Done Markdown body + Rule separator
  - raw=True:  accumulated plain text

The Static collapses to height=0 when empty so the prompt sits at
the column bottom unchanged.

## 2. Turn-ID headers across every pane

`_stream_turn_worker` writes a `Rule(title="turn N")` to all four
log panes (transcript, tools, debug, thinking) on the first event
of each new turn. Operators can now visually correlate "what
happened in Tools during turn 42" by section markers in matching
positions across panes.

## 3. New Thinking TabPane (Ctrl+3)

Closed thinking runs now route to `#thinking-log` (a dedicated
TabPane) instead of `#debug-log`. Each closed run writes three
entries:
  - Rule(title="turn N · thinking #K start")
  - Markdown(thinking_content)
  - Rule(title="turn N · thinking #K end")

Model reasoning often has lists/code/structure — rendering as
Markdown (instead of the previous "· thinking: ..." prefix line)
makes it scannable. The `thinking_run_index` counter scopes per
turn so multi-thinking-run turns get distinct markers.

`thinking-current` Static (live per-delta preview) stays in the
right column above TabbedContent (unchanged from v0.5.0) — live
visibility persists across tab switches.

## 4. Agent picker — multi-line items, full description visible

Pre-v0.6.0 the picker rendered each agent as a single Label with
"{id} · {name} — {description}", which truncated descriptions
visually. v0.6.0 uses two Static children per ListItem:
  - bold Aurora bright-blue line: "{agent_id} · {name}"
  - wrapped Sea dark-60 line(s): full description

ListItems are auto-height so long descriptions wrap as needed.
Highlighted (--highlight) row uses Sea dark-30 background instead
of Aurora blue (which the operator flagged as ugly).

## 5. Kill residual blue chrome

The user's "background is still blue" report traced to the prompt
Input's focused border, which I'd set to $primary (Aurora blue).
Switched to $au-bright-cyan (#42dcd1) — focus highlight is now
cyan, consistent with the operator's-voice accent throughout the
TUI. Also added explicit overrides for ContentTabs strip
background + active-tab underline color → Australis cyan.

## 6. Surfaced emotion-appraisal request to worldtree-dev

User asked for emotion-appraisal telemetry, but no SSE event for
this exists in the spec — persona/Vili affect lives in persona.log
(file-tail, blocked on remote-Worldtree topology) and per-character
state (poll endpoint, not per-turn). Posted an althing thread
proposing two shapes (worker_phase payload extension OR new
affect_update event type) and routing the decision to their team.
A 4th `Emotion` TabPane plugs in trivially when a wire event lands.
Low-priority / quality-of-life framing — not blocking ship.

## Contract amendment

docs/contracts/issues/13.contract.md amended in-place: INV-019
extended to 3 TabPanes; new INV-021 (Text → current_text Static),
INV-022 (thinking closed runs → thinking_log with Markdown +
start/end Rules), INV-023 (turn-ID headers across all panes),
INV-024 (thinking-current Static stays in right column with
"thinking… " prefix per v0.5.1 polish). INV-020 (render-exception
fallback routing) updated for Thinking → thinking_log. Drift-check
clean.

## Tests

241 GREEN (down from 244 in test count — 5 routing tests rewritten
for the new shape, replacing the v0.5.0 thinking-in-debug-log
assertions with the v0.6.0 thinking-log-as-Markdown shape; net
test coverage equivalent). ruff clean.

Live smoke against personal Worldtree's mimir confirmed:
  - transcript: 27 lines (turn header + user echo + done +
    markdown body, NO per-token spam)
  - thinking_log: 19 lines (turn header + 2x thinking start/end
    Rule sections with Markdown bodies)
  - current_text cleared post-Done

Minor bump (v0.5.1 → v0.6.0) per SemVer etiquette: visible routing
+ new pane = operator-observable surface change.
2026-05-24 15:29:55 -07:00
vh 7106af5c09 style(tui): UI polish pass (v0.5.1)
Cosmetic refinements on top of v0.5.0's content-only main pane. No
behavior change; ships as a patch bump.

## Color signal — terminal labels tinted per outcome

The transcript's [done]/[error]/[cancelled] labels were plain
foreground (Australis #a9bcc3 white), which made them slow to scan
against the surrounding assistant text. Now tinted per outcome:

- [done]      → Aurora green   (#16B866 / $success)
- [error]     → Dawn red       (#ff491a / $error)
- [cancelled] → Dawn yellow    (#e1c631 / $warning)

The post-Done Rule() separator is also tinted to Australis dark-60
(#86929d) so the streamed-text → markdown-body boundary reads as
chrome, not a content artifact.

## Empty-state placeholders

Tools and Debug panes were stark-empty before any turn fired — easy
to misread as "the pane is broken." Now show placeholder lines on
mount in Sea dark-50 italic:

  Tools tab: (no tool events yet — start a turn that uses tools)
  Debug tab: (waiting for telemetry — start a turn)

The placeholders scroll off naturally as real events fill the panes.

## Live thinking widget self-explains

The thinking-current Static at the top of the right column used to
just display raw thinking content with no context — an operator
glancing at the screen mid-stream might not realize they were
looking at LLM chain-of-thought. Now prefixed with "thinking… " so
the widget self-identifies.

## Spacing + chrome

- Transcript / tools-log / debug-log: 1-cell horizontal padding so
  content doesn't hug the column border.
- thinking-current: italic text-style on top of the dark-60 color,
  so the live-preview band is visually distinct from solid-colored
  log content.
- Active tab in TabbedContent: Aurora bright-cyan label + bold
  text-style, so the eye lands on the currently selected pane.
- Input placeholder text: tinted to Sea dark-50 so it reads as
  placeholder, not content.

## Test impact

3 new tests added (test_done_label_styled_success,
test_empty_state_placeholders_present, plus the polish hits
test_thinking_widget_truncation / test_thinking_coalesce updated for
the "thinking… " prefix). 4 existing tests that checked
`isinstance(w, str) and w.startswith("[done]")` updated to use the
_text_of helper (terminal labels are now RichText, not str).
_spy_writes helper widened to accept positional args after Textual's
internal deferred-render path started passing them positionally
post-Resize.

241/241 GREEN; ruff clean. Live smoke against personal Worldtree
confirmed: Done line renders in Aurora green #16B866 verbatim;
both placeholder lines appear in dark-50; thinking widget shows
"thinking… <content>" during a turn.

Patch bump (v0.5.0 → v0.5.1) per SemVer etiquette: purely cosmetic;
no signature change; no caller-visible behavioral shift.
2026-05-24 15:06:10 -07:00
vh ffd22fb587 refactor(tui): content-only main pane + Debug tab + dark chrome (v0.5.0)
Two operator-driven changes off v0.4.1:

1. **Main pane is content-only.** Pre-v0.5.0 the transcript mixed
   assistant text with telemetry (Thinking closed runs, WorkerPhase,
   TextBoundary) — only tool events were factored out per #13. The
   transcript now receives ONLY: user-prompt echo, assistant Text
   deltas, [done]/[error]/[cancelled] terminal labels, and the
   post-Done Markdown render. All telemetry routes to a new Debug
   tab in the right column.

2. **Chrome no longer blue.** Textual's default Header / Footer /
   active-tab styling tints with `$primary` (Aurora blue under
   Australis), which read as garish on dark terminals. Header,
   Footer, and the TabbedContent tab strip get explicit
   `background: $surface` (Sea bright-black #373b46) so the chrome
   sits cool and unobtrusive against the Ice black background.

## Layout reshape

```
LEFT COLUMN (content only):           RIGHT COLUMN (telemetry):
  transcript (RichLog, 1fr)             thinking-current (Static, dock top)
  prompt (Input, dock bottom)           TabbedContent:
                                          Tools  (tool_start, tool_result)
                                          Debug  (thinking, worker_phase,
                                                  text_boundary)
```

The thinking-current live-preview Static moves from left → right
column so the left column is genuinely content-only. Live thinking
visibility now persists across tab switches (it docks above the
TabbedContent, not inside any tab).

## Presenter routing (TuiPresenterState.render)

Signature widens with `debug_log: RichLog`. Routing matrix:

  Text                       → log (transcript)
  Done / Error / Cancelled   → log (transcript) [terminal labels]
  ToolStart / ToolResult     → tools_log (Tools tab)
  Thinking (closed run)      → debug_log (Debug tab)
  WorkerPhase                → debug_log (Debug tab)
  TextBoundary               → debug_log (Debug tab)
  Thinking (per-delta)       → thinking_widget (live preview)

INV-009 render-exception fallback preserves routing per event class
(new INV-020) — ToolStart/Result falls back to tools_log;
Thinking/WorkerPhase/TextBoundary to debug_log; everything else to log.

## Keybindings

- Ctrl+1 → Tools tab (existing, unchanged)
- Ctrl+2 → Debug tab (NEW)

`pane-name` footer widget updates dynamically as the operator
switches tabs ("Tools" ↔ "Debug"). This was previously deferred to
"the multi-tab issue" per the Volva contract-review amendment;
multi-tab now exists, so the dynamic update lands here.

## Contract amendments

docs/contracts/issues/13.contract.md amended in-place:
- INV-015 amended: transcript is content-only; telemetry routes to
  debug_log. Old routing (telemetry in transcript) retired under the
  no-backwards-compat rule.
- INV-017 amended: thinking-current docks to right column (was left).
- INV-019 new: two TabPanes (Tools + Debug), Ctrl+1/Ctrl+2 bindings,
  dynamic pane-name update.
- INV-020 new: render-exception fallback preserves per-event-class
  routing.
- Layout-spec snapshot ASCII diagram updated.

Drift-check clean.

## Tests

239/239 GREEN (+3 new: debug_tab_exists, ctrl_2_activates_debug_tab,
pane_name_updates_on_tab_switch). 6 existing tests adjusted for the
new routing (test_thinking_closes_one_debuglog_entry,
test_multiple_thinking_runs_each_get_debuglog_entry,
test_render_exception_fallback,
test_cancelled_mid_thinking_closes,
test_worker_phase_demoted_to_debug_log,
test_left_column_content_only). ruff clean.

Live smoke against personal Worldtree: mimir KB-search turn
populated tools_log with 11 lines of tool events (search_library +
read_note); debug_log with 20 lines of worker_phase + thinking
content; transcript stayed content-only with `❯ user-prompt`
(Aurora bright-cyan) + assistant text deltas. Routing matrix
holds end-to-end. (Diagnostic note: RichLog.lines is the rendered-
output buffer; inactive TabPane content shows lines=0 until the
tab activates and renders. Internal write store is correct — this
is a Textual rendering quirk, not a routing bug.)

Minor bump (v0.4.1 → v0.5.0) per SemVer etiquette: visible routing
surface change for operators; transcript and Debug tab contents
look different from yesterday's v0.4.1.
2026-05-24 14:20:35 -07:00
vh 2756f5f1dd style(tui): apply Australis theme to TUI chrome + widgets (v0.4.1)
Retheme the Textual TUI under the Australis Dark colorscheme
(github.com/lkraven/australis) — Aurora blue/cyan/green primary,
Ice neutrals (#222531 bg, #a9bcc3 fg, #cce7ec highlight), Sea
darks for chrome separators, Dawn accents reserved for terminal-
event labels (red/yellow). 16-color cool-tone palette with medium
contrast.

Mechanism: Textual `Theme` API. AUSTRALIS_THEME defined at module
scope mapped to semantic tokens (primary/secondary/accent/success/
warning/error/foreground/background/surface/panel) plus a Sea
variables block (`au-dark-30..60`, `au-bright-70/80/white`, plus
Aurora bright variants). Both `RatatoskrApp` and `AgentPickerApp`
register the theme in `__init__` and set `self.theme = "australis"`.

Per-widget styling via DEFAULT_CSS theme variables (no hex
sprinkled in CSS):

- `#identity` → `$au-bright-blue` (Aurora bright-blue).
- `#pane-name` → `$au-bright-cyan` (current-pane indicator).
- `#hint` → `$au-dark-60` (subtle Ctrl-C state line).
- `#prompt` border → `$panel` unfocused / `$primary` focused
  (focus highlight in Aurora blue).
- `#left-column` gets a `border-right: solid $panel` separator.
- `#thinking-current` color → `$au-dark-60` (matches demoted style).
- `#transcript` + `#tools-log` background → `$background`.
- Agent picker's highlighted ListItem → `$primary` bg +
  `$au-bright-white` fg.

Rich Text styling (where Textual's theme system doesn't apply):

- `_dim()` helper for demoted telemetry now uses explicit
  `#86929d` (Sea dark-60) instead of the terminal-dim filter
  `"dim"`. Renders consistently across emulators and stays
  anchored to the brand palette.
- User-prompt echo (`❯ <content>` in transcript) wrapped in
  `RichText` styled with `#42dcd1` (Aurora bright-cyan) — calls
  out the operator's voice in the primary palette.

CLI mode (`--send`) is unaffected by design — raw stdout has no
theme concept. The retheme is TUI-only.

236/236 tests GREEN; ruff clean. Live smoke against personal
Worldtree: `app.theme == "australis"`, all widget colors resolve
to expected Australis hex values (identity → `#a4c4ff`,
pane-name → `#42dcd1`, hint → `#86929d`).

One existing test adjusted: `test_worker_phase_demoted` previously
asserted `style == "dim"`; now asserts non-empty style (the demoted
style is now an explicit Australis hex, not the terminal "dim"
sentinel). Behavior-equivalent — the demotion intent is preserved.

Patch bump (v0.4.0 → v0.4.1) per SemVer etiquette: cosmetic
refinement of just-shipped surface, no public-API change.
2026-05-24 13:49:36 -07:00
13 changed files with 2560 additions and 372 deletions
+26 -13
View File
@@ -154,10 +154,16 @@ New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` w
- **INV-013**: Layout is `Horizontal` two-column. Left column width = 2fr; right column width = 1fr.
- **INV-014**: `ToolStart` / `ToolResult` events route to `tools_log` (Tools pane), never to `log` (transcript).
- **INV-015**: Every other event type (`Text`, `Thinking`, `WorkerPhase`, `TextBoundary`, `Done`, `Error`, `Cancelled`) keeps its issue-#12 routing target (`log` for chronological entries; `thinking_widget` for live deltas).
- **INV-016**: Input retains keyboard focus across `Ctrl+1` tab switch.
- **INV-017**: `thinking-current` Static docks to the top of the **left column**, not the whole App — TabbedContent's vertical extent on the right is independent of thinking-runs starting/stopping.
- **INV-015** *(amended v0.5.0)*: The transcript (`log`) is **content-only** — receives `Text`, `Done` (label + Markdown body + Rule), `Error`, `Cancelled`, and the user-prompt echo (` <content>`). **All telemetry events (`Thinking` closed runs, `WorkerPhase`, `TextBoundary`) route to `debug_log` (Debug pane), NOT `log`.** Live thinking deltas continue to update `thinking_widget` per-delta. The pre-v0.5.0 shape (telemetry mixed into transcript) is retired under the project's no-backwards-compat rule.
- **INV-016**: Input retains keyboard focus across `Ctrl+1` / `Ctrl+2` tab switches.
- **INV-017** *(amended v0.5.0)*: `thinking-current` Static docks to the top of the **right column** (above `TabbedContent`), not the left column. Live thinking visibility persists across tab switches. v0.5.0 moves it from left → right so the left column is genuinely content-only.
- **INV-018**: CLI mode (`ratatoskr.cli._amain`) is unaffected. CLI keeps inline `· tool_start: …` / `· tool_result: …` rendering on stderr per issue #12 INV-005.
- **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** *(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.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)
@@ -185,21 +191,28 @@ Existing tests that need adjustment (NOT rewrite):
All error routing from issues #4 / #6 / #7 / #12 stays verbatim. The tools_log routing change is internal to the presenter; error paths (`SseConnectFailed`, `SseConnectionDropped`, `MalformedSseId`, `MalformedSseData`, `TurnIdFlip`) all write their labeled lines to `log` (the main transcript). Reason: errors are turn-terminal and need to be visible in the operator's primary attention surface; routing them to the Tools pane would hide them behind a tab switch.
## Layout-spec snapshot (after this issue lands)
## Layout-spec snapshot (after v0.5.0)
```
+────────────────────────────────+──────────────────────+
| · thinking-current | ┌─ Tools ─────────┐ |
| | │ · tool_start:.. │ |
| user-typed line | │ · tool_result.. │ |
| assistant streaming text... | │ |
| [done] turn_id=… duration=… | │ │ |
| | │ │ |
| | │ │ |
| [prompt: type and press Enter]| └─────────────────┘ |
| user-typed line | · thinking-current |
| assistant streaming text... | ┌Tools─┬─Debug─────┐|
| [done] turn_id=… duration=… | │ · tool_start:.. │|
| …markdown render… | │ · tool_result.. │|
| | │ │|
| | │ │|
| | │ │|
| [prompt: type and press Enter]| └─────────────────┘|
+────────────────────────────────+──────────────────────+
| agent · …sess_id Tools Ctrl-C twice to exit |
+───────────────────────────────────────────────────────+
```
(Width split 2fr:1fr; tab strip is Textual-default.)
Left column is content-only (transcript + prompt). Right column hosts the
live `thinking-current` Static at top + `TabbedContent` cycling between
`Tools` (tool events) and `Debug` (thinking closed runs + worker_phase +
text_boundary).
(Width split 2fr:1fr; tab strip is Textual-default; Header/Footer
backgrounds explicitly set to `$surface` to override the Textual default
`$primary`-blue tinting.)
+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
```
+17 -4
View File
@@ -32,10 +32,9 @@ separate dev team rather than an in-tree Worldtree tool.
## Current state / in-flight
_As of 2026-05-24 (post-v0.4.0 §5 entry point: layout reshape +
Tools pane):_
_As of 2026-05-25 (post-v0.8.2 drop double-print; v0.9.0 live-md next):_
**Status: v0.4.0 shipped.** Nine core issues complete (`sse_client`
**Status: v0.8.2 shipped.** Eleven core features complete (`sse_client`
#1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
startup error visibility #6, presenter contract semantics amendment
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
@@ -52,7 +51,21 @@ 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.4.0 feat(tui): issue #13 — §5 layout reshape + Tools pane
- v0.8.2 fix(tui): drop post-Done Markdown body re-render (no double-print)
- `11ef683` fix(tui,sse): inline Text streaming + empty-id keepalive skip (v0.8.1)
- `9fade55` feat(local_agents): JSON-backed local tier-3 index + picker merge (v0.8.0)
- `9918c10` fix(tui): coalesce thinking deltas on `\n` (v0.7.1)
- `c086ae2` feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0)
- `d356990` refactor(tui): thinking streams into thinking-log (v0.6.5)
- `82437bd` style(tui): picker highlighted item → Aurora blue (v0.6.4)
- `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)
- `ffd22fb` refactor(tui): content-only main pane + Debug tab + chrome dark (v0.5.0)
- `2756f5f` style(tui): apply Australis theme to TUI chrome + widgets (v0.4.1)
- `24e4371` feat(tui): issue #13 — §5 layout reshape + Tools pane (v0.4.0)
- `d30be12` feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
- `c85f6bd` fix(tui): anchor layout via dock so Input never moves (v0.2.1)
- `3b9c610` feat(cli,tui): issue #12 — presenter contract semantics amendment (v0.2.0)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.4.0"
version = "0.8.2"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
+134
View File
@@ -0,0 +1,134 @@
"""Local index of tier-3 agents defined via `python -m ratatoskr.tier3`.
Workaround for Worldtree's ``GET /agents`` not returning consumer-defined
agents (the public list excludes tier-3 per-spec; see issue #15 smoke
findings). Local file maintains a list of agent_ids + display metadata so
the picker can show them alongside foundational agents.
Storage shape: JSON at ``$XDG_CONFIG_HOME/ratatoskr/local_agents.json``
(default ``~/.config/ratatoskr/local_agents.json``). Override via
``$RATATOSKR_LOCAL_AGENTS`` env var for tests / per-machine isolation.
If Worldtree later starts returning tier-3 agents in ``GET /agents``, this
module's role narrows to redundant local cache; can be removed cleanly
since the picker's dedup-by-agent-id keeps remote-wins behavior.
Failure modes are lenient: missing file → empty index; corrupt JSON or
schema mismatch → empty index (no crash). The picker continues to show
foundational agents either way; the local-tier-3 surface degrades to
"operator passes --agent ratatoskr:<name> explicitly" — the
pre-v0.8.0 workflow.
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass
from pathlib import Path
_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class LocalAgentEntry:
"""One row in the local tier-3 agent index.
Schema:
- ``agent_id``: full "user_id:agent_name" string (Worldtree-owned).
- ``agent_name``: slug from define (display name).
- ``model``: provider model ID at last define/patch.
- ``description``: synthetic display string (typically derived from
the system_prompt's first line + a "(tier 3)" prefix; the picker
uses this in its ``{id} · {name}{description}`` rendering).
- ``defined_at``: ISO-8601 timestamp from the Tier3AgentInfo response.
"""
agent_id: str
agent_name: str
model: str
description: str
defined_at: str
def _local_agents_path() -> Path:
"""Resolve the local index file path with XDG + env-var override."""
override = os.environ.get("RATATOSKR_LOCAL_AGENTS")
if override:
return Path(override)
xdg = os.environ.get("XDG_CONFIG_HOME")
base = Path(xdg) if xdg else (Path.home() / ".config")
return base / "ratatoskr" / "local_agents.json"
def load_local_agents() -> list[LocalAgentEntry]:
"""Read the local index. Returns ``[]`` on missing file, corrupt JSON,
schema mismatch, or any read error — never raises.
"""
path = _local_agents_path()
if not path.exists():
return []
try:
raw = json.loads(path.read_text())
except (json.JSONDecodeError, OSError):
return []
if not isinstance(raw, dict) or raw.get("version") != _SCHEMA_VERSION:
return []
agents = raw.get("agents", [])
if not isinstance(agents, list):
return []
out: list[LocalAgentEntry] = []
for item in agents:
if not isinstance(item, dict):
continue
try:
out.append(LocalAgentEntry(**item))
except TypeError:
# Malformed row (missing/extra fields) — skip silently.
continue
return out
def _save_local_agents(agents: list[LocalAgentEntry]) -> None:
"""Persist the index. Creates parent dir as needed."""
path = _local_agents_path()
path.parent.mkdir(parents=True, exist_ok=True)
payload = {"version": _SCHEMA_VERSION, "agents": [asdict(a) for a in agents]}
path.write_text(json.dumps(payload, indent=2))
def add_local_agent(entry: LocalAgentEntry) -> None:
"""Add (or replace) an agent in the local index. agent_id is the key."""
agents = [a for a in load_local_agents() if a.agent_id != entry.agent_id]
agents.append(entry)
_save_local_agents(agents)
def update_local_agent(entry: LocalAgentEntry) -> None:
"""Update an existing entry. Identical semantics to ``add_local_agent``
(agent_id is the dedup key), exposed separately so callers can
self-document intent.
"""
add_local_agent(entry)
def remove_local_agent(agent_id: str) -> None:
"""Remove an entry by agent_id. No-op if absent (idempotent)."""
agents = [a for a in load_local_agents() if a.agent_id != agent_id]
_save_local_agents(agents)
def make_description(system_prompt: str) -> str:
"""Synthesize a one-line description for the picker from a system prompt.
Strategy: first non-empty line, stripped of leading markdown heading
markers and whitespace, prefixed with "(tier 3) ", truncated to 80
chars. Falls back to "(tier 3) custom system prompt" if the prompt is
empty (defensive — define rejects empty prompts at PRE-002).
"""
for line in system_prompt.splitlines():
stripped = line.lstrip("# ").strip()
if stripped:
label = f"(tier 3) {stripped}"
return label[:80] + ("" if len(label) > 80 else "")
return "(tier 3) custom system prompt"
+8
View File
@@ -309,6 +309,14 @@ async def _iter_events(
# with a bad id is still a keepalive). Don't reorder.
if sse.data == "":
continue
# v0.8.1: empty-id frames are also treated as keepalives. Worldtree
# SOMETIMES emits events without an `id:` line (observed mid-stream
# on the qwen3.6-35-a3b-heretic provider, 2026-05-25). Per the SSE
# RFC, events without ids are legitimate (they just don't update
# Last-Event-ID); the previous strict behavior crashed every turn
# on the offending agent. Treat same as empty-data: skip silently.
if sse.id == "":
continue
try:
sse_id = _parse_sse_id(sse.id)
except ValueError as exc:
+471
View File
@@ -0,0 +1,471 @@
"""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
from ratatoskr.local_agents import (
LocalAgentEntry,
add_local_agent,
make_description,
)
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,
)
# v0.8.0: persist to local index so the picker can show it.
add_local_agent(
LocalAgentEntry(
agent_id=info.agent_id,
agent_name=info.agent_name,
model=info.model,
description=make_description(info.system_prompt),
defined_at=info.created_at,
)
)
print(f"defined {info.agent_id} ({info.model})")
return 0
async def _run_patch(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT
from ratatoskr.local_agents import (
LocalAgentEntry,
make_description,
update_local_agent,
)
if ns.system_prompt is None and ns.model is None:
raise _Tier3UsageError(
"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,
)
# v0.8.0: refresh local index with the post-patch state.
update_local_agent(
LocalAgentEntry(
agent_id=info.agent_id,
agent_name=info.agent_name,
model=info.model,
description=make_description(info.system_prompt),
defined_at=info.updated_at,
)
)
print(f"patched {info.agent_id}")
return 0
async def _run_delete(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT
from ratatoskr.local_agents import remove_local_agent
async with httpx.AsyncClient(
base_url=server_url,
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)
# v0.8.0: drop from local index so the picker stops listing it.
remove_local_agent(ns.agent_id)
print(f"deleted {ns.agent_id}")
return 0
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())
+457 -84
View File
@@ -10,18 +10,18 @@ from __future__ import annotations
import asyncio
import sys
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import ClassVar, Literal
import httpx
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.theme import Theme
from textual.widgets import (
Footer,
Header,
Input,
Label,
ListItem,
ListView,
RichLog,
@@ -61,6 +61,74 @@ from ratatoskr.sse_client import (
stream_turn,
)
# ---- Australis theme (https://github.com/lkraven/australis) ------------------
#
# 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.
# 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",
primary="#6388D8",
secondary="#00b1a8",
accent="#42dcd1",
success="#16B866",
warning="#e1c631",
error="#ff491a",
foreground="#a9bcc3",
background="#000000",
surface="#373b46",
panel="#414751",
dark=True,
variables={
# 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",
},
)
# Direct hex constants for Rich Text styling (Done/Error/Cancelled labels +
# transcript user-prompt echo). Themes set TCSS variables; Rich's RichText
# style strings live outside the theme system, so we resolve to hex here.
_AU_SUCCESS = "#16B866"
_AU_ERROR = "#ff491a"
_AU_WARNING = "#e1c631"
_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 -------------------------
#
# TuiPresenterState replaces the stateless _render_event_to_log with a stateful
@@ -117,28 +185,46 @@ 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
# 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 = ""
# v0.8.1: same pattern for Text deltas. Pre-v0.8.1 the Text deltas
# streamed into a dedicated #current-text Static below the transcript;
# that Static (docked-bottom, height: auto) grew during streaming and
# visually OVERLAPPED the transcript above (Textual didn't dynamically
# resize the 1fr transcript while the dock-bottom child expanded).
# The Static is gone in v0.8.1 — Text deltas coalesce on `\n` and write
# directly to `log` (transcript), the same shape thinking uses.
text_chunk_buffer: str = ""
def render(
self,
event: Event,
*,
log: RichLog,
thinking_widget: Static,
tools_log: RichLog,
debug_log: RichLog,
thinking_log: RichLog,
raw: bool,
) -> None:
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
Two-views-of-thinking decoupling: per-delta updates go to
`thinking_widget`; one closed entry per run goes to `log`.
v0.8.1 routing:
- `log` (transcript) = chat content: user-prompt echo (written
outside the presenter), coalesced Text deltas, terminal labels,
optional post-Done Markdown body.
- `tools_log` = ToolStart + ToolResult.
- `debug_log` = WorkerPhase + TextBoundary.
- `thinking_log` = streaming Thinking deltas inline (coalesced on
`\n`). Rule(start)/Rule(end) wrap each run.
Issue #13: `ToolStart` / `ToolResult` events route to `tools_log`
(the Tools pane in the right column) instead of `log`. Every other
event keeps its issue-#12 routing.
Exceptions are caught at the presenter boundary (INV-009 fallback).
Exceptions caught at the presenter boundary (INV-009 fallback).
"""
assert isinstance(
event,
@@ -150,64 +236,103 @@ class TuiPresenterState:
from rich.text import Text as RichText
def _dim(s: str) -> RichText:
"""Wrap a demoted-telemetry line in dim style for the RichLog."""
return RichText(s, style="dim")
"""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)
display_text = ("" + acc[-200:]) if len(acc) > 200 else acc
thinking_widget.update(display_text)
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 (one RichLog entry).
# Non-thinking event: close any open thinking run with Rule(end).
if self.thinking_open:
full_thinking = "".join(self.thinking_buffer)
log.write(_dim(f"· thinking: {full_thinking}"))
self.thinking_buffer.clear()
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 "?"
)
thinking_log.write(Rule(
title=f"turn {turn_id} · thinking #{self.thinking_run_index} end",
style=_AU_DEMOTED,
))
self.thinking_open = False
thinking_widget.update("")
thinking_widget.display = False
# Now render the non-thinking event itself.
if isinstance(event, Text):
# Streamed text content — no prefix, no demotion.
log.write(event.content)
# v0.8.1: stream Text deltas into transcript directly,
# coalesced on `\n`. Same pattern as Thinking (v0.7.1).
# The pre-v0.8.1 #current-text Static is gone — its dock-
# bottom growth was overlapping the transcript visually.
self.text_chunk_buffer += event.content
while "\n" in self.text_chunk_buffer:
line, _, rest = self.text_chunk_buffer.partition("\n")
if line:
log.write(line)
self.text_chunk_buffer = rest
return
if isinstance(event, (Done, Error, Cancelled)):
# Terminal events: load-bearing label (no demotion).
# Terminal event: flush any remaining text tail before the
# label / Markdown body lands.
if self.text_chunk_buffer:
log.write(self.text_chunk_buffer)
self.text_chunk_buffer = ""
# Terminal labels tinted per outcome (Aurora green / Dawn red
# / Dawn yellow) for at-a-glance scanning.
if isinstance(event, Done):
log.write(
log.write(RichText(
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
f"duration={_format_duration_ms(event.duration_ms)} "
f"usage {_format_usage(event.usage, arrow='')}"
)
if not raw:
from rich.markdown import Markdown
from rich.rule import Rule
log.write(Rule())
log.write(Markdown(event.response))
f"usage {_format_usage(event.usage, arrow='')}",
style=_AU_SUCCESS,
))
# v0.8.2: post-Done Markdown body re-render dropped. Pre-
# v0.8.2 the transcript got BOTH the streamed text AND
# the Markdown(response) re-render — same content twice,
# operator-flagged as "double prints". The streamed text
# IS the response now; markdown formatting (bold, lists,
# code) renders as plain text. Matches thinking pane's
# stream-as-content semantics (no post-close re-render).
elif isinstance(event, Error):
log.write(
log.write(RichText(
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
f"message={event.message!r}"
)
f"message={event.message!r}",
style=_AU_ERROR,
))
else: # Cancelled
log.write(
log.write(RichText(
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
f"partial_message_id={event.partial_message_id}"
)
# 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
f"partial_message_id={event.partial_message_id}",
style=_AU_WARNING,
))
return
if isinstance(event, WorkerPhase):
log.write(_dim(
# v0.5.0: telemetry → Debug pane, not transcript.
debug_log.write(_dim(
f"· worker_phase: phase={event.phase} turn_id={event.turn_id}"
))
return
@@ -225,7 +350,8 @@ class TuiPresenterState:
))
return
if isinstance(event, TextBoundary):
log.write(_dim(
# v0.5.0: telemetry → Debug pane, not transcript.
debug_log.write(_dim(
f"· text_boundary: kind={event.kind} char_offset={event.char_offset}"
))
return
@@ -234,10 +360,20 @@ class TuiPresenterState:
# the original event AND a render_error line with the class name only
# (NO exception message — security clause). Volva F1 fix.
#
# Issue #13: routing-under-failure preservation — ToolStart/ToolResult
# fallback writes go to tools_log (the routed destination per
# INV-014), not the transcript. Every other event falls back to log.
target = tools_log if isinstance(event, (ToolStart, ToolResult)) else log
# v0.6.0 routing-under-failure preservation — fallback writes go
# to the same destination the successful render would have used:
# - ToolStart/ToolResult → tools_log
# - Thinking → thinking_log
# - WorkerPhase/TextBoundary → debug_log
# - everything else → log
if isinstance(event, (ToolStart, ToolResult)):
target = tools_log
elif isinstance(event, Thinking):
target = thinking_log
elif isinstance(event, (WorkerPhase, TextBoundary)):
target = debug_log
else:
target = log
target.write(_plain_label(event))
target.write(f"[render_error] {type(exc).__name__}")
@@ -253,13 +389,74 @@ class AgentPickerApp(App[str | None]):
"""
DEFAULT_CSS = """
/* v0.6.1: kill Textual's $primary-blue tints everywhere — Header sub-
widgets (HeaderIcon etc.) have their own $primary tinting that the
parent `Header { background: $surface }` rule alone doesn't cover.
Sub-selectors force the cool palette down to every level. */
Header, HeaderIcon, HeaderTitle, HeaderClock {
background: $surface;
color: $au-bright-blue;
}
Footer {
background: $surface;
}
/* v0.6.1: scrollbar uses Textual's $primary-tint by default. Force
Australis Sea darks so the scrollbar gutter doesn't read as a blue
strip. Applied to ListView (the scrollable widget here). */
ListView {
scrollbar-background: $background;
scrollbar-background-hover: $background;
scrollbar-background-active: $background;
scrollbar-color: $au-dark-50;
scrollbar-color-hover: $au-dark-60;
scrollbar-color-active: $au-bright-cyan;
}
#picker-prompt {
dock: top;
height: 1;
padding: 0 1;
color: $au-bright-cyan;
background: $surface;
}
#agent-list {
height: 1fr;
background: $background;
}
/* Multi-line agent items. Each ListItem is auto-height so the full
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.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;
}
#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;
}
.agent-desc {
color: $au-bright-70;
}
"""
@@ -276,13 +473,22 @@ class AgentPickerApp(App[str | None]):
# [no_agents] before constructing the picker.
assert agents
self.agents = agents
self.register_theme(AUSTRALIS_THEME)
self.theme = "australis"
def compose(self) -> ComposeResult:
yield Header()
yield Static("Pick an agent for the new session:", id="picker-prompt")
# v0.6.0: each ListItem has two Static children — the id/name line
# in bold blue + the wrapped description in muted dark-60. No
# description truncation; tall items breathe so the operator can
# actually read what each agent does.
yield ListView(
*[
ListItem(Label(f"{a.agent_id} · {a.name}{a.description}"))
ListItem(
Static(f"{a.agent_id} · {a.name}", classes="agent-id-line"),
Static(a.description, classes="agent-desc"),
)
for a in self.agents
],
id="agent-list",
@@ -306,55 +512,122 @@ class AgentPickerApp(App[str | None]):
class RatatoskrApp(App[int]):
"""Textual TUI shell — single chat pane."""
# Issue #13: Horizontal two-column layout per design-brief §5.
# Left column (2fr) is the chat surface; right column (1fr) is the
# TabbedContent housing side panes. v1 has only the Tools tab.
# Issue #13 + v0.5.0 follow-up: Horizontal two-column layout per
# design-brief §5. Left column (2fr) is the **content-only** chat
# surface — assistant text, user prompt echo, [done]/[error]/[cancelled]
# terminal labels, post-Done markdown render. Right column (1fr) houses
# ALL telemetry: live thinking preview docked above TabbedContent;
# tab strip carries Tools (ToolStart/ToolResult) + Debug (Thinking
# closed runs + WorkerPhase + TextBoundary).
#
# Dock rules narrow to per-container scope so thinking-current toggling
# in the left column doesn't reflow the right column's TabbedContent.
# The v0.2.1 layout-stability property is preserved within the left
# column by docking thinking-current top + prompt bottom of that column.
# v0.5.0 routing change: thinking-current Static moved from left column
# to right column header so the left column is genuinely content-only;
# closed thinking runs go to debug-log instead of transcript.
#
# v0.5.0 chrome fix: Header/Footer backgrounds explicitly set to $surface
# (Sea bright-black #373b46) overriding Textual's default $primary-blue
# tinting. TabbedContent active-tab tinting also softened.
#
# Australis theme variables ($primary/$accent/$au-dark-60/$au-bright-cyan/
# etc.) carry colors so a future theme swap rebinds centrally.
DEFAULT_CSS = """
/* v0.6.1: kill Textual's default $primary-blue tinting on chrome —
Header sub-widgets (HeaderIcon, HeaderTitle, HeaderClock) each carry
their own $primary tint that the parent `Header { background }` rule
doesn't override; sub-selectors force the cool palette down. */
Header, HeaderIcon, HeaderTitle, HeaderClock {
background: $surface;
color: $au-bright-blue;
}
Footer {
background: $surface;
}
/* v0.6.1: scrollbars default to $primary-tint blue. Force Sea darks
on the scrollable widgets (RichLog instances). */
RichLog {
scrollbar-background: $background;
scrollbar-background-hover: $background;
scrollbar-background-active: $background;
scrollbar-color: $au-dark-50;
scrollbar-color-hover: $au-dark-60;
scrollbar-color-active: $au-bright-cyan;
}
#main-row {
height: 1fr;
}
#left-column {
width: 2fr;
border-right: solid $panel;
}
#right-column {
width: 1fr;
}
#thinking-current {
dock: top;
height: auto;
}
/* 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;
padding: 0 1;
}
/* v0.8.1: #current-text Static removed. Streaming text now coalesces
on `\n` and writes directly to #transcript (same pattern as v0.7.1
thinking fix). Eliminates the dock-bottom-growth-overlap bug. */
#tools-log, #debug-log, #thinking-log {
background: $background;
padding: 0 1;
}
/* Tab strip + active-tab underline — kill blue, use Australis cyan. */
#side-panes > ContentTabs {
background: $surface;
}
#side-panes ContentTab.-active {
color: $au-bright-cyan;
text-style: bold;
}
#side-panes Underline > .underline--bar {
color: $au-bright-cyan;
}
#prompt {
dock: bottom;
border: tall $panel;
}
/* v0.6.0: focused border uses Australis bright-cyan instead of $primary
(Aurora blue) — kills the lingering blue tint the user flagged. */
#prompt:focus {
border: tall $au-bright-cyan;
}
/* Placeholder text in the Input — dimmer than typed content. */
#prompt > .input--placeholder {
color: $au-dark-50;
}
#identity {
dock: bottom;
height: 1;
color: $au-bright-blue;
padding: 0 1;
}
#pane-name {
dock: bottom;
height: 1;
color: $au-bright-cyan;
padding: 0 1;
}
#hint {
dock: bottom;
height: 1;
color: $au-dark-60;
padding: 0 1;
}
"""
BINDINGS: ClassVar[list[Binding]] = [
Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True),
Binding("ctrl+d", "quit", "Exit immediately", priority=True),
# Issue #13: §5 keybinding family Ctrl+1..5 jumps between side panes
# without losing Input focus (INV-016). v1 only has Tools; Ctrl+2..5
# land as Persona/AdminEvents/BifrostState/ServerLog panes ship.
# §5 keybinding family Ctrl+1..5 jumps between side panes
# without losing Input focus (INV-016).
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False),
Binding("ctrl+2", "focus_debug", "Debug tab", priority=False),
Binding("ctrl+3", "focus_thinking", "Thinking tab", priority=False),
]
HINT_IDLE = "Ctrl-C twice to exit"
@@ -378,20 +651,25 @@ class RatatoskrApp(App[int]):
self.active_turn_id: int | None = None
self.stream_worker = None
self.hint: str = self.HINT_IDLE
self.register_theme(AUSTRALIS_THEME)
self.theme = "australis"
def compose(self) -> ComposeResult:
yield Header()
# Issue #13: Horizontal two-column layout. Left column = chat surface
# (thinking-current docked top, transcript fills middle, prompt docked
# bottom). Right column = TabbedContent for side panes (v1: Tools only).
# markup=False on RichLog so labeled lines like "[cancel_failed] ..."
# render verbatim; Rich would otherwise interpret bracket spans as
# style markup. The post-Done markdown render uses Markdown() directly
# which is a Rich Renderable and renders correctly without
# widget-level markup=True.
# v0.6.0 layout: left column is content-only (transcript + streaming
# text Static + prompt). Right column hosts thinking-current live
# preview above TabbedContent cycling Tools / Debug / Thinking.
#
# The current-text Static buffers in-flight assistant tokens so
# streaming doesn't spam the RichLog with one line per delta —
# the operator sees a single growing live line, then on Done the
# Static clears and the final Markdown body lands in the transcript.
#
# markup=False on RichLog so labeled lines render verbatim; the
# post-Done Markdown() / Rule() renders are Rich Renderables and
# work without widget-level markup=True.
with Horizontal(id="main-row"):
with Vertical(id="left-column"):
yield Static("", id="thinking-current")
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
yield Input(id="prompt", placeholder="Type a message and press Enter")
with Vertical(id="right-column"):
@@ -400,8 +678,22 @@ class RatatoskrApp(App[int]):
yield RichLog(
id="tools-log", wrap=True, markup=False, highlight=False
)
with TabPane("Debug", id="debug-tab"):
yield RichLog(
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
)
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
# INV (issue #13): pane-name widget displays current side-pane name.
# pane-name widget displays current side-pane name.
yield Static("", id="identity")
yield Static("Tools", id="pane-name")
yield Static(self.HINT_IDLE, id="hint")
@@ -418,11 +710,48 @@ 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
# placeholder text, not real telemetry. Disappear naturally as the
# log fills with real events (the placeholders scroll off the top).
from rich.text import Text as RichText
placeholder_style = f"{_AU_DEMOTED_FAINT} italic"
self.query_one("#tools-log", RichLog).write(
RichText("(no tool events yet — start a turn that uses tools)",
style=placeholder_style)
)
self.query_one("#debug-log", RichLog).write(
RichText("(waiting for worker_phase + text_boundary telemetry)",
style=placeholder_style)
)
self.query_one("#thinking-log", RichLog).write(
RichText("(no chain-of-thought captured yet — start a turn)",
style=placeholder_style)
)
self.state = "idle"
self._set_hint(self.HINT_IDLE)
def _write_turn_headers(self, turn_id: int) -> None:
"""v0.6.0: Write `── turn N ──` Rule headers across every pane so
operators can visually correlate sections during cross-pane
debugging. Called from `_stream_turn_worker` on first event of
each new turn (idempotent per turn via active_turn_id guard).
"""
from rich.rule import Rule
title = f"turn {turn_id}"
rule = Rule(title=title, style=_AU_DEMOTED)
try:
self.query_one("#transcript", RichLog).write(rule)
self.query_one("#tools-log", RichLog).write(rule)
self.query_one("#debug-log", RichLog).write(rule)
self.query_one("#thinking-log", RichLog).write(rule)
except Exception:
# Defensive: widget tree may be tearing down — never let a
# turn-header write block the SSE consumer.
pass
def _set_hint(self, hint: str) -> None:
"""Set the hint state attribute AND update the visible Static widget."""
self.hint = hint
@@ -444,7 +773,10 @@ class RatatoskrApp(App[int]):
content = event.input.value.strip()
if not content:
return
log.write(f" {content}") # noqa: RUF001 — intentional INV-006 prefix
# v0.4.1 retheme: operator's voice gets Australis bright cyan so it
# stands out against the default-foreground assistant text below it.
from rich.text import Text as RichText
log.write(RichText(f" {content}", style=_AU_USER_ECHO)) # noqa: RUF001
event.input.value = ""
self.state = "streaming"
self._set_hint(self.HINT_STREAMING)
@@ -458,19 +790,24 @@ 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)
# Issue #13: tools_log routes ToolStart/ToolResult into the Tools pane.
tools_log = self.query_one("#tools-log", RichLog)
debug_log = self.query_one("#debug-log", RichLog)
thinking_log = self.query_one("#thinking-log", RichLog)
presenter = TuiPresenterState()
try:
async for event in stream_turn(self.client, self.session_id, content):
if self.active_turn_id is None:
self.active_turn_id = event.sse_id.turn_id
# v0.6.0: turn-ID headers across all panes so the
# operator can visually correlate sections during
# cross-pane debugging.
self._write_turn_headers(self.active_turn_id)
presenter.render(
event,
log=log,
thinking_widget=thinking_widget,
tools_log=tools_log,
debug_log=debug_log,
thinking_log=thinking_log,
raw=self.args.raw,
)
if isinstance(event, (Done, Error, Cancelled)):
@@ -531,6 +868,17 @@ class RatatoskrApp(App[int]):
— `test_ctrl_1_preserves_input_focus` is the regression guard.
"""
self.query_one("#side-panes", TabbedContent).active = "tools-tab"
self.query_one("#pane-name", Static).update("Tools")
def action_focus_debug(self) -> None:
"""v0.5.0: Ctrl+2 activates the Debug tab. INV-016 preserves Input focus."""
self.query_one("#side-panes", TabbedContent).active = "debug-tab"
self.query_one("#pane-name", Static).update("Debug")
def action_focus_thinking(self) -> None:
"""v0.6.0: Ctrl+3 activates the Thinking tab. INV-016 preserves Input focus."""
self.query_one("#side-panes", TabbedContent).active = "thinking-tab"
self.query_one("#pane-name", Static).update("Thinking")
def run_tui(args: ParsedArgs) -> int:
@@ -570,6 +918,14 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
# Issue #8: startup agent picker — fetch GET /agents and prompt when
# --new is passed without --agent. list_agents errors land on real
# stderr before any alt-screen opens (preserves issue #6 INV-001).
#
# v0.8.0: merge in local tier-3 agent index. Worldtree's GET /agents
# doesn't return consumer-defined agents (issue #15 smoke finding);
# ratatoskr keeps its own JSON-backed index of agents the operator
# defined via `python -m ratatoskr.tier3 define`. Merged here so the
# picker shows foundational + local-tier-3 in one list. Dedup by
# agent_id (remote wins on conflict, since a server-listed agent
# is the authoritative source).
chosen_agent_id: str | None = args.agent_id
if args.new and args.agent_id is None:
try:
@@ -582,6 +938,23 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
# v0.8.0: append local tier-3 entries not already in the remote list.
from ratatoskr.local_agents import load_local_agents
remote_ids = {a.agent_id for a in agents}
for entry in load_local_agents():
if entry.agent_id in remote_ids:
continue
agents.append(AgentInfo(
agent_id=entry.agent_id,
name=entry.agent_name,
description=entry.description,
version=None,
capabilities=[],
supported_models=[],
persona_traits={},
ui_hints={},
))
if not agents:
sys.stderr.write("[no_agents] server returned empty agent list\n")
return 13
+187
View File
@@ -0,0 +1,187 @@
"""Tests for ratatoskr.local_agents.
Use ``$RATATOSKR_LOCAL_AGENTS`` env-var override + pytest tmp_path to
isolate from the operator's real ``~/.config/ratatoskr/local_agents.json``.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from ratatoskr.local_agents import (
LocalAgentEntry,
_local_agents_path,
add_local_agent,
load_local_agents,
make_description,
remove_local_agent,
update_local_agent,
)
@pytest.fixture
def local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point $RATATOSKR_LOCAL_AGENTS at a fresh tmp file for the test."""
path = tmp_path / "local_agents.json"
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(path))
return path
def _entry(
agent_id: str = "ratatoskr:wizard",
agent_name: str = "wizard",
model: str = "qwen3.6-35-a3b",
description: str = "(tier 3) test agent",
defined_at: str = "2026-05-25T00:00:00+00:00",
) -> LocalAgentEntry:
return LocalAgentEntry(
agent_id=agent_id,
agent_name=agent_name,
model=model,
description=description,
defined_at=defined_at,
)
class TestPathResolution:
def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", "/tmp/custom-agents.json")
assert _local_agents_path() == Path("/tmp/custom-agents.json")
def test_xdg_config_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("RATATOSKR_LOCAL_AGENTS", raising=False)
monkeypatch.setenv("XDG_CONFIG_HOME", "/tmp/xdg-config")
assert (
_local_agents_path()
== Path("/tmp/xdg-config/ratatoskr/local_agents.json")
)
def test_default_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("RATATOSKR_LOCAL_AGENTS", raising=False)
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
path = _local_agents_path()
assert path == Path.home() / ".config" / "ratatoskr" / "local_agents.json"
class TestLoadEmpty:
def test_missing_file_returns_empty(self, local_path: Path) -> None:
assert not local_path.exists()
assert load_local_agents() == []
def test_corrupt_json_returns_empty(self, local_path: Path) -> None:
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text("not json at all")
assert load_local_agents() == []
def test_wrong_schema_version_returns_empty(self, local_path: Path) -> None:
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text(json.dumps({"version": 999, "agents": []}))
assert load_local_agents() == []
def test_missing_version_key_returns_empty(self, local_path: Path) -> None:
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text(json.dumps({"agents": []}))
assert load_local_agents() == []
def test_malformed_row_skipped(self, local_path: Path) -> None:
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text(
json.dumps(
{
"version": 1,
"agents": [
{"agent_id": "incomplete"}, # missing required fields
{
"agent_id": "ratatoskr:good",
"agent_name": "good",
"model": "m",
"description": "d",
"defined_at": "t",
},
],
}
)
)
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:good"
class TestAdd:
def test_add_one(self, local_path: Path) -> None:
add_local_agent(_entry())
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:wizard"
def test_add_two_different(self, local_path: Path) -> None:
add_local_agent(_entry(agent_id="ratatoskr:a", agent_name="a"))
add_local_agent(_entry(agent_id="ratatoskr:b", agent_name="b"))
ids = {e.agent_id for e in load_local_agents()}
assert ids == {"ratatoskr:a", "ratatoskr:b"}
def test_add_replaces_same_id(self, local_path: Path) -> None:
add_local_agent(_entry(model="old-model"))
add_local_agent(_entry(model="new-model"))
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].model == "new-model"
def test_creates_parent_dirs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
nested = tmp_path / "deep" / "nested" / "path" / "agents.json"
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(nested))
add_local_agent(_entry())
assert nested.exists()
class TestUpdate:
def test_update_changes_existing(self, local_path: Path) -> None:
add_local_agent(_entry(model="v1"))
update_local_agent(_entry(model="v2"))
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].model == "v2"
class TestRemove:
def test_remove_existing(self, local_path: Path) -> None:
add_local_agent(_entry())
remove_local_agent("ratatoskr:wizard")
assert load_local_agents() == []
def test_remove_missing_is_noop(self, local_path: Path) -> None:
add_local_agent(_entry())
remove_local_agent("ratatoskr:doesnotexist")
assert len(load_local_agents()) == 1
class TestMakeDescription:
def test_first_nonempty_line(self) -> None:
prompt = "\n\n# IDENTITY\nYou are a test agent..."
desc = make_description(prompt)
assert desc.startswith("(tier 3) IDENTITY")
def test_strips_heading_markers(self) -> None:
prompt = "# A nice heading\nMore prompt..."
desc = make_description(prompt)
assert "(tier 3) A nice heading" == desc
def test_truncates_long(self) -> None:
prompt = "x" * 200
desc = make_description(prompt)
# 80 char cap including the prefix
assert len(desc) == 81 # 80 + ellipsis char
assert desc.endswith("")
def test_empty_prompt_fallback(self) -> None:
desc = make_description("")
assert desc == "(tier 3) custom system prompt"
def test_whitespace_only_fallback(self) -> None:
desc = make_description(" \n\n ")
assert desc == "(tier 3) custom system prompt"
+41
View File
@@ -711,6 +711,47 @@ def _sse_raw_chunk(sse_id: str, raw_data: str) -> bytes:
return f"id: {sse_id}\ndata: {raw_data}\n\n".encode()
def _sse_no_id_chunk(data: str) -> bytes:
"""SSE frame with NO id line + arbitrary data (v0.8.1: keepalive shape)."""
return f"data: {data}\n\n".encode()
class TestEmptyIdSkipped:
@respx.mock
async def test_empty_id_on_first_event_skipped(self) -> None:
"""empty_id_on_first_event_skipped [v0.8.1]: stream starts with an
event carrying NO `id:` line → httpx_sse exposes sse.id == ''
(no prior id to inherit). Pre-v0.8.1: MalformedSseId raw='' crashed
the turn. v0.8.1: treat same as empty-data keepalive — skip silently.
Observed 2026-05-25 on Worldtree's qwen3.6-35-a3b-heretic provider:
the first stream frame had no id line, every turn died with
`[malformed_sse_id] raw=''`.
"""
from ratatoskr.sse_client import Done as _Done
from ratatoskr.sse_client import Text as _Text
# First frame: no id line (httpx_sse → sse.id = ""). Skip it.
# Subsequent frames have ids; normal processing resumes.
stream = (
_sse_no_id_chunk('{"type":"keepalive"}') # ← skipped (sse.id == "")
+ _sse_chunk("42:1", {"type": "text", "content": "first"})
+ _sse_chunk("42:2", _DONE_42_6)
)
respx.post("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=stream
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_turn(client, "s1", "hi")]
# 2 events — the no-id frame is invisible (no MalformedSseId crash).
assert len(events) == 2
assert isinstance(events[0], _Text)
assert events[0].content == "first"
assert isinstance(events[1], _Done)
class TestEmptyDataSkipped:
@respx.mock
async def test_empty_data_skipped(self) -> None:
+491
View File
@@ -0,0 +1,491 @@
"""Tests for ratatoskr.tier3 per docs/contracts/issues/15.contract.md."""
from pathlib import Path
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
@pytest.fixture
def _isolated_local_agents(
tmp_path: "Path", monkeypatch: pytest.MonkeyPatch
) -> "Path":
"""Isolate the v0.8.0 local-tier-3 index from the operator's real file."""
path = tmp_path / "local_agents.json"
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(path))
return path
class TestCli:
@respx.mock
def test_cli_define_happy(
self,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
_isolated_local_agents: "Path",
) -> None:
"""cli_define_happy [happy]: argv → 201 mock → stdout confirmation;
local index updated with the new entry (v0.8.0 hook).
"""
from ratatoskr.local_agents import load_local_agents
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.post("https://w.example/agents/define").mock(
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)"
# v0.8.0: local index now has the new entry.
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:wizard"
assert entries[0].model == "qwen3.6-35-a3b"
@respx.mock
def test_cli_patch_happy(
self,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
_isolated_local_agents: "Path",
) -> None:
"""cli_patch_happy [happy]: argv → 200 mock → stdout confirmation;
local index refreshed with the post-patch state.
"""
from ratatoskr.local_agents import load_local_agents
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
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"
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:wizard"
@respx.mock
def test_cli_delete_happy(
self,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
_isolated_local_agents: "Path",
) -> None:
"""cli_delete_happy [happy]: argv → 204 mock → stdout confirmation;
local index entry removed (v0.8.0 hook).
"""
from ratatoskr.local_agents import (
LocalAgentEntry,
add_local_agent,
load_local_agents,
)
# Pre-populate so we can verify removal.
add_local_agent(LocalAgentEntry(
agent_id="ratatoskr:wizard",
agent_name="wizard",
model="m",
description="d",
defined_at="t",
))
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.delete("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(204)
)
rc = main(["delete", "ratatoskr:wizard"])
out = capsys.readouterr()
assert rc == 0
assert out.out.strip() == "deleted ratatoskr:wizard"
assert load_local_agents() == []
def test_cli_missing_auth(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> 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
+436 -269
View File
@@ -1,5 +1,6 @@
"""Tests for ratatoskr.tui per docs/contracts/issues/4.contract.md."""
from pathlib import Path
from unittest.mock import MagicMock
import httpx
@@ -11,7 +12,6 @@ from ratatoskr.cli import ParsedArgs
from ratatoskr.sse_client import (
Cancelled,
Done,
Error,
SseId,
Text,
Thinking,
@@ -61,13 +61,18 @@ def _args_existing(session_id: str = "s-1existing", **overrides) -> ParsedArgs:
def _spy_writes(monkeypatch) -> list:
"""Patch RichLog.write to record every arg into a list (returned)."""
"""Patch RichLog.write to record every arg into a list (returned).
Accepts *args/**kwargs so Textual's internal deferred-render path
(which calls write positionally with width/expand/shrink/scroll_end)
still works after a write-during-mount + Resize sequence.
"""
writes: list = []
original = RichLog.write
def spy(self, content, **kw):
def spy(self, content, *args, **kw):
writes.append(content)
return original(self, content, **kw)
return original(self, content, *args, **kw)
monkeypatch.setattr(RichLog, "write", spy)
return writes
@@ -111,204 +116,175 @@ 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(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="b"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="c"),
log=log,
thinking_widget=widget,
tools_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)
assert widget.update.call_args_list[-1][0][0] == "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(),
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_one_richlog_entry(self) -> None:
"""thinking_closes_one_richlog_entry [happy]: 2x Thinking + WorkerPhase →
RichLog has ONE closed thinking entry + one worker_phase entry; 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
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(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="b"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
for chunk in ("Hello", " world", "\n"):
state.render(
Thinking(sse_id=SID, content=chunk),
log=MagicMock(),
tools_log=MagicMock(),
debug_log=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.rule import Rule
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
debug_log = MagicMock()
thinking_log = MagicMock()
state = TuiPresenterState()
for content in ("a", "b"):
state.render(
Thinking(sse_id=SID, content=content),
log=log,
tools_log=MagicMock(),
debug_log=debug_log,
thinking_log=thinking_log,
raw=False,
)
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
thinking_log=thinking_log,
raw=False,
)
# Closure wrote "· thinking: ab"; then worker_phase wrote "· worker_phase: ..."
assert log.write.call_count == 2
# First write = closed thinking entry containing the full accumulated text
assert "· thinking: ab" in log.write.call_args_list[0][0][0]
# Second write = worker_phase with demotion prefix
assert "· worker_phase:" in log.write.call_args_list[1][0][0]
# Widget cleared + hidden
widget.update.assert_called_with("")
assert widget.display is False
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
# 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
def test_thinking_widget_truncation(self) -> None:
"""thinking_widget_truncation [trace]: buffer 500 chars → widget shows "" + last 200."""
from ratatoskr.tui import TuiPresenterState
# v0.6.5: thinking-current Static removed; test_thinking_widget_truncation
# and test_thinking_widget_visibility_lifecycle deleted (no longer apply).
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(),
raw=False,
)
last_update = widget.update.call_args_list[-1][0][0]
# …-prefix + last-200 = 201 chars
assert last_update.startswith("")
assert len(last_update) == 201
def test_thinking_widget_visibility_lifecycle(self) -> None:
"""thinking_widget_visibility_lifecycle [trace]: hidden at start; visible during thinking;
hidden after closing event.
def test_multiple_thinking_runs_each_get_thinking_log_section(self) -> None:
"""multiple_thinking_runs_each_get_section [scenario, v0.8.1]:
Thinking → Text → Thinking → Done → TWO start/end Rule pairs in
thinking_log (deltas coalesced into tail-flushes per run).
Text deltas now stream into the transcript via coalesce-on-newline
(no current-text Static); "hi" with no `\\n` stays buffered until
Done's tail-flush.
"""
from rich.rule import Rule
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
widget.display = False # initial state (composed hidden)
thinking_log = MagicMock()
state = TuiPresenterState()
# First thinking delta → widget visible
state.render(
Thinking(sse_id=SID, content="x"),
log=log,
thinking_widget=widget,
tools_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(),
raw=False,
)
assert widget.display is False
def test_multiple_thinking_runs_each_get_richlog_entry(self) -> None:
"""multiple_thinking_runs_each_get_richlog_entry [scenario]:
Thinking → Text → Thinking → Done → TWO closed thinking RichLog entries.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
for evt in (
Thinking(sse_id=SID, content="first"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Text(sse_id=SID, content="hi"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="second"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
# Close the second run with a Done.
):
state.render(
evt, log=log,
tools_log=MagicMock(), debug_log=MagicMock(),
thinking_log=thinking_log, raw=False,
)
state.render(
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=True
_make_tui_done(),
log=log,
tools_log=MagicMock(), debug_log=MagicMock(),
thinking_log=thinking_log, raw=False,
)
# Count closed thinking entries — now dim RichText; plain text starts with "· thinking:".
thinking_entries = [_text_of(call[0][0]) for call in log.write.call_args_list]
thinking_entries = [t for t in thinking_entries if t.startswith("· thinking:")]
assert len(thinking_entries) == 2
assert "first" in thinking_entries[0]
assert "second" in thinking_entries[1]
# thinking_log: 4 Rules (start+end per run) + 2 tail-flush strings.
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)]
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 "first" in delta_strs
assert "second" in delta_strs
# v0.8.1: Text "hi" flushes as a line in transcript on Done.
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
assert "hi" in log_writes
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]:
widget.update raises → RichLog gets BOTH a plain-labeled fallback line for
the original event AND a `[render_error] <ExceptionClassName>` line
(NO exception message per INV-009 security clause); state does NOT propagate.
"""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()
widget = MagicMock()
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
thinking_log = MagicMock()
# 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()
# Should not raise; should write a fallback labeled line + a [render_error] line.
state.render(
Thinking(sse_id=SID, content="x"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
thinking_log=thinking_log,
raw=False,
)
writes = [call[0][0] for call in log.write.call_args_list if isinstance(call[0][0], str)]
# POST-007: plain-label fallback for the original Thinking event (pre-amendment shape).
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
# POST-007: render_error line with class name ONLY.
assert any(w == "[render_error] AttributeError" for w in writes), writes
# Critical: exception message MUST NOT appear in any write (INV-009 security).
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:
"""state_reset_per_worker [trace]: fresh TuiPresenterState() starts no thinking open."""
@@ -318,8 +294,9 @@ class TestTuiPresenterState:
s1.render(
Thinking(sse_id=SID, content="x"),
log=MagicMock(),
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
thinking_log=MagicMock(),
raw=False,
)
s2 = TuiPresenterState()
@@ -327,39 +304,41 @@ class TestTuiPresenterState:
assert s2.thinking_open is False
def test_cancelled_mid_thinking_closes(self) -> None:
"""cancelled_mid_thinking_closes [scenario]:
Thinking, Cancelled → ONE closed thinking entry + a [cancelled] entry; widget hidden.
"""cancelled_mid_thinking_closes [scenario, v0.5.0]:
Thinking, Cancelled → ONE closed thinking entry in debug_log + a
[cancelled] entry in transcript; widget hidden.
"""
from ratatoskr.tui import TuiPresenterState
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(),
raw=False,
debug_log=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(),
raw=False,
debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
)
# Closed thinking entries are now dim RichText; terminal labels are plain str.
writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
assert any(w.startswith("· thinking: partial") for w in writes)
assert any(w.startswith("[cancelled]") for w in writes)
assert widget.display is False
# 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)
# 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]:
Text("hi"), Done(response="hi") with raw=False → [done] label, Rule, Markdown in RichLog.
def test_done_flushes_tail_and_writes_label(self) -> None:
"""done_flushes_tail_and_writes_label [happy, v0.8.2]:
Text("hi") buffers in text_chunk_buffer (no `\\n`). Done flushes
"hi" tail to transcript, then writes [done] label. v0.8.2 drops
the post-Done Markdown body re-render — streamed text is the
canonical content (no double-print).
"""
from rich.markdown import Markdown
from rich.rule import Rule
@@ -367,26 +346,33 @@ 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(),
thinking_log=MagicMock(),
raw=False,
)
assert not log.write.called
assert state.text_chunk_buffer == "hi"
state.render(
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=False
_make_tui_done(),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
thinking_log=MagicMock(),
raw=False,
)
# On Done: tail flush "hi" + [done] label. No Markdown, no Rule.
writes = [c[0][0] for c in log.write.call_args_list]
# Text stream wrote "hi" with no prefix.
assert "hi" in writes
# [done] label wrote.
assert any(isinstance(w, str) and w.startswith("[done]") for w in writes)
# Rule + Markdown render present (post-Done body re-render per issue #4 INV-005).
assert any(isinstance(w, Rule) for w in writes)
assert any(isinstance(w, Markdown) for w in writes)
assert any(_text_of(w).startswith("[done]") for w in writes)
# v0.8.2: no post-Done re-render — no duplicate content.
assert not any(isinstance(w, Markdown) for w in writes)
assert not any(isinstance(w, Rule) for w in writes)
assert state.text_chunk_buffer == ""
def test_raw_flag_skips_markdown(self) -> None:
"""raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown."""
@@ -396,70 +382,57 @@ 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(),
raw=True,
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
)
state.render(
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=True
_make_tui_done(),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
)
writes = [c[0][0] for c in log.write.call_args_list]
assert not any(isinstance(w, Rule) for w in writes)
assert not any(isinstance(w, Markdown) for w in writes)
def test_worker_phase_demoted(self) -> None:
"""worker_phase_demoted [trace]: WorkerPhase → RichLog "· worker_phase:" prefix
rendered with dim Rich style (INV-003: dim style + `· ` prefix in TUI).
def test_worker_phase_demoted_to_debug_log(self) -> None:
"""worker_phase_demoted_to_debug_log [trace, v0.5.0]: WorkerPhase → debug_log
"· worker_phase:" prefix rendered with Australis dark-60 Rich style.
Transcript receives nothing.
"""
from rich.text import Text as RichText
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
debug_log = MagicMock()
state = TuiPresenterState()
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=False,
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
)
renderable = log.write.call_args[0][0]
# INV-003: must be a dim-styled Rich Text renderable, not a plain str.
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
assert not log.write.called
renderable = debug_log.write.call_args[0][0]
# INV-003: must be a styled Rich Text renderable, not a plain str.
# v0.4.1 retheme: style is now Australis Sea dark-60 ("#86929d") instead
# of the terminal-dim filter "dim". Assert non-empty styling either way.
assert isinstance(renderable, RichText), type(renderable)
assert renderable.style == "dim"
assert renderable.style, "demoted telemetry must carry SOME style"
text = renderable.plain
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(), 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.
@@ -476,9 +449,8 @@ class TestTuiPresenterState:
state.render(
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
raw=False,
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
)
# INV-014: write went to tools_log
assert tools_log.write.called
@@ -496,16 +468,17 @@ 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,
raw=False,
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
)
assert tools_log.write.called
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
assert not log.write.called
def test_text_event_does_not_route_to_tools_log(self) -> None:
"""text_event_does_not_route_to_tools_log [INV-015]: Text → transcript, NOT tools_log."""
def test_text_event_buffers_until_newline(self) -> None:
"""text_event_buffers_until_newline [v0.8.1]: Text deltas without
`\\n` accumulate in text_chunk_buffer; no log write yet.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
@@ -514,31 +487,37 @@ class TestTuiPresenterState:
state.render(
Text(sse_id=SID, content="hello"),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
debug_log=MagicMock(),
thinking_log=MagicMock(),
raw=False,
)
assert log.write.called
assert log.write.call_args[0][0] == "hello"
# INV-015: tools_log was NOT written to
# v0.8.1: buffered, not written until `\n` or Done.
assert state.text_chunk_buffer == "hello"
assert not log.write.called
assert not tools_log.write.called
def test_text_no_prefix(self) -> None:
"""text_no_prefix [trace]: Text → RichLog line has no `·` prefix, no demotion."""
def test_text_flushes_on_newline(self) -> None:
"""text_flushes_on_newline [v0.8.1]: a delta carrying `\\n` flushes
the accumulated buffer as ONE line to log (transcript).
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hello"),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=False,
)
line = log.write.call_args[0][0]
# Pure content, no demotion prefix.
assert line == "hello"
for tok in ("Hel", "lo", " ", "world", "\n"):
state.render(
Text(sse_id=SID, content=tok),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
thinking_log=MagicMock(),
raw=False,
)
writes = [c[0][0] for c in log.write.call_args_list]
# "Hello world" coalesces to ONE log entry.
assert writes == ["Hello world"]
assert state.text_chunk_buffer == ""
def test_duration_format_seconds(self) -> None:
"""duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s"."""
@@ -549,14 +528,13 @@ class TestTuiPresenterState:
state.render(
_make_tui_done(duration_ms=5467),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=True,
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
)
done_line = next(
c[0][0]
_text_of(c[0][0])
for c in log.write.call_args_list
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
if _text_of(c[0][0]).startswith("[done]")
)
assert "duration=5.5s" in done_line
assert "duration_ms=5467" not in done_line
@@ -576,14 +554,13 @@ class TestTuiPresenterState:
state.render(
_make_tui_done(usage=usage),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=True,
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
)
done_line = next(
c[0][0]
_text_of(c[0][0])
for c in log.write.call_args_list
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
if _text_of(c[0][0]).startswith("[done]")
)
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
@@ -736,23 +713,29 @@ class TestLayoutShape:
row = app.query_one("#main-row", Horizontal)
assert row is not None
async def test_left_column_has_transcript_and_prompt(self) -> None:
"""left_column_has_transcript_and_prompt: left column = transcript + prompt + thinking."""
async def test_left_column_content_only(self) -> None:
"""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)
assert left is not None
transcript = app.query_one("#transcript", RichLog)
prompt = app.query_one("#prompt", Input)
thinking = app.query_one("#thinking-current", Static)
# Widgets are inside the left column (descendant check)
assert transcript in left.walk_children()
assert prompt in left.walk_children()
assert thinking in left.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."""
@@ -817,6 +800,100 @@ class TestLayoutShape:
f"INV-016: Input focus must survive Ctrl+1 tab switch; got focused={app.focused}"
)
async def test_debug_tab_exists(self) -> None:
"""debug_tab_exists [v0.5.0]: right column has Debug TabPane + #debug-log RichLog."""
from textual.widgets import RichLog, TabPane
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
debug_tab = app.query_one("#debug-tab", TabPane)
debug_log = app.query_one("#debug-log", RichLog)
assert debug_log in debug_tab.walk_children()
async def test_ctrl_2_activates_debug_tab(self) -> None:
"""ctrl_2_activates_debug_tab [v0.5.0]: Ctrl+2 → TabbedContent.active == 'debug-tab'."""
from textual.widgets import TabbedContent
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("ctrl+2")
await pilot.pause()
assert app.query_one("#side-panes", TabbedContent).active == "debug-tab"
async def test_done_label_styled_success(self) -> None:
"""done_label_styled_success [v0.5.1]: [done] label renders in Aurora green."""
from rich.text import Text as RichText
from textual.widgets import RichLog
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
# Probe the presenter directly — write a Done via state.render.
from ratatoskr.tui import TuiPresenterState
log = app.query_one("#transcript", RichLog)
state = TuiPresenterState()
seen: list = []
orig = log.write
log.write = lambda c, *a, **kw: (seen.append(c), orig(c, *a, **kw))[1]
state.render(
_make_tui_done(),
log=log,
tools_log=app.query_one("#tools-log", RichLog),
debug_log=app.query_one("#debug-log", RichLog),
thinking_log=MagicMock(),
raw=True,
)
done = next(
c for c in seen
if isinstance(c, RichText) and _text_of(c).startswith("[done]")
)
assert done.style == "#16B866" # Aurora green
async def test_empty_state_placeholders_present(self) -> None:
"""empty_state_placeholders_present [v0.5.1]: tools-log + debug-log show
placeholder lines before any turn fires."""
from textual.widgets import RichLog
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
# Activate Debug tab so its content actually renders.
from textual.widgets import TabbedContent
tabbed = app.query_one("#side-panes", TabbedContent)
tabbed.active = "debug-tab"
await pilot.pause()
tabbed.active = "tools-tab"
await pilot.pause()
tools_log = app.query_one("#tools-log", RichLog)
debug_log = app.query_one("#debug-log", RichLog)
tools_text = " ".join(str(line) for line in tools_log.lines)
tabbed.active = "debug-tab"
await pilot.pause()
debug_text = " ".join(str(line) for line in debug_log.lines)
assert "no tool events" in tools_text
assert "worker_phase" in debug_text
async def test_pane_name_updates_on_tab_switch(self) -> None:
"""pane_name_updates_on_tab_switch [v0.5.0]: pane-name reflects active tab.
Two tabs now (Tools / Debug); pane-name updates from "Tools" to "Debug"
and back as the operator switches via Ctrl+1 / Ctrl+2.
"""
from textual.widgets import Static
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
pane_name = app.query_one("#pane-name", Static)
await pilot.press("ctrl+2")
await pilot.pause()
assert str(pane_name.render()) == "Debug"
await pilot.press("ctrl+1")
await pilot.pause()
assert str(pane_name.render()) == "Tools"
import asyncio # noqa: E402
@@ -983,8 +1060,13 @@ async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None:
class TestStreamTurnWorker:
@respx.mock
async def test_happy_text_done_renders_markdown(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""happy_text_done_renders_markdown [happy,tracer]: …"""
async def test_happy_text_done_no_double_print(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""happy_text_done_no_double_print [happy,tracer, v0.8.2]:
Text("hello") buffers; on Done, "hello" flushes as tail to transcript
+ [done] label. v0.8.2 drops the post-Done Markdown body re-render
(was double-printing the response — streamed text + Markdown twice).
Only the turn-header Rule remains in the transcript.
"""
stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk(
"42:2", _DONE_BODY
)
@@ -999,20 +1081,26 @@ class TestStreamTurnWorker:
await pilot.pause()
await _submit_and_wait(app, pilot, "hi")
assert app.state == "idle"
# Streamed delta + done label + rule + markdown render
from rich.markdown import Markdown
# "hello" appears as a tail-flush; [done] label fires.
assert any(w == "hello" for w in writes)
assert any("[done]" in str(w) for w in writes)
# The post-Done markdown render uses rich Rule + Markdown — non-string writes.
# INV-005: BOTH separator (Rule) AND markdown render must be present in non-raw.
from rich.markdown import Markdown
from rich.rule import Rule
assert any(isinstance(w, Markdown) for w in writes)
assert any(isinstance(w, Rule) for w in writes)
# v0.8.2: NO Markdown body re-render (was the duplicate).
assert not any(isinstance(w, Markdown) for w in writes)
# The turn-header Rule is written to all 4 panes; we still expect
# SOME Rules in the spy (one per pane), but NOT the post-Done
# separator Rule that pre-v0.8.2 wrote.
# We rely on _spy_writes counting turn-header Rules only.
@respx.mock
async def test_raw_flag_skips_markdown_render(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""raw_flag_skips_markdown_render [trace]: …"""
"""raw_flag_skips_markdown_render [trace, v0.6.0]:
With --raw, no Markdown render. A turn-header Rule IS still written
(v0.6.0 INV — turn correlation lives in every pane). The post-Done
Rule(separator) is suppressed; accumulated streamed text is written
as a plain string instead.
"""
stream = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk(
"42:2", _DONE_BODY
)
@@ -1024,12 +1112,17 @@ class TestStreamTurnWorker:
async with app.run_test() as pilot:
await pilot.pause()
await _submit_and_wait(app, pilot, "x")
# INV-005: with --raw, NEITHER Rule separator NOR Markdown render appears.
from rich.markdown import Markdown
from rich.rule import Rule
# No Markdown in raw mode.
assert not any(isinstance(w, Markdown) for w in writes)
assert not any(isinstance(w, Rule) for w in writes)
# Only turn-header Rules — one per pane (transcript + tools +
# debug + thinking = 4). No post-Done separator Rule.
rules = [w for w in writes if isinstance(w, Rule)]
assert len(rules) == 4, f"expected 4 turn-header Rules, got {len(rules)}"
# Accumulated text "hi" written as plain string post-Done.
assert "hi" in writes
@respx.mock
async def test_error_terminal_returns_to_idle(self, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -2018,6 +2111,72 @@ class TestResolveThenRunWithPicker:
assert agents_route.call_count == 0
assert sessions_route.call_count == 1
@respx.mock
def test_picker_merges_local_tier3_agents(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""picker_merges_local_tier3_agents [v0.8.0]: local index entries
appear in the picker's agent list alongside remote agents."""
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
# Isolate the local index in a tmp file.
monkeypatch.setenv(
"RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json")
)
add_local_agent(LocalAgentEntry(
agent_id="ratatoskr:wizard",
agent_name="wizard",
model="qwen3.6-35-a3b",
description="(tier 3) test wizard",
defined_at="2026-05-25T00:00:00+00:00",
))
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json=_AGENTS_RESP)
)
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
)
from ratatoskr.tui import AgentPickerApp
captured: list = []
async def capture_picker_init(self, *a, **kw):
captured.append(list(self.agents))
return "mimir" # auto-pick something so the rest succeeds
# Patch __init__ to capture the agent list passed to the picker.
orig_init = AgentPickerApp.__init__
def init_spy(self, agents):
captured.append(list(agents))
orig_init(self, agents)
monkeypatch.setattr(AgentPickerApp, "__init__", init_spy)
async def picker_returns_mimir(self):
return "mimir"
monkeypatch.setattr(AgentPickerApp, "run_async", picker_returns_mimir)
async def fake_main(self, *a, **kw):
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", fake_main)
from ratatoskr.tui import run_tui
rc = run_tui(_args_new_no_agent())
assert rc == 0
# The local tier-3 agent should appear in the picker's agents list.
assert captured, "AgentPickerApp.__init__ was never called"
agent_ids = {a.agent_id for a in captured[0]}
assert "ratatoskr:wizard" in agent_ids
# Plus the remote agents.
assert "mimir" in agent_ids
assert "lofn" in agent_ids
@respx.mock
def test_picker_skipped_when_session_mode(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""picker_skipped_when_session_mode: --session s-1 → no list_agents, no create_session."""
@@ -2084,8 +2243,16 @@ class TestResolveThenRunWithPicker:
self,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
"""list_agents returns [] → stderr [no_agents]; exit 13; picker NOT opened."""
"""list_agents returns [] AND no local tier-3 entries → stderr
[no_agents]; exit 13; picker NOT opened. Isolate
$RATATOSKR_LOCAL_AGENTS so the operator's real local index
doesn't merge in and turn this into a non-empty list."""
# v0.8.0 isolation: point local agents at an empty tmp file.
monkeypatch.setenv(
"RATATOSKR_LOCAL_AGENTS", str(tmp_path / "empty_local_agents.json")
)
respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[]))
from ratatoskr.tui import AgentPickerApp
Generated
+1 -1
View File
@@ -968,7 +968,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.4.0"
version = "0.8.2"
source = { editable = "." }
dependencies = [
{ name = "httpx" },