Compare commits

...

6 Commits

Author SHA1 Message Date
vh 24e4371ec7 feat(tui): issue #13 — §5 layout reshape + Tools pane (v0.4.0)
Reshape the TUI from vertical-stack single-pane to Horizontal
two-column with TabbedContent on the right; v1 has a single Tools
tab that consumes ToolStart/ToolResult SSE events previously
rendered inline in the transcript. Foundation for the rest of
design-brief §5; subsequent panes (Persona/AdminEvents/BifrostState/
ServerLog) plug in as sibling TabPanes when their substrate
blockers resolve.

Three coupled pieces, all in-place amendments to issues #4 + #12:

- **Layout**: compose() yields Horizontal#main-row containing
  Vertical#left-column (transcript + thinking-current + prompt) and
  Vertical#right-column (TabbedContent#side-panes with
  TabPane#tools-tab → RichLog#tools-log). Width split 2fr:1fr. CSS
  dock rules narrow to per-container scope so thinking-current
  toggling doesn't reflow the right column.
- **Tools pane**: TuiPresenterState.render() signature widens with
  tools_log: RichLog. ToolStart/ToolResult route there per INV-014;
  every other event keeps its issue-#12 routing. Plain-label
  fallback under render-exception preserves routing (INV-009).
- **Ctrl+1 binding + pane-name widget**: BINDINGS gains
  Binding("ctrl+1", "focus_tools") which programmatically sets
  TabbedContent.active; Textual's default preserves Input focus per
  INV-016 (test asserts; regression path documented).
  Static#pane-name in the footer renders "Tools" v1 (static — no
  tab-switch handler wiring lands in #13 per amendment-2 from
  Volva paraphrase review).

CLI mode (--send) is unaffected by design per INV-018 — non-
interactive, no tabs concept; CLI keeps inline tool-event rendering.

Contract: docs/contracts/issues/13.contract.md (drift-check clean,
two amendments applied from Volva contract-paraphrase pass).

Tests: +9 net (TestLayoutShape × 7 + TestTuiPresenterState routing
× 3, minus 1 deprecated test_tool_start_demoted superseded by
test_tool_start_routes_to_tools_log). 236 total GREEN; ruff clean.

Live smoke against personal Worldtree's mimir: tool-using turn
(KB search) populated tools_log with tool_start + tool_result for
search_library + read_note; transcript stayed chat-only with
worker_phase + thinking. Routing-not-duplication confirmed
end-to-end.
2026-05-23 21:41:16 -07:00
vh d30be12deb feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
Adds GET /agents fetch + ListView picker for bare `--new` (TUI mode
without --agent). Three in-place amendments:

- ratatoskr.sessions: new `list_agents()` + `AgentInfo` frozen
  dataclass with omit-when-null/empty defaults mirroring SessionInfo's
  INV-001/INV-002 origin-conditional pattern. Non-200 responses raise
  the existing SessionApiFailed (no new exception).
- ratatoskr.cli: `_parse_args` softens `--agent` from absolute to
  mode-conditional — required for `--send --new`, optional for bare
  `--new`, forbidden with `--session` (unchanged INV-004).
- ratatoskr.tui: new `AgentPickerApp(App[str | None])` — separate
  Textual App (not Screen-within-RatatoskrApp) so list_agents errors
  land on real stderr before any alt-screen opens (preserves issue
  #6's INV-001). `_resolve_then_run` gains a pre-create branch:
  fetch agents → empty list → exit 13; non-200 → exit 20; network
  error → exit 21; picker dismissed → exit 0; otherwise thread chosen
  agent_id into create_session.

Contract: docs/contracts/issues/8.contract.md (drift-check clean).

Tests: +18 (227 total, was 209). Live smoke against personal Worldtree
(:8081) returned 12 agents; programmatic picker drive auto-picked lofn
and created a real session with `end_user_id="ratatoskr-tui"`.
2026-05-23 17:58:22 -07:00
vh a77a872810 snapshot: persistent-memory — v0.2.1 layout fix + §5 sequencing decision + Worldtree-stall diagnostic
Captures three things accumulated since the v0.2.0 snapshot in 3b9c610:

1. v0.2.1 layout fix (c85f6bd) — Recent decision documenting the
   dock-anchored TUI chrome that fixed Input bouncing. Operator-verified
   "a lot better" interactively. Going-forward principle: TUI-layout
   patches ship + operator verifies (TTY is the load-bearing test
   surface; respx/Pilot can't catch screen-relative positioning bugs).

2. §5 sequencing decision — collapsible Thinking pane + Debug pane
   proposals fold into design-brief §5's TabbedContent column rather
   than ship as inline-Collapsibles first. Do issue #8 (startup agent
   picker) before §5. Avoids the build-inline-then-rebuild waste.

3. Worldtree-stall diagnostic shorthand — "2-events-then-silence"
   = upstream LLM-provider connection wedged, not ratatoskr.
   worldtree-dev confirmed via code-level walk-through (althing
   01KSBKTG096Q…). Future-self defense against bisecting ratatoskr
   code when this shape appears.

Also refreshes the in-flight section: v0.2.0 + v0.2.1 shipped + tagged;
lofn smoke now unblocked on auth (still pending operator); next planned
feature is issue #8 (startup agent picker), then §5 side-panes.

No version bump per CLAUDE.md SemVer etiquette (memory-snapshot
commits skip).
2026-05-23 17:45:21 -07:00
vh c85f6bd701 fix(tui): anchor layout via dock so Input never moves (v0.2.1)
Reported during v0.2.0 mimir smoke: the Input pane bounces up/down
mid-turn and streamed tokens land at shifting screen positions. Cause
is the v0.2.0 compose order — `Static(id="thinking-current")` was
yielded between hint and Footer in the auto-stacked flow, so each
display=True/False toggle per thinking-run shifted Input + identity +
hint vertically. RichLog growth from streaming text also drifted Input
downward in the auto-layout.

Fix: dock the chrome to the screen edges via DEFAULT_CSS:
- thinking-current docks top under Header (grows/shrinks above RichLog,
  doesn't affect Input position).
- transcript (RichLog) gets `height: 1fr` — absorbs all layout reflows
  internally via its scroll viewport.
- prompt (Input), identity, hint all dock bottom — locked above Footer.

Compose order moves thinking-current to position 2 (right after Header)
so its dock-top placement is visually adjacent to where Textual lays it
out. Old position (between hint and Footer) would still work with the
dock CSS, but the proximity reads more clearly.

Screen-relative positions are now stable: Input is anchored to the
bottom-dock stack; RichLog's content scrolls inside its bounded
viewport regardless of how much thinking-current expands. Tokens land
at the same screen position each delta.

No public API change; pure layout fix. 209/209 tests GREEN; ruff clean.
v0.2.0 → v0.2.1 (patch).

Cannot directly verify in TTY from a non-interactive session; operator
verification needed in real terminal.
2026-05-23 17:13:27 -07:00
vh 3b9c610587 feat(cli,tui): issue #12 — presenter contract semantics amendment (v0.2.0)
Replaces the stateless _render_event / _render_event_to_log helpers with
stateful per-turn presenters (CliPresenterState / TuiPresenterState).
Coalesces thinking-event deltas into a single growing display per run;
demotes telemetry events with editorial hierarchy; formats duration +
usage for human reading. Headline behavior change: a 50-token thinking
phase now renders as ONE coalesced growing line in CLI (or one closed
RichLog entry + per-delta live Static widget in TUI), not 50 lines of
[thinking] spam.

Editorial promotion line (issue #12 INV-002):
- Load-bearing (no demotion prefix): Text, Done, Error, Cancelled
- Demoted telemetry (`. ` ASCII prefix in CLI; dim `· ` in TUI):
  WorkerPhase, Thinking, TextBoundary, ToolStart, ToolResult

Stateful coalescing:
- Thinking deltas accumulate into thinking_buffer; first non-thinking
  event closes the run with a single \n boundary in CLI / one closed
  dim RichLog entry in TUI.
- TUI adds a dedicated Static(id="thinking-current") widget that shows
  the last ~200 chars of the active run, mirroring per-delta updates.
  Two-views-of-thinking decoupling per INV-004: chronological RichLog +
  always-visible widget.
- CLI INV-005: when stdout text was streamed mid-line,
  text_written_since_newline triggers a stdout flush + \n before the
  next stderr terminal label — guarantees [done] / [error] / [cancelled]
  land on their own line in a TTY without breaking pipe-to-file
  scripted consumers.

Formatting helpers (issue #12 INV-006 / INV-007):
- _format_duration_ms — autoscale `347ms` / `5.5s` / `1.2m`
- _format_usage — natural-language `6756 in -> 126 out (6882 total, 0
  cached)` with arrow="->" CLI / "→" TUI

Cross-frontier design pass (eitri-smithy-dev, althing
01KSBE52YZR5E3SPTKA672JE43) returned 16-of-16 confirmed decisions + 4
material divergences applied:
- ASCII `. ` prefix in CLI (`·` is U+00B7, not ASCII)
- RichLog one-closed-entry-per-run + Static per-delta updates (not
  inline-mirror as initially proposed)
- presenter-state object instead of pure-function rendering
- Framed as "contract semantics amendment", not "polish"

Volva paraphrase round (5 prose-precision fixes applied to
12.contract.md): INV-001 "growing display" semantics; single hide
mechanism for the Static widget (Textual reactive `display: bool`);
[render_error] security clause (type-only, no exception message);
text_written_since_newline `\n`-terminated text corner case;
[create_session] integration path (bypasses state.render — not an SSE
Event variant).

Volva code-review round (5 findings applied):
- F1 drift: render-exception fallback now writes BOTH a plain-label
  fallback line for the original event AND the `[render_error] <type>`
  line (was missing the fallback half).
- F2 drift: dim Rich style applied to all demoted-telemetry RichLog
  writes via `rich.text.Text(..., style="dim")` (was plain str).
- F3 drift: belt-and-braces widget clear+hide on EVERY terminal event
  (Done/Error/Cancelled), even when thinking_open was False.
- F4 precision: _format_usage gains PRE-001 assertion on the four
  expected usage keys.
- F5 precision: _run_turn signature amended in issue #3 contract to
  document the new `state: CliPresenterState | None = None` test-
  injection kwarg.

[create_session] lifecycle line demoted to `. create_session:` (written
directly by _amain; bypasses state.render since it's not a wire-level
SSE Event variant). Pre-amendment _render_event / _render_event_to_log
and their test classes removed under the no-backwards-compat rule.

Issues #3 and #4 contracts amended in-place: #3 (CliPresenterState
CLASS + FN block + helper FN blocks + _run_turn signature + _amain
create_session demotion); #4 (TuiPresenterState CLASS + FN block +
compose Static widget + _stream_turn_worker state construction).

209 tests GREEN; ruff clean. Bumps v0.1.0 → v0.2.0 (minor — output
shape change breaks pre-amendment grep patterns like `[thinking] '`;
no public API surface change beyond the rendering contract).

Persistent-memory commit-along: captures the issue #12 decision,
forward direction (require end_user_id for every access — declined
worldtree-dev's requires_end_user_id offer because we'll send it
universally), and the Heimdall scope-model foot-gun note (the
"per-Tier-1-agent scope add" diagnosis was a phantom ask resolved by
worldtree-dev's correction; agent.call:* baseline covers all Tier 1).
2026-05-23 16:13:55 -07:00
vh 82821561e6 snapshot: persistent-memory — Heimdall scope-model foot-gun note (post-v0.1.0)
Captures the lesson from today's lofn-scope chase: "per-Tier-1-agent scope
add" is a phantom ask. The `agent.call:*` (singular) baseline rule in
config/policies.yaml covers ALL Tier 1 foundational agents (mimir, lofn,
all Asgardians) for every authenticated tier; there is no per-agent grant
in this path. The plural `agents.call:<owner>:<agent>` namespace is Tier 3
only (consumer-defined agents via POST /agents/define).

Worldtree-dev shipped a corresponding doc fix (dd6e091) — new
"Authorization model — agent invocation" section at
docs/conversation-api-spec.md lines 58-114 + a heimdall.contract.md fix
removing a misleading agent.call:mimir example.

Don't ping infra-ops for "per-Tier-1-agent scope adds" again. Real future
infra-ops asks remain: admin-tier key for the AdminEvents pane
(admin.events.read scope, different tier) and Tier 3 custom-agent
registration (POST /agents/define flow, different from scope-add).
2026-05-23 14:42:43 -07:00
14 changed files with 3379 additions and 531 deletions
+570
View File
@@ -0,0 +1,570 @@
---
contract_version: "2.1"
target_module: "ratatoskr.cli + ratatoskr.tui"
scope: "Presenter contract semantics amendment — replace stateless 'one labeled line per non-Text event' rendering with stateful event coalescing + visual hierarchy. Both presenters gain a small renderer-state object that owns `thinking_buffer` / `thinking_open` / `text_written_since_newline`. Thinking deltas coalesce into a single growing run, closed on the first non-thinking event. Demoted-telemetry events (`WorkerPhase`, `Thinking`, `TextBoundary`, `ToolStart`, `ToolResult`) get visual demotion: `. ` prefix on stderr in CLI (ASCII), dim style + `· ` prefix in TUI's RichLog. Load-bearing events (`Text`, `Done`, `Error`, `Cancelled`) keep no demotion prefix. TUI adds a dedicated `Static(id='thinking-current')` widget for live per-delta updates alongside the RichLog's chronological one-closed-entry-per-run. `duration_ms` auto-scales (`347ms` / `5.5s` / `1.2m`); `usage` renders as natural-language flow with ASCII arrow in CLI, Unicode arrow in TUI. CLI's `[done]` / `[error]` / `[cancelled]` labels get a stdout-flush + newline-boundary guarantee. No new public flags; no wire-level surface change. Two parallel in-place amendments to issues #3 (cli) and #4 (tui), landing in one commit at v0.2.0. Mission refinement (per persistent-memory amendment): 'the observability surface presented at the right zoom level for the operator's task' — raw per-event streaming is the wrong zoom for debugging; coalesced thinking + demoted telemetry is the right zoom."
depends_on:
- "ratatoskr.sse_client"
used_by: []
language: "python"
complexity: "medium"
estimated_loc: 220
confidence: 0.85
assumptions:
- "Worldtree event sequencing per the v0.19.0 spec pin is `Thinking* WorkerPhase Text* (ToolStart ToolResult)* Done`, with WorkerPhase and Text optionally interleaved across thinking runs (e.g., `Thinking* WorkerPhase Text* Thinking* Text* Done`). The closure trigger 'first non-thinking event' is correct for every interleaving pattern in the spec; multiple thinking runs each get their own coalesced display."
- "The presenter-state object is a small per-turn-spawned dataclass-style structure, not a long-lived singleton. CLI: new state per `_amain` call. TUI: new state per `_stream_turn_worker` invocation (one per turn). Reset semantics fall out of construction — no explicit reset method needed."
- "TUI's `Static(id='thinking-current')` widget is composed once at app startup (during `compose()`); the widget is hidden by setting `widget.display = False` AFTER the compose pass (in `on_mount`, or constructed with `display=False` if the Static constructor supports it — implementer's mechanical call). Visibility toggle uses Textual's reactive `Widget.display: bool` attribute throughout (`widget.display = True` to show, `widget.display = False` to hide). The contract does NOT use CSS `classes='hidden'` or direct `widget.styles.display` mutation — those are equivalent in effect but mixing them in one contract creates implementation-spec churn. Clear + hide happens on the turn-terminal event (`Done` / `Error` / `Cancelled`)."
- "CLI stdout/stderr split stays intact: stdout = LLM text content; stderr = everything else. The TTY-interleave problem is solved by `text_written_since_newline` state — when about to render a terminal label, if text has been written, flush a `\\n` to stdout first. Scripted consumers piping `--send '...' > out.txt` are not affected."
- "Per-delta flush in CLI (~50× per turn for a long thinking phase) is acceptable. Real-time observability requires per-delta visibility; batch-buffering adds latency without meaningful CPU savings."
- "ASCII-only prefixes in CLI output (`. `, `->`) for scriptability across non-UTF8 terminals + log redirects. TUI may use Unicode (`· `, `→`) since it already commits to UTF-8 terminal assumptions via Textual."
- "No new CLI flags. The renderer's choices ARE the product. `--quiet` / `--debug` / `--verbose-events` toggle complexity is rejected until a concrete scripted-caller use case demonstrates real demand."
- "Editorial promotion line is stable: load-bearing = `Text`, `Done`, `Error`, `Cancelled` (the model's voice + terminal outcomes); demoted = `WorkerPhase`, `Thinking`, `TextBoundary`, `ToolStart`, `ToolResult` (streaming telemetry + tool activity). Tool events are debug-important but visually compete with assistant text; a future Tools pane (design-brief §5) may promote tool details there without changing this transcript contract."
- "Cross-frontier design pass with eitri-smithy-dev (althing thread 01KSBE52YZR5E3SPTKA672JE43, 2026-05-23) confirmed 16 of 16 originally proposed decisions. 4 material divergences applied: (a) `·` is U+00B7 not ASCII, use `. ` in CLI; (b) RichLog gets one closed-entry-per-run + Static gets live per-delta updates (not inline-mirror); (c) presenter-state object instead of stateless functions; (d) frame as 'contract semantics amendment' not 'polish'."
open_questions:
- "Should we eventually expose a `--debug` flag that renders the pre-v0.2.0 raw-event-per-line shape for the case where coalescing hides something? Draft: no — `git checkout v0.1.0` is the escape hatch until a concrete use case demands the flag. Defer to follow-up issue if it surfaces."
- "Should the TUI `Static(id='thinking-current')` widget use Rich markup for emphasis (italic-dim) or stay plain text? Draft: plain text for v0.2.0; revisit if visual hierarchy proves insufficient. Rich markup inside Static requires `markup=True` which then needs careful escape handling for user-supplied content."
- "Should `ToolResult.result` get smarter truncation (parse-aware for known shapes vs the current `{!r:.200}` repr-truncate)? Draft: no for this pass — same truncation behavior as today; the demotion + prefix change is the visual fix. Smart truncation is a separate Tools-pane-shaped concern."
prd:
issue: 12
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/12"
body_sha256_16: "9e5daa500cc3df85"
lock_in_comment_id: null
lock_in_sha256_16: null
lock_in_at: null
pinned_at: "2026-05-23T22:13:28+00:00"
dependencies:
- issue: 3
path: "src/ratatoskr/cli.py"
reason: "In-place contract amendment: `_render_event` becomes `CliPresenterState.render` (method on a new small state class); `_amain` constructs the state and threads it through `_run_turn`; existing per-event TESTS restructured around state transitions; new multi-event-sequence tests added for coalesce closure + stdout/stderr newline-boundary."
- issue: 4
path: "src/ratatoskr/tui.py"
reason: "In-place contract amendment: `_render_event_to_log` becomes `TuiPresenterState.render` (method on a new small state class); `RatatoskrApp.compose()` gains a new `Static(id='thinking-current')` widget; `_stream_turn_worker` constructs the presenter state per turn and threads it through; existing TESTS restructured; new tests added for the two-views-of-thinking decoupling + Static widget lifecycle."
---
# Presenter contract semantics amendment — stateful event coalescing + visual hierarchy
## Context
A 2026-05-23 mimir smoke against personal Worldtree (post-v0.1.0 commit
`804c2df`) exposed that the current presenters
(`cli._render_event` and `tui._render_event_to_log`) render every Worldtree
SSE event as a labeled line, with no visual hierarchy and no coalescing.
Concrete problems observed:
1. **Thinking deltas spam.** Worldtree streams thinking as token-deltas
(just like text). The current renderer writes one `[thinking] '<token>'`
line per delta. A 50-token thinking phase = 50 lines.
2. **text_boundary fires inline** mid-text, visually breaking sentences.
3. **worker_phase renders** with the same visual weight as actual model
output, drowning load-bearing signal.
4. **ToolStart / ToolResult** share the telemetry-vs-transcript tension.
5. **duration_ms / usage** are unformatted (`duration_ms=5467`,
`usage={'prompt_tokens': 6756, ...}`).
6. **`[done]` on stderr interleaves** with stdout text in a TTY because
there's no newline-boundary guarantee between the two streams.
Per persistent-memory: "the product IS the observability surface; chat is
the input mechanism." Suppressing events is the wrong direction. The right
direction is rendering them cleanly, with editorial judgment about what's
load-bearing transcript vs demoted telemetry.
This amendment refines the mission slightly: **"the observability surface
presented at the right zoom level for the operator's task."** Per-event
raw streaming is the wrong zoom for normal debugging; coalesced thinking
+ demoted telemetry is the right zoom. Design pass was cross-frontier
consulted with `eitri-smithy-dev` (althing thread
`01KSBE52YZR5E3SPTKA672JE43`); 16 of 16 original decisions confirmed
with 4 material divergences applied (see assumptions).
## Data flow
**Input:** unchanged. Same `Event` discriminated union from
`ratatoskr.sse_client` (`WorkerPhase | Thinking | Text | TextBoundary |
ToolStart | ToolResult | Done | Error | Cancelled`).
**Output change (both presenters):**
- Thinking deltas: instead of one line per delta, single growing
run closed on the first non-thinking event.
- Demoted telemetry (WorkerPhase, Thinking, TextBoundary, ToolStart,
ToolResult): visual demotion — `. ` prefix in CLI stderr, dim style
+ `· ` prefix in TUI RichLog.
- Load-bearing terminal events (Done, Error, Cancelled): no demotion
prefix; CLI guarantees a `\n` to stdout BEFORE the label is written
to stderr when text has been written this turn.
- `duration_ms` formats: `347ms` / `5.5s` / `1.2m` autoscale.
- `usage` formats: `6756 in -> 126 out (6882 total, 0 cached)` ASCII
arrow in CLI; `→` Unicode arrow in TUI.
**Output change (TUI-only):**
- New `Static(id="thinking-current")` widget composed in
`RatatoskrApp.compose()`. Hidden by default. Updated per Thinking delta
with the last ~200 chars of accumulated thinking. Hidden + cleared on
turn-terminal event.
- RichLog receives ONE closed thinking entry per thinking-run (not
per-delta). Static widget gets per-delta updates.
**Side effects:** none new. Same outbound HTTP, same SSE consumption.
No persistence.
## Invariants
- **INV-001 [hard]**: Thinking coalescing — every consecutive run of
`Thinking` events MUST render as a single growing display. The CLI
emits MULTIPLE writes to stderr (one `. thinking: ` prefix on the
first delta of the run, then one write per subsequent delta with no
intervening `\n`), composing ONE logical stderr line that is
terminated by a single `\n` written at closure. The TUI emits
per-delta updates to the `thinking-current` Static widget AND, at
closure, ONE RichLog entry containing the full accumulated run
content. The closure trigger is the first non-thinking event arrival,
including terminal events (`Done` / `Error` / `Cancelled`).
Multiple thinking runs in one turn each get their own coalesced
display; the state machine resets on closure and re-opens on the
next `Thinking` event.
- **INV-002 [hard]**: Editorial promotion is fixed:
**Load-bearing:** `Text`, `Done`, `Error`, `Cancelled` (the model's
voice + terminal outcomes — no demotion prefix).
**Demoted telemetry:** `WorkerPhase`, `Thinking`, `TextBoundary`,
`ToolStart`, `ToolResult` (streaming telemetry + tool activity —
demotion prefix in CLI and TUI).
Adding or moving an event variant between the two groups is a
contract change.
- **INV-003 [hard]**: Demotion visual treatment:
- **CLI**: `. ` prefix on every demoted-event stderr line. ASCII-only
(no `·` Unicode, no ANSI dim). Load-bearing events get no prefix.
- **TUI**: dim Rich style + `· ` prefix on demoted-event RichLog
entries. Load-bearing events get no demotion.
- **INV-004 [hard]**: TUI two-views-of-thinking:
- The dedicated `Static(id="thinking-current")` widget receives
per-delta updates showing the last ~200 chars of the current
accumulated thinking-run content (`…` prefix when truncated).
- The RichLog transcript receives ONE closed entry per thinking-run
written at closure time, content = the full accumulated thinking
text (dim, `· ` prefix).
- The two views are decoupled: per-delta widget update fires for
every `Thinking` event; per-run RichLog write fires once on
closure. No per-delta RichLog writes for Thinking events.
- **INV-005 [hard]**: CLI stdout/stderr newline boundary — before
writing any load-bearing terminal label (`[done]`, `[error]`,
`[cancelled]`) to stderr, the renderer state MUST check
`text_written_since_newline`; if true, write `\n` to stdout and
flush, then reset the flag, then write the terminal label to stderr.
This guarantees the terminal label lands on its own line below the
assistant text in a TTY. (Scripted consumers piping stdout to a file
see the same `\n` cleanly terminating the text.)
- **INV-006 [hard]**: Duration formatting via shared helper
`_format_duration_ms(ms: int) -> str`:
- `ms < 1000``"347ms"`
- `1000 ≤ ms < 60_000``"5.5s"` (one decimal)
- `ms ≥ 60_000``"1.2m"` (one decimal)
- **INV-007 [hard]**: Usage formatting via shared helper
`_format_usage(usage: dict, *, arrow: str) -> str`:
Input: `{"prompt_tokens": int, "completion_tokens": int,
"total_tokens": int, "cached_input_tokens": int}`.
Output: `f"{p} in {arrow} {c} out ({t} total, {ci} cached)"`.
`arrow="->"` in CLI (ASCII), `arrow="→"` in TUI (Unicode).
- **INV-008 [hard]**: Presenter-state lifecycle:
- One `CliPresenterState` instance constructed per `_amain` call;
discarded on return.
- One `TuiPresenterState` instance constructed per
`_stream_turn_worker` invocation (one per turn); discarded when
the worker exits (success, error, or cancellation).
- No long-lived singleton; no cross-turn state in either presenter.
- **INV-009 [hard]**: TUI render-exception fallback — if any exception
fires inside `TuiPresenterState.render()` (e.g., the
`thinking-current` widget reference goes stale during teardown), the
exception MUST be caught at the presenter boundary; the original
event MUST still be rendered as a plain labeled RichLog line (the
pre-amendment behavior); a visible `[render_error] <type>` line MUST
be written to the RichLog so the operator sees the degradation.
Format is the exception class name ONLY — NOT the exception message
/ args — because exception payloads can contain wire data from the
original event (e.g., `AttributeError`'s repr of the event object).
The `[security]` constraint trumps the readability win of including
`<msg>`; the operator can attach a debugger or read logs if the
bare `<type>` doesn't pinpoint the cause. Silent-swallow remains
forbidden.
- **INV-010 [hard]**: No new public CLI flags. `--quiet`,
`--verbose-events`, `--debug`, `--no-thinking` are out of scope. The
renderer choices ARE the product. Adding a flag is a separate
contract amendment with its own demand-evidence.
- **INV-011 [hard]**: No `core.*` / `worldtree.*` imports (existing
boundary; unchanged). No new third-party deps. Existing dependencies
on `httpx`, `httpx-sse`, `textual`, `rich` cover the surface.
## Out of scope
- **`--quiet` / `--debug` / `--verbose` flags.** Per INV-010.
- **Side-pane work** (AdminEvents, Persona, Tools, BifrostState,
ServerLog per design-brief §5). The dedicated TUI thinking widget
added here is the foundation but is NOT a "Persona pane" or any
other named §5 pane. Future side-panes may relocate thinking
rendering; this amendment fixes the current chat-pane shell.
- **i18n / locale-aware formatting.** English-only Vuong-only.
`5.5s` is locale-blind by design.
- **Cross-process resume / transcript persistence.** Per design-brief
§8d, deferred to v2.
- **Smart truncation of `ToolResult.result`** beyond the existing
`{!r:.200}` repr-truncate. Same shape as today; the demotion +
prefix change is the visual fix. Smart per-tool-result-shape
truncation is a Tools-pane-shaped concern.
- **Replay of pre-amendment behavior** via a `--legacy-render` flag.
`git checkout v0.1.0` is the escape hatch.
## Constraints
- **[compatibility]** Spec pin unchanged. The wire surface
(`Event` union from `ratatoskr.sse_client`) is unchanged; only the
rendering of those events changes.
- **[performance]** Per-delta flush in CLI is acceptable (real-time
observability requires it). The state object is a small dataclass;
per-render overhead is negligible relative to the SSE stream
cost.
- **[security]** Same as today — no logged credentials. The
`[render_error]` fallback (INV-009) MUST NOT include
exception-payload content that could leak request data.
- **[style]** Ruff line-length=100. ASCII-only in CLI presenter
output; Unicode allowed in TUI. Type-hinted `PresenterState` classes
with `dataclass(slots=True)` for memory efficiency.
## Architecture
```
ratatoskr <args> [shell entry]
└─ ratatoskr.cli.main(argv)
├─ args.send_content is not None ──► asyncio.run(_amain(args))
│ │
│ ├─ open AsyncClient
│ ├─ create_session (if --new)
│ ├─ state = CliPresenterState() ◄── NEW
│ ├─ _run_turn(..., state=state)
│ │ │
│ │ └─ for event in stream_turn(...):
│ │ state.render(event, stdout=sys.stdout,
│ │ stderr=sys.stderr) ◄── NEW
│ │ # state owns: thinking_buffer, thinking_open,
│ │ # text_written_since_newline
│ └─ close AsyncClient
└─ args.send_content is None ──► run_tui(args)
└─ asyncio.run(_resolve_then_run(args))
└─ async with AsyncClient(...):
├─ pre-flight session resolve
├─ app = RatatoskrApp(args, ...)
│ └─ compose():
│ Header()
│ RichLog(id="transcript", ...)
│ Input(id="prompt", ...)
│ Static("", id="identity")
│ Static("", id="hint")
│ Static("", id="thinking-current", ◄── NEW
│ display=False)
│ Footer()
└─ on_input_submitted: spawn _stream_turn_worker
└─ _stream_turn_worker(content):
├─ state = TuiPresenterState(app=self) ◄── NEW
└─ for event in stream_turn(...):
state.render(event, log=log,
thinking_widget=tw,
raw=self.args.raw) ◄── NEW
# state owns: thinking_buffer,
# thinking_open
```
---
## In-place amendments to issue #3 (`ratatoskr.cli`)
### `CLASS CliPresenterState` (NEW)
```contract
CLASS CliPresenterState
BRIEF: Stateful presenter for the cli `--send` mode. Owns per-turn rendering state: in-flight thinking buffer + whether a thinking run is currently open + whether stdout text has been written since the last newline. One instance per `_amain` call; discarded on return.
PROPERTIES:
thinking_buffer: list[str] # accumulated thinking content for the active run
thinking_open: bool # whether a thinking run is currently open
text_written_since_newline: bool # whether stdout has received text without a closing \n
METHODS:
render(event, *, stdout: TextIO, stderr: TextIO) -> None
INV-WIRE-001: One instance per `_amain` call (INV-008).
INV-WIRE-002: state.render(...) is called for every event in stream order; events MUST NOT be skipped.
```
### `FN CliPresenterState.render` (NEW)
```contract
FN CliPresenterState.render(self, event: Event, *, stdout: TextIO, stderr: TextIO) -> None
BRIEF: Render one event with the editorial hierarchy per INV-002. Coalesces thinking runs per INV-001; guarantees stdout newline boundary before load-bearing terminal labels per INV-005. ASCII-only output.
PRE: [PRE-001 hard] event is an instance of one of the Event union variants -- assert isinstance(event, (WorkerPhase, Thinking, Text, TextBoundary, ToolStart, ToolResult, Done, Error, Cancelled))
PRE: [PRE-002 hard] stdout and stderr are writeable text IO objects
POST: [POST-001 side_effect] for Thinking events: open thinking run if not open, append delta to thinking_buffer, write delta to stderr without trailing newline
POST: [POST-002 side_effect] for non-Thinking events when thinking_open: close the thinking run by writing "\n" to stderr; set thinking_open=False; clear thinking_buffer; THEN render the new event
POST: [POST-003 side_effect] for Text events: write event.content to stdout without forcing a trailing newline; set text_written_since_newline = NOT event.content.endswith("\n") so already-terminated content does NOT trigger an extra newline before subsequent terminal labels (INV-005 boundary fires only when text lacks a trailing newline)
POST: [POST-004 side_effect] for Done/Error/Cancelled: if text_written_since_newline, write "\n" to stdout + flush, reset flag; then write the terminal label to stderr per INV-005
POST: [POST-005 side_effect] for demoted-telemetry events (WorkerPhase, TextBoundary, ToolStart, ToolResult): write ". <label> <fields>\n" to stderr (no prefix on load-bearing)
STEPS:
1. [setup, flexibility=prescriptive] Validate PRE-001..PRE-002
2. [branch, flexibility=prescriptive] IF isinstance(event, Thinking):
IF NOT self.thinking_open:
stderr.write(". thinking: ")
self.thinking_open = True
stderr.write(event.content)
stderr.flush()
self.thinking_buffer.append(event.content)
RETURN
3. [branch, flexibility=prescriptive] IF self.thinking_open:
# Close the open thinking run before rendering the new event
stderr.write("\n")
stderr.flush()
self.thinking_open = False
self.thinking_buffer.clear()
4. [branch, flexibility=prescriptive] IF isinstance(event, Text):
stdout.write(event.content)
stdout.flush()
# Track whether the cursor is mid-line — if content ends with \n,
# stdout is already at column 0 and INV-005 should NOT inject another \n.
self.text_written_since_newline = not event.content.endswith("\n")
RETURN
5. [branch, flexibility=prescriptive] IF isinstance(event, (Done, Error, Cancelled)):
IF self.text_written_since_newline:
stdout.write("\n")
stdout.flush()
self.text_written_since_newline = False
# Then write the load-bearing terminal label (NO demotion prefix)
label = _format_terminal_label(event) # see helpers below
stderr.write(label + "\n")
RETURN
6. [branch, flexibility=prescriptive] # Demoted telemetry: WorkerPhase, TextBoundary, ToolStart, ToolResult
label = _format_demoted_label(event)
stderr.write(". " + label + "\n")
TESTS:
thinking_coalesce_single_run [happy,tracer]: render Thinking("hello"), Thinking(" world") in sequence; stderr captures ". thinking: hello world" (no \n yet); then render Done → stderr gets a final \n + the [done] line
thinking_closes_on_first_non_thinking_event [happy]: Thinking → WorkerPhase → stderr has ". thinking: ...\n" (closed) then ". worker_phase: ..."
thinking_closes_on_error [error]: Thinking → Error → thinking line closes with \n, then [error] line rendered (no discard of partial thinking)
multiple_thinking_runs [scenario]: Thinking → Text → Thinking → Done → two separate ". thinking: ..." runs in stderr, with stdout receiving the text + \n boundary before [done]
text_then_done_newline_boundary [trace]: Text("answer") → Done; stdout receives "answer\n" (the \n is from INV-005), stderr receives "[done] ..."
no_text_then_done_no_extra_newline [trace]: Done with no preceding Text → stdout untouched; stderr receives only "[done] ..."
newline_terminated_text_then_done [trace]: Text("answer\n") → Done; stdout receives "answer\n" exactly once (NO double-newline before [done]) per INV-005 + POST-003 reset rule
cancelled_mid_thinking [scenario]: Thinking → Cancelled → thinking closes with \n; then [cancelled] (no demotion prefix)
worker_phase_demoted [trace]: WorkerPhase → stderr line starts with ". worker_phase:" not "[worker_phase]"
tool_start_demoted [trace]: ToolStart → stderr line starts with ". tool_start:"
tool_result_truncated [trace]: ToolResult(result="b"*500) → stderr line has ". tool_result:" + ≤200 chars of result repr
text_boundary_demoted [trace]: TextBoundary → stderr line starts with ". text_boundary:"
duration_format_seconds [trace]: Done(duration_ms=5467) → stderr label contains "duration=5.5s" (not duration_ms=5467)
duration_format_subsecond [trace]: Done(duration_ms=347) → "duration=347ms"
duration_format_minutes [trace]: Done(duration_ms=72000) → "duration=1.2m"
usage_format_ascii_arrow [trace]: Done(usage=...) → stderr label contains "usage 6756 in -> 126 out (6882 total, 0 cached)" (ASCII arrow)
state_reset_per_amain [trace]: two _amain calls in one process; second one starts with thinking_open=False (fresh state)
```
### `FN _format_duration_ms` (NEW helper)
```contract
FN _format_duration_ms(ms: int) -> str
BRIEF: Auto-scale duration formatting per INV-006. Locale-blind; English-only.
PRE: [PRE-001 hard] ms is a non-negative int -- assert isinstance(ms, int) and ms >= 0
POST: [POST-001 return_value] returns a short string: "<ms>ms" / "<s.s>s" / "<m.m>m"
STEPS:
1. IF ms < 1000: RETURN f"{ms}ms"
2. ELIF ms < 60_000: RETURN f"{ms/1000:.1f}s"
3. ELSE: RETURN f"{ms/60_000:.1f}m"
TESTS:
subsecond: 347 → "347ms"
exact_one_second: 1000 → "1.0s"
fractional_seconds: 5467 → "5.5s"
exact_one_minute: 60000 → "1.0m"
fractional_minutes: 72000 → "1.2m"
zero: 0 → "0ms"
```
### `FN _format_usage` (NEW helper)
```contract
FN _format_usage(usage: dict, *, arrow: str) -> str
BRIEF: Natural-language usage formatting per INV-007. `arrow="->"` in CLI, `arrow="→"` in TUI.
PRE: [PRE-001 hard] usage has the four expected keys
POST: [POST-001 return_value] returns f"{prompt} in {arrow} {completion} out ({total} total, {cached} cached)"
STEPS:
1. p = usage["prompt_tokens"]; c = usage["completion_tokens"]; t = usage["total_tokens"]; ci = usage["cached_input_tokens"]
2. RETURN f"{p} in {arrow} {c} out ({t} total, {ci} cached)"
TESTS:
ascii_arrow: arrow="->" → "6756 in -> 126 out (6882 total, 0 cached)"
unicode_arrow: arrow="→" → "6756 in → 126 out (6882 total, 0 cached)"
zero_cached: cached=0 → "..., 0 cached)" (literal)
```
### `_amain` STEPS amended
`_amain` now constructs a `CliPresenterState` and threads it through `_run_turn`. The old stateless `_render_event(event, stdout, stderr)` call inside `_run_turn` becomes `state.render(event, stdout=stdout, stderr=stderr)`.
The `[create_session] session_id=... agent_id=...` lifecycle line is NOT routed through `state.render()` — it is not an SSE `Event` variant (CliPresenterState.render's PRE-001 only accepts `Event` union members), and the lifecycle line fires before any stream event has arrived. It remains a direct `sys.stderr.write(...)` call inside `_amain`, with the `. ` demotion prefix applied at the call site for consistency with the rest of the demoted-telemetry hierarchy:
```python
sys.stderr.write(f". create_session: session_id={info.session_id} agent_id={info.agent_id}\n")
```
Decision (was an open question): `[create_session]` is demoted, NOT load-bearing. It's a lifecycle observability marker, not the model's voice; demoting it preserves visual hierarchy with `[done]`/`[error]`/`[cancelled]` as the only no-prefix terminal events.
### TESTS amendments (issue #3 in-place)
Existing per-event TESTS in `_render_event` block become tests on `CliPresenterState.render` with single-event sequences. New tests added for state transitions (above). The `_run_turn` tests stay; their assertions on stderr labels update for the new `. ` prefix on demoted events and the autoscale formatting.
---
## In-place amendments to issue #4 (`ratatoskr.tui`)
### `CLASS TuiPresenterState` (NEW)
```contract
CLASS TuiPresenterState
BRIEF: Stateful presenter for the TUI mode. Owns per-turn rendering state: in-flight thinking buffer + whether a thinking run is currently open. One instance per `_stream_turn_worker` invocation; discarded when the worker exits.
PROPERTIES:
thinking_buffer: list[str]
thinking_open: bool
METHODS:
render(event, *, log: RichLog, thinking_widget: Static, raw: bool) -> None
INV-WIRE-001: One instance per `_stream_turn_worker` invocation (INV-008).
INV-WIRE-002: Two-views-of-thinking decoupling (INV-004): thinking_widget updates per-delta; log receives one closed entry per run.
INV-WIRE-003: Render-exception fallback (INV-009): any exception in render() is caught at the boundary; original event renders as plain labeled log line + `[render_error]` log entry.
```
### `FN TuiPresenterState.render` (NEW)
```contract
FN TuiPresenterState.render(self, event: Event, *, log: RichLog, thinking_widget: Static, raw: bool) -> None
BRIEF: Render one event into the TUI with INV-002 hierarchy + INV-004 two-views-of-thinking + INV-009 exception fallback. Unicode allowed in output.
PRE: [PRE-001 hard] event is an instance of one of the Event union variants
PRE: [PRE-002 hard] log and thinking_widget are valid Textual widget references
POST: [POST-001 side_effect] for Thinking events: open thinking run if not open; append delta to thinking_buffer; update thinking_widget with last ~200 chars of buffer (… prefix when truncated); make widget visible if hidden
POST: [POST-002 side_effect] for non-Thinking events when thinking_open: close the thinking run by writing ONE dim+`· ` RichLog entry with the full accumulated thinking content; clear buffer; set thinking_open=False; clear + hide thinking_widget; THEN render the new event
POST: [POST-003 side_effect] for Text events: stream content into RichLog as raw text delta (no prefix, no demotion)
POST: [POST-004 side_effect] for Done/Error/Cancelled: write a no-prefix RichLog entry with the formatted label; ensure thinking widget is cleared+hidden if it wasn't already
POST: [POST-005 side_effect] for demoted-telemetry events except Thinking (WorkerPhase, TextBoundary, ToolStart, ToolResult): write a dim+`· ` RichLog entry
POST: [POST-006 side_effect] on Done events with NOT raw: after the [done] line, write a Rule + Markdown render of event.response per existing issue #4 INV-005
POST: [POST-007 exception] never propagates; on any internal exception, write a plain labeled RichLog line for the original event + a `[render_error] <type>` line (class name ONLY, no exception message — INV-009 security clause); degrade gracefully
STEPS:
1. [setup, flexibility=prescriptive] Try-block wraps the whole body for INV-009 fallback
2. [branch, flexibility=prescriptive] IF isinstance(event, Thinking):
IF NOT self.thinking_open:
thinking_widget.display = True
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)
RETURN
3. [branch, flexibility=prescriptive] IF self.thinking_open:
full_thinking = "".join(self.thinking_buffer)
log.write(_dim_demoted("· thinking: " + full_thinking)) # ONE closed entry per run
self.thinking_buffer.clear()
self.thinking_open = False
thinking_widget.update("")
thinking_widget.display = False
4. [branch, flexibility=prescriptive] IF isinstance(event, Text):
log.write(event.content) # streaming; no prefix
RETURN
5. [branch, flexibility=prescriptive] IF isinstance(event, Done):
log.write(_format_terminal_label(event, arrow="→"))
IF NOT raw:
from rich.markdown import Markdown
from rich.rule import Rule
log.write(Rule())
log.write(Markdown(event.response))
# Belt-and-braces: ensure widget is cleared+hidden
thinking_widget.update("")
thinking_widget.display = False
RETURN
6. [branch, flexibility=prescriptive] IF isinstance(event, (Error, Cancelled)):
log.write(_format_terminal_label(event, arrow="→"))
thinking_widget.update("")
thinking_widget.display = False
RETURN
7. [branch, flexibility=prescriptive] # Demoted telemetry except Thinking
label = _format_demoted_label(event, arrow="→")
log.write(_dim_demoted("· " + label))
CATCH (Exception as exc):
# INV-009 fallback: type-only, NO exception message (security)
log.write(_format_plain_label(event)) # pre-amendment behavior
log.write(f"[render_error] {type(exc).__name__}")
TESTS:
thinking_coalesce_single_widget_update [happy,tracer]: 3 Thinking events; thinking_widget.update called 3 times with cumulative content (last delta = full content truncated); RichLog has 0 thinking entries yet
thinking_closes_one_richlog_entry [happy]: Thinking, Thinking, WorkerPhase → RichLog has exactly ONE thinking entry (closed run) + the worker_phase entry; thinking_widget cleared+hidden
thinking_widget_truncation [trace]: thinking_buffer 500 chars → widget displays "…" + last 200
thinking_widget_visibility_lifecycle [trace]: hidden at start; visible after first Thinking; hidden after closing event
multiple_thinking_runs_each_get_richlog_entry [scenario]: Thinking, Text, Thinking, Done → TWO closed thinking RichLog entries
cancelled_mid_thinking_closes [scenario]: Thinking, Cancelled → one closed thinking RichLog entry, then [cancelled] entry; widget hidden
done_renders_markdown_after_label [happy]: Text("hi"), Done(response="hi") with NOT raw → [done] line, Rule, Markdown(text="hi") in RichLog
raw_flag_skips_markdown [trace]: same with raw=True → no Rule, no Markdown
worker_phase_demoted [trace]: WorkerPhase → RichLog line starts with "· worker_phase:" with dim style
tool_start_demoted [trace]: ToolStart → RichLog line starts with "· tool_start:" with dim style
text_no_prefix [trace]: Text → RichLog line has no `·` prefix, no demotion
render_exception_fallback [adversarial]: monkeypatch thinking_widget.update to raise → RichLog gets a plain labeled fallback line + a `[render_error] <ExceptionClassName>` line (no message content per INV-009 security); worker does not crash
state_reset_per_worker [trace]: two consecutive _stream_turn_worker invocations; second one starts with thinking_open=False (fresh state)
duration_format_seconds [trace]: Done(duration_ms=5467) → label contains "duration=5.5s"
usage_format_unicode_arrow [trace]: Done → label contains "usage 6756 in → 126 out (6882 total, 0 cached)"
```
### `RatatoskrApp.compose` STEPS amended
`compose()` now yields one additional widget: `Static("", id="thinking-current")`. The widget is hidden by default — implementer either constructs with `display=False` (if Static's constructor supports it directly) OR sets `widget.display = False` in `on_mount` after compose. Visibility is toggled via Textual's reactive `Widget.display: bool` attribute (`widget.display = True/False`) per INV-004 lifecycle. The contract does NOT prescribe `classes="hidden"` + CSS or `widget.styles.display` mutation — both are equivalent in effect, but the reactive-attribute path is the chosen mechanism for this amendment to avoid implementation churn.
### `_stream_turn_worker` STEPS amended
`_stream_turn_worker` now constructs a `TuiPresenterState` at the top and threads it through the per-event loop. Replaces the existing `_render_event_to_log(event, log=log, raw=self.args.raw)` call with `state.render(event, log=log, thinking_widget=self.query_one("#thinking-current", Static), raw=self.args.raw)`.
### TESTS amendments (issue #4 in-place)
Existing per-event TESTS in `_render_event_to_log` block become tests on `TuiPresenterState.render`. New tests added for state transitions (above) and the new Static widget lifecycle. `test_happy_text_done_renders_markdown` (issue #4) stays but updates its assertions for the new no-prefix Text + label format.
---
## Acceptance
- Issue #3 contract amended in-place per the cli section above.
- Issue #4 contract amended in-place per the tui section above.
- All amended contracts drift-check clean.
- All existing tests + new `CliPresenterState` / `TuiPresenterState`
coverage GREEN under `uv run pytest tests/`.
- `uv run ruff check src/ tests/` clean.
- Boundary smoke `tests/test_no_worldtree_imports.py` still passes.
- Manual smoke: `source env.sh && uv run ratatoskr --new --agent mimir
--send "test"` produces:
- Thinking deltas render as ONE coalesced growing line ending with `\n`
on the first non-thinking event.
- WorkerPhase/TextBoundary/ToolStart/ToolResult lines start with `. `
prefix.
- `[done]` lands on its own line below the assistant text, with `usage
6756 in -> 126 out (...)` and `duration=5.5s` formatting.
- Manual smoke TUI: `source env.sh && uv run ratatoskr --new --agent
mimir` shows the dedicated `Static(id="thinking-current")` widget
visible during thinking, hidden after the turn terminal, with one
closed RichLog entry per thinking run.
- Regression: --send "..." > out.txt continues to write ONLY the
assistant's text content to out.txt (no demoted-telemetry leakage).
## Dependencies
- Issue #3 (`ratatoskr.cli`) — landed on main; this issue amends its
rendering contract in-place.
- Issue #4 (`ratatoskr.tui`) — landed on main; this issue amends its
rendering contract in-place.
- Cross-frontier design pass with eitri-smithy-dev (althing
`01KSBE52YZR5E3SPTKA672JE43`) — settled the 16 confirmed decisions +
4 material divergences baked into this contract.
+205
View File
@@ -0,0 +1,205 @@
---
contract_version: "2.1"
target_module: "ratatoskr.tui"
scope: "Design-brief §5 v1 entry point: reshape the TUI from vertical-stack single-pane to Horizontal two-column with `TabbedContent` on the right; first (and only v1) tab is `Tools`, which consumes `ToolStart` / `ToolResult` SSE events that previously rendered inline in the transcript. Pure in-place amendment to issue #4 + #12 — no new modules, no new files apart from this contract. The CLI (`ratatoskr.cli`) is unaffected: `--send` mode is non-interactive and keeps its current inline tool-event rendering. Substrate move only: persona / admin-events / bifrost-state / server-log panes stay deferred (blocked on remote-Worldtree topology + admin scope + opt-in flag). The TabbedContent shape makes them additive — when a blocker resolves the new pane plugs in as another TabPane sibling without further layout work."
depends_on:
- "textual"
used_by: []
language: "python"
complexity: "medium"
estimated_loc: 180
confidence: 0.85
assumptions:
- "Textual's `TabbedContent(*titles, initial='')` + `TabPane(title, *children, id=...)` is the right primitive for the right-column tabs (verified API at textual.widgets._tabbed_content). One `TabPane(\"Tools\", tools_log, id=\"tools-tab\")` in v1; additional siblings get appended as Persona/AdminEvents/BifrostState/ServerLog land."
- "Textual's `Horizontal` + `Vertical` containers compose the two-column split (verified at textual.containers). Width via CSS `width: 2fr` on the left container + `width: 1fr` on the right container gives the 2:1 chat-primary split."
- "**The presenter contract amendment is small and tightly scoped**: `TuiPresenterState.render` gains a `tools_log: RichLog` parameter alongside the existing `log: RichLog` (main transcript) + `thinking_widget: Static`. `ToolStart` / `ToolResult` events route to `tools_log`; every other event (Text, Thinking, WorkerPhase, Done, Error, Cancelled, TextBoundary) keeps its existing routing to `log` + `thinking_widget`. The plain-label fallback path in `_plain_label` (issue #12 INV-009 render-exception recovery) keeps its current shape — only the routing target changes."
- "**Tool events are routed, not duplicated**. The brief's §5 wording 'side pane (inline-from-SSE for v1)' factors tool events OUT of the main transcript. A consumer who wants to debug a tool-using turn now reads the Tools pane; the main transcript stays focused on assistant text. Trade-off: a fast-skim of the transcript no longer shows tool activity inline; if that hurts the debug ergonomics empirically, a follow-up issue can add a one-line `· tool_used name=...` breadcrumb to the transcript as a compromise. v1 commits to the cleaner split."
- "**Demoted-prefix style stays consistent across panes**. ToolStart in the Tools pane renders as `· tool_start: name=foo args={...}` — the same `· ` ASCII prefix issue #12 INV-005 established for demoted telemetry in the main transcript. Pane separation handles the visual hierarchy; prefix style stays cross-pane consistent so the operator's mental model is portable."
- "**Input field retains focus across tab switches** (design-brief §5 invariant: 'Tab key (Ctrl+1..5) jumps between tabs without losing focus on the input field'). INV-016 is the load-bearing invariant; the assumption about Textual's default behavior is just an implementation hint. If Textual's default `TabbedContent.active = ...` programmatic assignment preserves Input focus (current observed behavior), no extra code is needed. If a future Textual version regresses on this, the implementation MUST add `self.query_one('#prompt', Input).focus()` immediately after the `.active = ` assignment in `action_focus_tools` to satisfy INV-016. The test `test_ctrl_1_preserves_input_focus` is the regression guard; if it fails, the fix is the explicit `.focus()` call, not relaxing the invariant. Pre-existing INV-007 (Input always-focused except during error sub-states) is preserved verbatim."
- "**Status footer gains a `current-pane-name` element** — design-brief §5 calls for it explicitly. v1 only has one tab so the indicator stable-renders \"Tools\". Wiring it as a separate Static (`id='pane-name'`) docked alongside identity + hint makes it trivially extend when more tabs land — the widget is in place; the value will become dynamic in the future multi-tab issue. **No tab-switch handler wiring lands in #13.** The `on_mount` flow populates `pane-name` once with the literal string \"Tools\" and never updates it. Adding event-handler plumbing in v1 (a `@on(TabbedContent.TabActivated)` handler, etc.) is out of scope — that's a deliberate deferral, not an implementer's call."
- "**The existing `· thinking-current` widget keeps its position** — docked to the top of the left column (was docked top of the whole App; now docked top of the left Vertical container). Pre-amendment dock-fix from v0.2.1 stays; the scope of the dock just narrows from \"App\" to \"left column\" so it doesn't bleed into the right column's TabbedContent area."
- "**Width split is fixed `2fr : 1fr` for v1**. User-resizable splits are textual-native (via `Splitter` or similar), but adding interactive resize is its own UX surface. v1 ships fixed; if the right pane proves cramped on narrow terminals operators will tell us. Out of scope."
- "**TabbedContent's CSS classes**: the right column's TabbedContent + its tabbed-content wrappers (`#tabbed-content`, `.--tabs`, etc.) come with Textual's default styling. No custom CSS for tab strip in v1; if the visual feels wrong adjust later. The contract specifies the structure; the chrome stays Textual-default."
- "**Test strategy**: existing TestStreamTurnWorker tests for Text/Thinking/Done/Error/Cancelled routing stay GREEN unchanged (they assert what shows in the transcript log; that still shows the same content). ToolStart/ToolResult tests get adjusted to assert routing to `tools_log` instead of `log`. New tests cover layout shape (Horizontal parent exists, TabbedContent with tools-tab on the right) + Ctrl+1 binding + tools_log writes for tool events."
- "**Issue #12's INV-009 render-exception fallback path** stays correct: `_plain_label(event)` is still callable; the render method's except branch writes the labeled-string to the appropriate Widget (tools_log for tool events; log for everything else). The fallback writes to the same routed widget as a successful render — failure mode preserves the routing invariant."
open_questions:
- "Should the Tools tab show a count badge when new tool events arrive while the user is on a future Persona/AdminEvents tab? Draft: no for v1 — only one tab, so the question is moot. When persona/admin-events panes land, revisit: a small `[N]` badge on the tab header (`'Tools [3]'`) would help operators not miss tool activity that happens off-screen. Defer to a follow-up that touches multiple panes."
- "Should the Tools pane support filter-by-tool-name (e.g., show only `kb_search` results)? Draft: no for v1 — flat scroll matches the design brief's posture. The volume of tool events per turn is small enough that scrollback handles the use case. Revisit if mimir-style heavy-tool agents produce visible-cluttering volume."
- "Should the v1 Ctrl+1 binding be Ctrl+1 specifically, or `t` for 'tools' (no modifier)? Draft: Ctrl+1 — design brief specifies `Ctrl+1..5` as the family; matches the Ctrl-prefix discipline already used by Ctrl-C / Ctrl-D bindings. Plain-letter bindings would steal letter input from the Input field; Ctrl-prefixed is the standard escape."
prd:
issue: 13
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/13"
body_sha256_16: "52c8f886a9cc986a"
lock_in_comment_id: null
lock_in_sha256_16: null
lock_in_at: null
pinned_at: "2026-05-24T02:28:28+00:00"
dependencies:
- issue: 4
path: "src/ratatoskr/tui.py"
reason: "In-place contract amendment: `RatatoskrApp.compose()` reshapes from vertical-stack to Horizontal 2-col + TabbedContent right; `RatatoskrApp.DEFAULT_CSS` reshapes to scope dock rules to the new left/right containers; `RatatoskrApp.__init__` is unchanged (state attributes carry over); `RatatoskrApp.on_mount` gains pane-name widget population + tools_log lookup; `RatatoskrApp.BINDINGS` gains `Ctrl+1``action_focus_tools`; existing `action_interrupt` / `action_quit` unchanged."
- issue: 12
path: "src/ratatoskr/tui.py"
reason: "In-place contract amendment: `TuiPresenterState.render` signature widens to accept `tools_log: RichLog` alongside `log` + `thinking_widget`; ToolStart/ToolResult branches write to `tools_log` instead of `log`. `_stream_turn_worker` does the new lookup (`self.query_one('#tools-log', RichLog)`) and threads it through. All other event branches stay verbatim."
---
# TUI layout reshape + Tools pane — §5 v1 entry point
## Context
`ratatoskr.tui` ships v0.x as a single-pane Textual app: Header / thinking-current widget / transcript RichLog / Input / identity + hint Statics / Footer, vertical-stacked via `dock: top` / `dock: bottom` CSS (v0.2.1 layout fix). Design-brief §5 commits the product to a multi-pane debug-observability dashboard. Most of §5's panes (Persona, AdminEvents, BifrostState widget, ServerLog) are blocked on substrate that isn't here — remote-Worldtree topology blocks file-tail-based panes; issue #11's `admin.events.read` scope blocks the admin surfaces.
The unblocked v1 entry point is **layout reshape + Tools pane together**: reshape the TUI into the Horizontal two-column shape the design brief specifies, with `TabbedContent` on the right populated by a single `Tools` tab that consumes the existing `ToolStart` / `ToolResult` SSE events. No new endpoints; no scope grants; no cross-repo coordination. The shape is the foundation; subsequent panes plug in additively.
## Data flow
**Input** (unchanged from issue #4):
- `args: ParsedArgs`, `session_id: str`, `agent_id: str | None`, `client: httpx.AsyncClient`.
- SSE event stream from `ratatoskr.sse_client.stream_turn`.
**Output** (unchanged):
- Exit code via `App.exit(code)`.
**Internal routing change**:
- `ToolStart` / `ToolResult` events route to `tools_log: RichLog` (Tools pane) instead of the main transcript `log: RichLog`.
- All other event types (`Text`, `Thinking`, `WorkerPhase`, `TextBoundary`, `Done`, `Error`, `Cancelled`) keep their existing routing.
## Layout shape (post-amendment)
```
RatatoskrApp(App[int]):
compose():
yield Header()
yield Horizontal(
Vertical(
Static(id="thinking-current"), # dock: top of left column
RichLog(id="transcript"), # height: 1fr (fills middle)
Input(id="prompt"), # dock: bottom of left column
id="left-column",
),
Vertical(
TabbedContent(
TabPane("Tools", RichLog(id="tools-log"), id="tools-tab"),
# future: TabPane("Persona", …, id="persona-tab"), etc.
id="side-panes",
),
id="right-column",
),
id="main-row",
)
yield Static(id="identity") # dock: bottom of App
yield Static(id="pane-name") # dock: bottom of App (new in §5)
yield Static(id="hint") # dock: bottom of App
yield Footer()
```
**DEFAULT_CSS reshape:**
```css
#main-row { height: 1fr; }
#left-column { width: 2fr; }
#right-column { width: 1fr; }
#thinking-current { dock: top; height: auto; }
#transcript { height: 1fr; }
#prompt { dock: bottom; }
#identity { dock: bottom; height: 1; }
#pane-name { dock: bottom; height: 1; }
#hint { dock: bottom; height: 1; }
```
Dock rules scope to the right container (left column for `thinking-current`/`prompt`; App for `identity`/`pane-name`/`hint`). The left-column `prompt` Input docks to the bottom of its column, not the App, so the right column's TabbedContent extends full height beside it.
## Presenter routing (amendment to issue #12)
```
FN TuiPresenterState.render(
event: Event,
*,
log: RichLog,
thinking_widget: Static,
tools_log: RichLog, # NEW (issue #13)
raw: bool,
) -> None
```
Steps (only the ToolStart/ToolResult cases change; every other case keeps issue #12's behavior verbatim):
- `ToolStart` → write `· tool_start: name=<name> args=<args!r>` to `tools_log` (not `log`).
- `ToolResult` → write `· tool_result: name=<name> duration_ms=<n> result=<r!r:.200>` to `tools_log` (not `log`).
- All other events → unchanged routing per issue #12 INV-005.
- Render-exception fallback (`_plain_label(event)`): write to `tools_log` if the event is `ToolStart`/`ToolResult`; write to `log` otherwise. Routing preservation under failure.
## Keybindings (amendment)
```
BINDINGS: ClassVar[list[Binding]] = [
Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True),
Binding("ctrl+d", "quit", "Exit immediately", priority=True),
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False), # NEW
]
def action_focus_tools(self) -> None:
self.query_one(TabbedContent).active = "tools-tab"
# Input focus is preserved by Textual's default behavior — TabbedContent
# doesn't steal focus when `.active` is set programmatically.
```
`Ctrl+1` is the v1 entry of the design-brief `Ctrl+1..5` family. `Ctrl+2..5` get added by subsequent issues as Persona/AdminEvents/BifrostState/ServerLog land. The binding does NOT steal Input focus — the test asserts `Input` keeps focus across the tab switch.
## Status footer (amendment)
New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` widgets. v1 renders the literal string `"Tools"` set once at `on_mount`; the widget never updates after that. Dynamic updating + tab-switch handler wiring is **out of scope for #13** — it lands in the multi-tab follow-up that introduces the second TabPane. An implementer who adds a `@on(TabbedContent.TabActivated)` handler in this issue is out of spec.
## Invariants
- **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-018**: CLI mode (`ratatoskr.cli._amain`) is unaffected. CLI keeps inline `· tool_start: …` / `· tool_result: …` rendering on stderr per issue #12 INV-005.
## TESTS (additions / changes to test_tui.py)
```
- test_compose_has_horizontal_main_row: RatatoskrApp.compose() yields a Horizontal with id="main-row" containing left-column + right-column children.
- test_compose_right_column_has_tabbed_content: query_one("#side-panes", TabbedContent) is non-None; one TabPane child with title="Tools" id="tools-tab".
- test_compose_left_column_has_transcript_input: query_one("#left-column", Vertical) contains #transcript (RichLog) + #prompt (Input).
- test_tools_log_present: query_one("#tools-log", RichLog) is non-None; lives inside the tools-tab TabPane.
- test_pane_name_widget_renders_tools: query_one("#pane-name", Static).renderable == "Tools" (v1 static).
- test_tool_start_routes_to_tools_log: stream_turn emits ToolStart → tools_log receives the line; transcript RichLog does NOT receive it.
- test_tool_result_routes_to_tools_log: stream_turn emits ToolResult → tools_log receives the line; transcript does NOT receive it.
- test_text_event_still_routes_to_transcript: stream_turn emits Text("hello") → transcript receives it; tools_log does NOT.
- test_thinking_event_still_routes_to_thinking_widget: thinking deltas continue to update #thinking-current Static, not tools_log.
- test_done_event_still_routes_to_transcript: Done event renders `[done] …` in transcript, not tools_log.
- test_ctrl_1_activates_tools_tab: simulate Ctrl+1 → TabbedContent.active == "tools-tab".
- test_ctrl_1_preserves_input_focus: simulate Ctrl+1 while Input is focused → Input is still focused afterwards.
- test_plain_label_fallback_routes_tool_events_to_tools_log: simulate render exception on a ToolStart → tools_log gets the _plain_label fallback string; transcript doesn't.
```
Existing tests that need adjustment (NOT rewrite):
- Any test that asserted `log.write(...)` was called with a `tool_start: …` / `tool_result: …` string changes its target widget to `tools_log` instead.
- TestAppMount tests gain `tools_log` widget lookup assertions.
## ERROR_ROUTING (unchanged)
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)
```
+────────────────────────────────+──────────────────────+
| · thinking-current | ┌─ Tools ─────────┐ |
| | │ · tool_start:.. │ |
| user-typed line | │ · tool_result.. │ |
| assistant streaming text... | │ │ |
| [done] turn_id=… duration=… | │ │ |
| | │ │ |
| | │ │ |
| [prompt: type and press Enter]| └─────────────────┘ |
+────────────────────────────────+──────────────────────+
| agent · …sess_id Tools Ctrl-C twice to exit |
+───────────────────────────────────────────────────────+
```
(Width split 2fr:1fr; tab strip is Textual-default.)
+64 -47
View File
@@ -321,7 +321,7 @@ BRIEF: Async orchestrator. Opens an authenticated httpx.AsyncClient, optionally
PRE: [PRE-001 hard] args is a ParsedArgs (post-validation; PRE-002/PRE-003 of _parse_args hold) -- assert isinstance(args, ParsedArgs)
POST: [POST-001 return_value] returns one of the documented exit codes (0, 2, 3, 12, 20, 21, 22)
POST: [POST-002 side_effect] when args.new is True, exactly one POST /sessions was issued -- assert respx tracked the call
POST: [POST-003 side_effect] when args.new is True, stderr contains "[create_session] session_id=... agent_id=..." before any stream events
POST: [POST-003 side_effect] when args.new is True, stderr contains ". create_session: session_id=... agent_id=..." before any stream events (issue #12 amendment: `[create_session]` demoted to `. create_session:` to match the telemetry hierarchy; written directly by `_amain` — bypasses `state.render` since it is not a wire-level Event variant)
POST: [POST-004 side_effect] the SIGINT handler is removed in cleanup (loop.remove_signal_handler called) -- verified via teardown probe in test fixtures
ERROR_ROUTING:
AgentNotFound:
@@ -346,7 +346,7 @@ STEPS:
WRITE stderr; RETURN 20
ON httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError as exc:
WRITE stderr; RETURN 21
WRITE f"[create_session] session_id={info.session_id} agent_id={info.agent_id}\n" to stderr
WRITE f". create_session: session_id={info.session_id} agent_id={info.agent_id}\n" to stderr (issue #12: demoted prefix; direct write bypasses state.render)
SET session_id = info.session_id
ELSE:
SET session_id = args.session_id # pre-validated non-None
@@ -360,7 +360,7 @@ STEPS:
loop.remove_signal_handler(signal.SIGINT)
5. [cleanup] RETURN exit_code
TESTS:
happy_new_session_then_stream [happy,tracer]: respx mocks POST /sessions → 201 + the SSE POST → text+done; argv specifies --new --agent mimir → _amain returns 0; stderr has "[create_session]" before "[done]"
happy_new_session_then_stream [happy,tracer]: respx mocks POST /sessions → 201 + the SSE POST → text+done; argv specifies --new --agent mimir → _amain returns 0; stderr has ". create_session:" before "[done]" (issue #12: demoted prefix; pre-amendment shape "[create_session]" forbidden)
happy_existing_session [happy]: respx mocks the SSE POST only; argv specifies --session s-1 → _amain returns 0; respx tracked exactly 0 POST /sessions calls
agent_not_found_exits_12 [error]: respx mocks POST /sessions → 404; --new → returns 12; stderr "[agent_not_found]"; stream_turn never invoked
session_api_failed_exits_20 [error]: respx mocks POST /sessions → 500 with body → returns 20; stderr "[session_api_failed] status=500 body=..."
@@ -370,58 +370,75 @@ TESTS:
```
```contract
FN _render_event(event: Event, *, stdout: TextIO, stderr: TextIO) -> None
BRIEF: Pure event-to-output renderer. Routes `Text` deltas to stdout (with per-chunk flush per INV-010); routes every other Event variant to stderr with a labeled line. No I/O outside the two passed TextIO objects; no side effects on the event itself.
PRE: [PRE-001 hard] event is an instance of one of the Event union variants -- assert isinstance(event, (WorkerPhase, Thinking, Text, TextBoundary, ToolStart, ToolResult, Done, Error, Cancelled))
POST: [POST-001 side_effect] for Text events: stdout received event.content (no newline appended) AND stdout was flushed -- assert stdout.getvalue().endswith(event.content) and stdout.flush.called
POST: [POST-002 side_effect] for non-Text-non-Done events (WorkerPhase, Thinking, TextBoundary, ToolStart, ToolResult, Error, Cancelled): stdout was NOT written to (INV-002); stderr received exactly one line ending in newline -- assert stdout.getvalue() == "" and stderr.getvalue().endswith("\n")
POST: [POST-003 side_effect] for Done: stdout receives a single newline AND is flushed; stderr receives a single labeled line including `turn_id` (from event.sse_id.turn_id) + `model` + `duration_ms` (INV-002 carve-out — Done is the one non-Text variant that writes to stdout) -- assert stdout.getvalue() == "\n" and stderr.getvalue().startswith("[done]")
ERROR_ROUTING:
(none — pure function over the typed union; if an instance doesn't match any branch, PRE-001 catches it as an assertion failure)
STEPS:
1. [setup, flexibility=prescriptive] Match on `type(event)`:
2. [branch, flexibility=prescriptive]
CASE Text:
stdout.write(event.content); stdout.flush()
CASE Done:
stdout.write("\n"); stdout.flush()
stderr.write(f"[done] turn_id={event.sse_id.turn_id} model={event.model} duration_ms={event.duration_ms} usage={event.usage!r}\n")
CASE Error:
stderr.write(f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} message={event.message!r}\n")
CASE Cancelled:
stderr.write(f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} partial_message_id={event.partial_message_id}\n")
CASE WorkerPhase:
stderr.write(f"[worker_phase] phase={event.phase} turn_id={event.turn_id}\n")
CASE Thinking:
stderr.write(f"[thinking] {event.content[:200]!r}\n")
CASE TextBoundary:
stderr.write(f"[text_boundary] kind={event.kind} char_offset={event.char_offset}\n")
CASE ToolStart:
stderr.write(f"[tool_start] name={event.name} args={event.arguments!r}\n")
CASE ToolResult:
stderr.write(f"[tool_result] name={event.name} duration_ms={event.duration_ms} result={event.result!r:.200}\n")
TESTS:
text_to_stdout_only [happy,tracer]: Text(content="hello", sse_id=...) → stdout=="hello"; stderr==""; stdout.flush called once
done_writes_newline_and_label [happy]: Done(sse_id=(42,5), model="glm5-turbo", duration_ms=1234, ...) → stdout=="\n"; stderr starts with "[done]" and contains "turn_id=42" + "model=glm5-turbo"
error_to_stderr_only [happy]: Error(sse_id=(42,5), error_code="llm_output_invalid", message="m", ...) → stdout==""; stderr starts with "[error]"; contains "code=llm_output_invalid"; turn_id from sse_id
cancelled_to_stderr_only [happy]: Cancelled(sse_id=(42,5), turn_id=42, reason="user", partial_message_id=7) → stderr starts with "[cancelled]" and contains "reason='user'" + "partial_message_id=7"; stdout==""
worker_phase_to_stderr [happy]: WorkerPhase(phase="streaming", turn_id=42, ...) → stderr starts with "[worker_phase]"; stdout==""
thinking_truncated [trace]: Thinking(content="a"*500, ...) → stderr line includes only first 200 chars of content
tool_start_to_stderr [happy]: ToolStart(name="read_file", arguments={"path": "/x"}, ...) → stderr starts with "[tool_start] name=read_file args="
tool_result_truncated [trace]: ToolResult(name="x", result="b"*500, duration_ms=42, ...) → stderr line repr truncated to ≤200 chars in result field
text_boundary_to_stderr [happy]: TextBoundary(kind="sentence", char_offset=128, ...) → stderr starts with "[text_boundary]"
invariant_inv003_stderr_only [scenario]: emit one of each non-Text variant in sequence; assert stdout buffer is empty after each (INV-003 verified by exhaustion of the non-Text union)
CLASS CliPresenterState # issue #12 amendment
BRIEF: Stateful per-turn presenter for `--send` mode. Replaces the stateless `_render_event` (removed). Owns `thinking_buffer`, `thinking_open`, `text_written_since_newline`; coalesces thinking-event deltas into one growing stderr line per run; demotes telemetry events with a `. ` prefix; guarantees a stdout `\n` boundary before terminal labels (`[done]`, `[error]`, `[cancelled]`) when assistant text has been streamed.
PROPERTIES:
thinking_buffer: list[str]
thinking_open: bool
text_written_since_newline: bool
INV-WIRE-001: One instance per `_amain` call (issue #12 INV-008).
```
```contract
FN _run_turn(client: httpx.AsyncClient, session_id: str, content: str, sigint_event: asyncio.Event, *, stdout: TextIO, stderr: TextIO) -> int
FN CliPresenterState.render(self, event: Event, *, stdout: TextIO, stderr: TextIO) -> None # issue #12 amendment
BRIEF: Render one event into stdout/stderr with editorial hierarchy + thinking coalescing per issue #12 INV-001..INV-007. ASCII-only output (no Unicode in CLI). Demoted-telemetry events get `. ` prefix on stderr; load-bearing events (Text on stdout; Done/Error/Cancelled on stderr) get no prefix.
PRE: [PRE-001 hard] event is an instance of one of the Event union variants
POST: [POST-001 side_effect] for Thinking: append delta to thinking_buffer; write to stderr (with `. thinking: ` prefix on the first delta of the run, content-only on subsequent deltas); set thinking_open=True
POST: [POST-002 side_effect] for non-Thinking when thinking_open: write `\n` to stderr; clear buffer; thinking_open=False; THEN render the new event
POST: [POST-003 side_effect] for Text: write event.content to stdout (no forced newline); set text_written_since_newline = not event.content.endswith("\n") (Volva F4 fix)
POST: [POST-004 side_effect] for Done/Error/Cancelled: if text_written_since_newline, write `\n` to stdout + flush + reset flag (INV-005); then write the load-bearing terminal label to stderr (no demotion prefix); for Done, format `duration=<autoscale>` + `usage <p> in -> <c> out (<t> total, <ci> cached)` via INV-006 / INV-007 helpers
POST: [POST-005 side_effect] for demoted telemetry (WorkerPhase, TextBoundary, ToolStart, ToolResult): write `. <label>: <fields>\n` to stderr
ERROR_ROUTING:
(none at this level — pure dispatch over the typed union)
TESTS:
thinking_coalesce_single_run [happy,tracer]: Thinking("hello"), Thinking(" world"), Done → stderr has ". thinking: hello world\n" then "[done] ..."; no demotion prefix on [done]
thinking_closes_on_first_non_thinking_event [happy]: Thinking, WorkerPhase → ". thinking: ...\n" then ". worker_phase: ..."
thinking_closes_on_error [error]: Thinking, Error → thinking closes with \n; partial thinking preserved; "[error]" rendered (no demotion prefix)
multiple_thinking_runs [scenario]: Thinking, Text, Thinking, Done → TWO ". thinking: " runs; stdout receives Text + INV-005 boundary before [done]
text_then_done_newline_boundary [trace]: Text("answer"), Done → stdout=="answer\n"; stderr has [done]
no_text_then_done_no_extra_newline [trace]: Done with no Text → stdout untouched
newline_terminated_text_then_done [trace, Volva F4]: Text("answer\n"), Done → stdout="answer\n" exactly once (no double newline)
cancelled_mid_thinking [scenario]: Thinking, Cancelled → thinking closes; "[cancelled]" without demotion prefix
worker_phase_demoted [trace]: stderr line starts with ". worker_phase:" not "[worker_phase]"
tool_start_demoted [trace]: ". tool_start:" prefix
tool_result_truncated [trace]: ". tool_result:" + ≤200 chars of result repr
text_boundary_demoted [trace]: ". text_boundary:" prefix
duration_format_seconds [trace]: Done(duration_ms=5467) → "duration=5.5s" (not duration_ms=5467)
duration_format_subsecond [trace]: Done(duration_ms=347) → "duration=347ms"
duration_format_minutes [trace]: Done(duration_ms=72000) → "duration=1.2m"
usage_format_ascii_arrow [trace]: Done → "usage 6756 in -> 126 out (6882 total, 0 cached)" (ASCII arrow, not Unicode)
state_reset_per_amain [trace]: two independent CliPresenterState() instances; the second starts with thinking_open=False
```
```contract
FN _format_duration_ms(ms: int) -> str # issue #12 INV-006 helper
BRIEF: Auto-scale duration formatting. ms<1000 → "{ms}ms"; ms<60_000 → "{s:.1f}s"; else "{m:.1f}m". Locale-blind.
TESTS:
subsecond: 347 → "347ms"
exact_one_second: 1000 → "1.0s"
fractional_seconds: 5467 → "5.5s"
exact_one_minute: 60000 → "1.0m"
fractional_minutes: 72000 → "1.2m"
zero: 0 → "0ms"
```
```contract
FN _format_usage(usage: dict, *, arrow: str) -> str # issue #12 INV-007 helper
BRIEF: Natural-language usage formatting. arrow="->" for CLI (ASCII), arrow="→" for TUI (Unicode).
TESTS:
ascii_arrow: arrow="->" → "6756 in -> 126 out (6882 total, 0 cached)"
unicode_arrow: arrow="→" → "6756 in → 126 out (6882 total, 0 cached)"
```
```contract
FN _run_turn(client: httpx.AsyncClient, session_id: str, content: str, sigint_event: asyncio.Event, *, stdout: TextIO, stderr: TextIO, state: CliPresenterState | None = None) -> int # issue #12 amendment: `state` kwarg threaded by `_amain`; defaults to a fresh state when omitted so tests can construct standalone
BRIEF: Drive `stream_turn`, render events, race each `__anext__()` against `sigint_event.wait()` so a SIGINT lands within one event boundary. On first SIGINT (with last_turn_id known), spawn `cancel_turn` as a background task and keep draining until the `Cancelled` terminal event arrives. Map terminal events and uncaught exceptions to exit codes per the Data flow table.
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] session_id is a non-empty string -- assert session_id and isinstance(session_id, str)
PRE: [PRE-003 hard] content is a non-empty string -- assert content and isinstance(content, str)
PRE: [PRE-004 hard] sigint_event is an asyncio.Event -- assert isinstance(sigint_event, asyncio.Event)
POST: [POST-001 return_value] returns one of (0, 2, 3, 20, 21, 22) — terminal-event-driven OR exception-mapped
POST: [POST-002 side_effect] each yielded event passed through _render_event exactly once -- spy on _render_event call count == event count
POST: [POST-002 side_effect] each yielded event passed through CliPresenterState.render exactly once -- spy on CliPresenterState.render call count == event count (issue #12 amendment: was _render_event)
POST: [POST-003 side_effect] sigint mid-stream issues exactly one cancel_turn HTTP call -- assert respx tracked one POST /sessions/{id}/turns/{turn_id}/cancel
POST: [POST-004 side_effect] sigint before any event yields zero cancel_turn calls -- INV-008: turn_id is unknown so cancel cannot be issued
POST: [POST-005 side_effect] cancel_failed during sigint drains writes "[cancel_failed]" to stderr but does NOT raise -- INV-009: primary exit code is the stream's terminal-event code
@@ -493,7 +510,7 @@ STEPS:
# exception type → stderr label + exit_code per ERROR_ROUTING
RETURN <mapped exit code>
last_turn_id = event.sse_id.turn_id
_render_event(event, stdout=stdout, stderr=stderr)
state.render(event, stdout=stdout, stderr=stderr) # issue #12 amendment
IF isinstance(event, Done):
IF NOT cancelling: sigint_task.cancel()
RETURN 0
+39 -32
View File
@@ -299,12 +299,12 @@ TESTS:
```contract
FN RatatoskrApp._stream_turn_worker(self, content: str) -> None
BRIEF: Worker coroutine spawned by `on_input_submitted`. Drives `stream_turn`, renders each event into the RichLog via `_render_event_to_log`, captures `active_turn_id` from the first event for the Ctrl-C cancel path, and transitions state back to "idle" after the terminal event (or on a mid-session error).
BRIEF: Worker coroutine spawned by `on_input_submitted`. Drives `stream_turn`, renders each event into the RichLog via a freshly-constructed `TuiPresenterState` instance (issue #12 amendment: was `_render_event_to_log`), captures `active_turn_id` from the first event for the Ctrl-C cancel path, and transitions state back to "idle" after the terminal event (or on a mid-session error).
PRE: [PRE-001 hard] self.state == "streaming" (set by on_input_submitted before spawn) -- assert self.state == "streaming"
PRE: [PRE-002 hard] self.client is not None (set in on_mount) -- assert self.client is not None
PRE: [PRE-003 hard] content is non-empty (caller validated in on_input_submitted) -- assert content
POST: [POST-001 state_change] after terminal event OR error, self.state == "idle"; self.active_turn_id is None; footer hint reset to "Ctrl-C twice to exit"
POST: [POST-002 side_effect] each event passed through _render_event_to_log exactly once (until terminal OR until cancel-induced abort)
POST: [POST-002 side_effect] each event passed through TuiPresenterState.render exactly once (until terminal OR until cancel-induced abort) (issue #12 amendment: was _render_event_to_log)
POST: [POST-003 side_effect] for Done events with NOT args.raw: a separator line + the markdown-rendered Done.response appended to RichLog (INV-005)
POST: [POST-004 state_change] active_turn_id is set to event.sse_id.turn_id on the FIRST yielded event (for cancel_turn use by action_interrupt)
ERROR_ROUTING:
@@ -320,7 +320,7 @@ STEPS:
1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-003
2. [loop, flexibility=prescriptive] TRY: async for event in stream_turn(self.client, self.session_id, content):
IF self.active_turn_id is None: SET self.active_turn_id = event.sse_id.turn_id # POST-004
_render_event_to_log(event, log=self.query_one("#transcript", RichLog), raw=self.args.raw)
presenter.render(event, log=self.query_one("#transcript", RichLog), thinking_widget=self.query_one("#thinking-current", Static), raw=self.args.raw) # issue #12: state-based rendering
IF isinstance(event, Done):
IF NOT self.args.raw:
Append a horizontal-rule separator to RichLog
@@ -341,39 +341,46 @@ TESTS:
sse_connect_failed_returns_to_idle [error]: mock returns 404 → "[sse_connect_failed]" label in RichLog; state → idle; app does NOT exit (INV-008)
connection_dropped_returns_to_idle [error]: mock raises RemoteProtocolError mid-stream → "[connection_dropped]" label; state → idle
malformed_sse_data_returns_to_idle [error,issue#7]: mock yields text + event with `data: not-json` → "[malformed_sse_data]" label; state → idle; app does NOT exit (INV-008)
rendered_event_per_event [trace]: spy on _render_event_to_log; mock yields N events; call_count == N (terminal events included, since Done/Error/Cancelled also render through it)
rendered_event_per_event [trace]: spy on TuiPresenterState.render (issue #12 amendment: was _render_event_to_log); mock yields N events; call_count == N
```
```contract
FN _render_event_to_log(event: Event, *, log: RichLog, raw: bool) -> None
BRIEF: Pure event-to-RichLog renderer. Routes `Text` event deltas (raw text appended to the log) and labels every other Event variant (consistent with cli.py's `_render_event` but writes to a RichLog widget instead of stdout/stderr). The post-Done markdown render is NOT this function's job — it lives in `_stream_turn_worker` so the contract concern (per-event labeling) stays separate from the per-turn concern (post-Done markdown).
PRE: [PRE-001 hard] event is an instance of one of the Event union variants -- assert isinstance(event, (WorkerPhase, Thinking, Text, TextBoundary, ToolStart, ToolResult, Done, Error, Cancelled))
POST: [POST-001 side_effect] for Text events: log received event.content as a streamed delta (no newline appended per delta — RichLog handles chunk-by-chunk display)
POST: [POST-002 side_effect] for non-Text events: log received exactly one labeled line per event
POST: [POST-003 side_effect] Done event renders the same label format as cli.py's `_render_event` (turn_id from sse_id, model, duration_ms, usage); the post-Done markdown render is the caller's responsibility (NOT this function's)
STEPS:
1. [setup, flexibility=prescriptive] Match on type(event)
2. [branch, flexibility=prescriptive] Same case-table as cli._render_event but writing log.write(...) instead of stdout/stderr:
CASE Text: log.write(event.content) (raw text; RichLog handles wrap)
CASE Done: log.write(f"[done] turn_id={event.sse_id.turn_id} model={event.model} duration_ms={event.duration_ms} usage={event.usage!r}")
CASE Error: log.write(f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} message={event.message!r}")
CASE Cancelled: log.write(f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} partial_message_id={event.partial_message_id}")
CASE WorkerPhase: log.write(f"[worker_phase] phase={event.phase} turn_id={event.turn_id}")
CASE Thinking: log.write(f"[thinking] {event.content[:200]!r}")
CASE TextBoundary: log.write(f"[text_boundary] kind={event.kind} char_offset={event.char_offset}")
CASE ToolStart: log.write(f"[tool_start] name={event.name} args={event.arguments!r}")
CASE ToolResult: log.write(f"[tool_result] name={event.name} duration_ms={event.duration_ms} result={event.result!r:.200}")
# Note on {!r:.200}: this is valid Python f-string syntax — `!r` converts via repr(), then `:.200` is the format spec which for strings truncates to 200 chars. The composition yields a repr() that is at most 200 chars long (quotes count). Mirrors cli.py's _render_event for consistency.
CLASS TuiPresenterState # issue #12 amendment
BRIEF: Stateful per-turn presenter for TUI mode. Replaces the stateless `_render_event_to_log` (removed). Owns `thinking_buffer`, `thinking_open`; coalesces thinking-event deltas into per-delta live updates on the dedicated `Static(id="thinking-current")` widget AND one closed RichLog entry per run (two-views-of-thinking decoupling); demotes telemetry events with a `· ` dim prefix on RichLog; Done renders a load-bearing label + Markdown (when not raw); render exceptions degrade to a plain-labeled fallback + `[render_error] <type>` line (NO exception message per INV-009 security).
PROPERTIES:
thinking_buffer: list[str]
thinking_open: bool
INV-WIRE-001: One instance per `_stream_turn_worker` invocation (issue #12 INV-008).
INV-WIRE-002: Two-views-of-thinking decoupling (issue #12 INV-004): per-delta updates → thinking-current Static; closed run → RichLog entry.
```
```contract
FN TuiPresenterState.render(self, event: Event, *, log: RichLog, thinking_widget: Static, raw: bool) -> None # issue #12 amendment
BRIEF: Render one event into the TUI with editorial hierarchy + coalescing per issue #12 INV-001..INV-007 + render-exception fallback per INV-009. Unicode allowed in TUI output (e.g., `· ` U+00B7 prefix, `→` U+2192 arrow in usage). Decoupling: thinking deltas go to `thinking_widget` per-delta; one closed RichLog entry per thinking-run.
PRE: [PRE-001 hard] event is an instance of one of the Event union variants
POST: [POST-001 side_effect] for Thinking: open run (display=True, thinking_open=True) on first delta; append to buffer; update widget with last ~200 chars (… prefix when truncated)
POST: [POST-002 side_effect] for non-Thinking when thinking_open: write ONE RichLog entry `· thinking: <full>`; clear buffer; thinking_open=False; widget cleared + display=False; THEN render the new event
POST: [POST-003 side_effect] for Text: write content to RichLog (no prefix, no demotion)
POST: [POST-004 side_effect] for Done: write `[done] turn_id=... model=... duration={autoscale} usage={p} in → {c} out ({t} total, {ci} cached)`; if NOT raw, append Rule() + Markdown(event.response) per issue #4 INV-005
POST: [POST-005 side_effect] for Error/Cancelled: write the load-bearing label (no demotion); ensure widget cleared+hidden
POST: [POST-006 side_effect] for demoted telemetry (WorkerPhase, TextBoundary, ToolStart, ToolResult): write `· <label>: <fields>` to RichLog
POST: [POST-007 exception] never propagates; on internal exception, write the plain labeled fallback + `[render_error] <ExceptionClassName>` (NO exception message — INV-009 security clause)
TESTS:
text_renders_raw_delta [happy,tracer]: Text(content="hello") → log received "hello" (verify via log.lines or a spy on log.write)
done_renders_label_only [happy]: Done(...) → log line starts with "[done]"; does NOT include the post-Done markdown render (caller's job)
error_renders_label [happy]: Error(...) → log line starts with "[error]"
cancelled_renders_label [happy]: Cancelled(...) → log line starts with "[cancelled]"
worker_phase_renders_label [happy]: WorkerPhase → "[worker_phase]"
thinking_truncated [trace]: Thinking(content="a"*500) → log line shows only first 200 chars in repr
tool_start_renders_label [happy]: ToolStart → "[tool_start]"
tool_result_truncated [trace]: ToolResult(result="b"*500) → repr truncated to ≤200 chars
text_boundary_renders_label [happy]: TextBoundary → "[text_boundary]"
thinking_coalesce_single_widget_update [happy,tracer]: 3 Thinking events → widget.update called 3× with cumulative content; RichLog has 0 thinking entries yet
thinking_closes_one_richlog_entry [happy]: 2× Thinking + WorkerPhase → ONE closed thinking entry + worker_phase entry; widget cleared+hidden
thinking_widget_truncation [trace]: 500-char buffer → widget shows "…" + last 200
thinking_widget_visibility_lifecycle [trace]: hidden initially; visible during run; hidden after closing event
multiple_thinking_runs_each_get_richlog_entry [scenario]: Thinking → Text → Thinking → Done → TWO closed thinking entries
cancelled_mid_thinking_closes [scenario]: Thinking → Cancelled → ONE closed thinking entry + [cancelled]; widget hidden
done_renders_markdown_after_label [happy]: Text + Done(response=...) with NOT raw → [done] line, Rule, Markdown
raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown
worker_phase_demoted [trace]: "· worker_phase:" prefix (not "[worker_phase]")
tool_start_demoted [trace]: "· tool_start:" prefix
text_no_prefix [trace]: Text → no demotion prefix
render_exception_fallback [adversarial]: widget.update raises → fallback line + `[render_error] <ClassName>` (NO message); state does not propagate
state_reset_per_worker [trace]: fresh TuiPresenterState() starts with no thinking open
duration_format_seconds [trace]: Done(duration_ms=5467) → "duration=5.5s"
usage_format_unicode_arrow [trace]: Done → "usage ... in → ... out (...)" (Unicode arrow, not ASCII)
```
```contract
+323
View File
@@ -0,0 +1,323 @@
---
contract_version: "2.1"
target_module: "ratatoskr.sessions"
scope: "Startup agent picker for TUI mode when `--new` is passed without `--agent`. Small surface change distributed across three existing modules via in-place contract amendments: `ratatoskr.sessions` gains `list_agents()` (GET /agents) returning `list[AgentInfo]` (new frozen dataclass with omit-when-null defaults mirroring SessionInfo's INV-001/INV-002 pattern); `ratatoskr.cli` softens `--agent` requirement from absolute to mode-conditional (`--send --new` still requires it; bare `--new` accepts None; `--session` still forbids it); `ratatoskr.tui._resolve_then_run` gains a pre-create branch that, when `args.new and args.agent_id is None`, calls `list_agents(client)` then runs a dedicated tiny `AgentPickerApp` (separate Textual `App` instance, opens before the main `RatatoskrApp`) whose `run_async()` returns the chosen `agent_id` (or `None` on Esc/Ctrl-D for clean exit). No new files; no wire-level surface change beyond the new endpoint hit. Composes naturally with #5 (`--end-user-id`): both thread through `ParsedArgs` before any App opens."
depends_on:
- "httpx"
- "textual"
used_by:
- "ratatoskr.cli"
- "ratatoskr.tui"
language: "python"
complexity: "low"
estimated_loc: 200
confidence: 0.85
assumptions:
- "Worldtree spec pin (`docs/conversation-api-spec.md` v0.19.0, repo SHA `55101e909abcd2219833266b6f905c5bc956e0f0`) declares `GET /agents` at §832: returns 200 with a JSON array of agent objects. Three fields always present (`agent_id`, `name`, `description`); five optional with omit-when-null/omit-when-empty semantics (`version`, `capabilities`, `supported_models`, `persona_traits`, `ui_hints`). No pagination, no filters, no auth-scope requirement beyond bearer-authenticated (worldtree-dev confirmed 2026-05-23: Tier 1 `agent.list` baseline scope covers it; same auth posture as POST /sessions)."
- "`AgentInfo` is a frozen dataclass on `ratatoskr.sessions` (alongside `SessionInfo` / `SessionPage`) carrying all 8 fields. Origin-conditional defaults mirror INV-001/INV-002 from issue #2: required fields (`agent_id`, `name`, `description`) take the response value verbatim; optional fields default to `None` (scalar `version`) or empty container (`capabilities=[]`, `supported_models=[]`, `persona_traits={}`, `ui_hints={}`) when omitted from the response. Empty containers (NOT `None`) for collection-shaped optionals so caller code can branch on truthiness without `is None` ceremony."
- "`list_agents` uses the same caller-owned-client posture as `create_session` / `list_sessions`: takes `httpx.AsyncClient`, returns `list[AgentInfo]`, raises `SessionApiFailed(status, body)` on any non-200 response. No new exception type — list_agents' failure modes (auth, transport, server-side 5xx) all map cleanly to the existing `SessionApiFailed` shape. The module's posture against shared types with sse_client (`ratatoskr.sessions` issue #1 convention dependency) is preserved."
- "**CLI surface change is mode-conditional, not flag-removal**: `--agent` becomes optional ONLY when `--new` is passed AND `--send` is NOT passed (i.e., bare TUI-mode create). `--send --new` still raises `UsageError('--agent is required when --new is passed in --send mode')` because non-interactive --send mode has no way to prompt. `--session <id>` still forbids `--agent` (preserves issue #3 INV-004 mutual-exclusion). The single existing line `if ns.new and not ns.agent: raise UsageError(...)` in `_parse_args` STEP 3 splits into two conditionals that distinguish on `ns.send`."
- "**Picker is its own App, not a Screen within RatatoskrApp** (deliberate divergence from the issue body's 'pushed onto the App's screen stack' phrasing, which predated issue #6's refactor). Reason: issue #6's load-bearing invariant is that session-resolution + startup errors land on the operator's REAL stderr before any alt-screen opens. `list_agents` failures (network, auth, server error) need that same property. Doing it via a Screen inside RatatoskrApp re-introduces the alt-screen-eats-stderr problem #6 fixed. Doing it via a separate `AgentPickerApp` opened in `_resolve_then_run` (before `RatatoskrApp`) preserves #6's invariant: `list_agents` errors print to stderr and short-circuit BEFORE the picker's alt-screen opens; picker errors (which don't really exist — it's pure UI navigation) are bounded; chosen `agent_id` flows back through `app.run_async()`'s return value."
- "**Two alt-screen cycles is acceptable** (picker opens + closes; main RatatoskrApp opens). Textual's `App.run_async()` handles alt-screen entry + restoration cleanly per-instance. The visible-flicker cost is one quick alt-screen flash between picker dismissal and main App mount; the architectural cost of avoiding this (Screen-within-App, breaking #6) is higher than the cosmetic cost. If empirical operator feedback indicates the flicker is jarring, follow-up issue collapses to one App with two Screens AFTER re-engineering the stderr-error path."
- "**Picker Esc/Ctrl-D returns exit 0, NOT a `UsageError`**: when the operator dismisses the picker without choosing, the intent is 'never mind, exit cleanly' — same as Ctrl-D from the main chat pane in INV-002 of issue #4. `_resolve_then_run` returns 0 without calling `create_session` or `App.run_async()` on RatatoskrApp. No session is created server-side; no `agent_id` is required to satisfy this exit path."
- "**Picker layout uses ListView, not DataTable**: design-brief §5 mentions `DataTable` for the session picker but ListView is the right primitive for agent picking — single-column, keyboard-navigable, one row per agent rendered as `agent_id · name — description`. v1 picker is a flat list per the issue's out-of-scope clause (search/filter/sort/ui_hints rendering all deferred). DataTable's column-header + sortable-column ergonomics are wasted on this surface."
- "**Empty agent list is a clean exit, not an error**: if `GET /agents` returns `[]`, the picker writes `[no_agents] server returned empty agent list\\n` to stderr and `_resolve_then_run` returns exit code 13 (new — see ERROR_ROUTING below). The picker UI never opens in this case; no point showing an empty list with no actionable rows."
- "**Single agent does NOT auto-select**: if `GET /agents` returns one agent, the picker still opens with one row. Auto-select would hide the choice (and the agent's description) from the operator. The cost is one keystroke; the benefit is transparency about what's about to happen."
- "**`AgentInfo` field order in the dataclass matches the spec's column order** (`agent_id`, `name`, `description`, `version`, `capabilities`, `supported_models`, `persona_traits`, `ui_hints`). Mirrors how readers scanning the dataclass map mental model from spec → code."
- "**`AgentInfo.persona_traits` / `ui_hints` are typed as `dict[str, Any]` not nested dataclasses**: v1 picker just displays `agent_id · name — description`; the inner shape (ocean object, icon, color_hint, vibe) is opaque to ratatoskr. Future polish that renders icon/color_hint would either parse on-demand or introduce nested dataclasses then. Keeping them as dict[str, Any] avoids paying a typing tax now for a display surface that's deferred."
- "**`list_agents` does NOT pass query params**: spec §832 declares no pagination, no filters. The request is a bare `GET /agents` with the bearer header from the caller-owned client. If Worldtree later adds filters (e.g., `?capability=foo`), `list_agents` gains them via amendment then."
open_questions:
- "Should the picker display `version` when available (e.g., `mimir v0.2.0 — Keeper of the Well of Knowledge`)? Draft: no for v1 — the issue body specifies `agent_id · name — description` exactly. Add in a follow-up if operators report ambiguity (two `mimir` rows from different deployments). Drift-check evidence first."
- "Should `AgentPickerApp.run_async()` return the chosen `AgentInfo` or just the `agent_id` string? Draft: just the `agent_id` string for v1 — that's all `create_session` needs. Returning the full `AgentInfo` would let `RatatoskrApp` show name/description in the identity widget without re-fetching, but the existing identity widget format is `<agent_id> · …<session_id>` so the extra metadata has no consumer yet. Defer until §5 side-panes work needs it."
- "Should the picker show a loading spinner while `list_agents` is in flight? Draft: no for v1 — list_agents runs BEFORE the picker App opens (per the architectural decision above), so there's no in-app loading state to show. Operator sees stderr label on failure; on success the picker opens with the list already populated. If the request latency turns out to be noticeable (e.g., >300ms), reconsider."
prd:
issue: 8
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/8"
body_sha256_16: "c34f4878936a4edc"
lock_in_comment_id: null
lock_in_sha256_16: null
lock_in_at: null
pinned_at: "2026-05-24T00:49:03+00:00"
dependencies:
- issue: 2
path: "src/ratatoskr/sessions.py"
reason: "In-place contract amendment: new `list_agents()` function + new `AgentInfo` frozen dataclass added to the module's public surface. POST /sessions paths unchanged; no shared types with the new code beyond the module's existing `SessionApiFailed` exception (reused for non-200 responses)."
- issue: 3
path: "src/ratatoskr/cli.py"
reason: "In-place contract amendment: `_parse_args` STEP 3 splits the single `if ns.new and not ns.agent` check into two conditionals — `--send --new` keeps the strict requirement; bare `--new` accepts `agent_id=None` for the TUI-picker case."
- issue: 4
path: "src/ratatoskr/tui.py"
reason: "In-place contract amendment: new `AgentPickerApp(App[str | None])` class with ListView + Enter/Esc bindings; `_resolve_then_run` gains a pre-create branch that runs the picker when `args.new and args.agent_id is None`; on chosen `agent_id`, threads it into `create_session(client, chosen, end_user_id=args.end_user_id)`. RatatoskrApp itself is unchanged."
---
# Startup agent picker — GET /agents when --new without --agent (TUI)
## Context
Today, `ratatoskr --new` requires `--agent <id>`. If omitted, `_parse_args`
raises `UsageError("--agent is required when --new is passed")`. That's
correct for `--send --new` (non-interactive — can't prompt) but wrong
for the TUI (operator may not know which agents are available, would
prefer to pick from a list at startup).
Worldtree's spec §832 exposes `GET /agents`. The endpoint returns a flat
JSON array; required fields are `agent_id`, `name`, `description`;
optional fields (`version`, `capabilities`, `supported_models`,
`persona_traits`, `ui_hints`) follow omit-when-null/empty rules. No
pagination, no filters, no special scope. Worldtree-dev confirmed
2026-05-23: Tier 1 baseline auth covers it.
This issue threads a small surface change through three existing modules
in-place — no new files apart from this contract.
## Data flow
**Input:**
- `httpx.AsyncClient` (caller-owned, base_url + bearer auth on the client).
- No request body, no query params.
**Output (`list_agents`):**
- `list[AgentInfo]` — one entry per available agent, in server-declared order.
**Output (`AgentPickerApp.run_async()`):**
- `str | None` — chosen `agent_id`, or `None` on Esc/Ctrl-D dismissal.
## Public surface (ratatoskr.sessions amendment)
```python
@dataclass(frozen=True)
class AgentInfo:
"""One agent's metadata from GET /agents.
INV-005: Required fields (`agent_id`, `name`, `description`) take the
response value verbatim. Optional fields default to None (`version`)
or an empty container (`capabilities`, `supported_models`,
`persona_traits`, `ui_hints`) when omitted from the server response,
mirroring SessionInfo's INV-001/INV-002 origin-conditional pattern.
"""
agent_id: str
name: str
description: str
version: str | None
capabilities: list[str]
supported_models: list[str]
persona_traits: dict[str, Any]
ui_hints: dict[str, Any]
async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
"""GET /agents → list of available agents. See contract FN list_agents."""
```
## Functions
### FN list_agents
```
FN list_agents(client: httpx.AsyncClient) -> list[AgentInfo]
BRIEF: GET /agents → list of available agents. No pagination, no filters.
PRE-001: client is not None.
STEPS:
1. resp = await client.get("/agents")
2. IF resp.status_code != 200:
raise SessionApiFailed(status=resp.status_code, body=resp.content)
3. body = resp.json() # expected: list[dict]
4. items = [
AgentInfo(
agent_id = item["agent_id"],
name = item["name"],
description = item["description"],
version = item.get("version"),
capabilities = item.get("capabilities") or [],
supported_models = item.get("supported_models") or [],
persona_traits = item.get("persona_traits") or {},
ui_hints = item.get("ui_hints") or {},
)
for item in body
]
5. RETURN items
POST-001: every item in the return list has required fields populated.
POST-002: optional fields default to None / [] / {} when absent from the response.
ERROR_ROUTING:
- 200 with non-list body → KeyError / TypeError propagates (server bug; not handled here).
- non-200 → SessionApiFailed(status=N, body=resp.content)
- httpx.RequestError → propagates (network failure; caller handles).
TESTS:
- test_happy_full_shape: 200 + spec's full-shape mimir example → AgentInfo with all fields populated.
- test_happy_minimum_shape: 200 + spec's minimum-shape "minimal" example → AgentInfo with required + defaulted optional.
- test_happy_multi_agent: 200 + array of 3 agents → list of 3 AgentInfo in order.
- test_happy_empty: 200 + [] → empty list (no error).
- test_omit_capabilities: 200 + agent missing capabilities → AgentInfo.capabilities == [].
- test_omit_persona_traits: 200 + agent missing persona_traits → AgentInfo.persona_traits == {}.
- test_omit_ui_hints: 200 + agent missing ui_hints → AgentInfo.ui_hints == {}.
- test_500_raises: 500 → SessionApiFailed with status=500.
- test_401_raises: 401 → SessionApiFailed with status=401.
```
## CLI surface change (ratatoskr.cli amendment)
`_parse_args` STEP 3, currently:
```python
if ns.new and not ns.agent:
raise UsageError("--agent is required when --new is passed")
```
becomes:
```python
if ns.new and not ns.agent:
if ns.send is not None:
# --send --new mode: non-interactive, cannot prompt for choice.
raise UsageError("--agent is required with --new in --send mode")
# else: bare --new (TUI mode) — agent_id stays None, TUI runs picker.
```
INV-004 (`--session` + `--agent` mutual exclusion) is unchanged. Existing
`--send --new` smoke flows that pass `--agent` continue to work unchanged.
ERROR_ROUTING (cli):
- `--send --new` without `--agent``UsageError` → exit 10 (unchanged from today).
- bare `--new` without `--agent``ParsedArgs.agent_id=None`, TUI handles.
TESTS (additions to test_cli.py):
- test_parse_send_new_without_agent_raises: `--send "hi" --new` (no --agent) → UsageError.
- test_parse_bare_new_without_agent_accepted: `--new` (no --agent, no --send) → ParsedArgs with agent_id=None.
- test_parse_bare_new_with_agent_accepted: `--new --agent mimir` → unchanged behavior, agent_id="mimir".
## TUI surface change (ratatoskr.tui amendment)
### New: AgentPickerApp
```python
class AgentPickerApp(App[str | None]):
"""Single-purpose picker App. Opens before RatatoskrApp.
`run_async()` returns the chosen agent_id (str) or None on Esc/Ctrl-D.
"""
BINDINGS: ClassVar[list[Binding]] = [
Binding("enter", "pick", "Pick", priority=True),
Binding("escape", "dismiss", "Cancel", priority=True),
Binding("ctrl+d", "dismiss", "Cancel", priority=True),
Binding("ctrl+c", "dismiss", "Cancel", priority=True),
]
def __init__(self, agents: list[AgentInfo]) -> None:
super().__init__()
assert agents # PRE-002 — caller guarantees non-empty
self.agents = agents
def compose(self) -> ComposeResult:
yield Header()
yield Static("Pick an agent for the new session:", id="picker-prompt")
yield ListView(
*[
ListItem(Label(f"{a.agent_id} · {a.name}{a.description}"))
for a in self.agents
],
id="agent-list",
)
yield Footer()
async def on_mount(self) -> None:
self.query_one("#agent-list", ListView).focus()
def action_pick(self) -> None:
lv = self.query_one("#agent-list", ListView)
idx = lv.index
if idx is None:
return # no row highlighted; ignore
self.exit(self.agents[idx].agent_id)
def action_dismiss(self) -> None:
self.exit(None)
```
### Modified: _resolve_then_run
Insert a pre-create branch between the `async with httpx.AsyncClient(...)`
and the existing `if args.new:` block:
```python
async def _resolve_then_run(args: ParsedArgs) -> int:
assert isinstance(args, ParsedArgs) and args.send_content is None
async with httpx.AsyncClient(...) as client:
# NEW (issue #8): startup agent picker when --new without --agent.
chosen_agent_id: str | None = args.agent_id
if args.new and args.agent_id is None:
try:
agents = await list_agents(client)
except SessionApiFailed as exc:
sys.stderr.write(
f"[session_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 not agents:
sys.stderr.write("[no_agents] server returned empty agent list\n")
return 13
picker = AgentPickerApp(agents)
chosen_agent_id = await picker.run_async()
if chosen_agent_id is None:
return 0 # Esc/Ctrl-D — clean exit, no session created
# EXISTING: create_session OR re-use --session id
if args.new:
assert chosen_agent_id is not None
try:
info = await create_session(
client, chosen_agent_id, end_user_id=args.end_user_id
)
...
else:
...
app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client)
...
```
### ERROR_ROUTING (tui startup)
| Failure | Stderr label | Exit |
|---------|--------------|------|
| `list_agents``SessionApiFailed` | `[session_api_failed] status=N body=B` | 20 |
| `list_agents` → network error | `[network_error] T: M` | 21 |
| empty agent list (`GET /agents` returns `[]`) | `[no_agents] server returned empty agent list` | 13 (new) |
| picker Esc/Ctrl-D dismissal | (none — clean exit) | 0 |
| `create_session` post-pick → `AgentNotFound` | `[agent_not_found] agent_id=X` | 12 (unchanged from #4) |
INV: exit code 13 is new (no-agents). Previously unused — does not collide
with any existing exit code in `_resolve_then_run` or `_amain`.
### TESTS (additions to test_tui.py)
```
- test_picker_happy_path: list_agents returns 2 agents; AgentPickerApp opens; user picks index 0; chosen agent_id flows into create_session; main App opens.
- test_picker_esc_clean_exit: list_agents returns 2 agents; picker opens; user presses Esc; _resolve_then_run returns 0; create_session NOT called; RatatoskrApp NOT opened.
- test_picker_skipped_when_agent_id_provided: bare --new --agent mimir → list_agents NOT called; picker NOT opened; create_session called with "mimir".
- test_picker_skipped_when_session_mode: --session s-1 → list_agents NOT called; picker NOT opened; no create_session.
- test_picker_list_agents_session_api_failed: list_agents raises SessionApiFailed → stderr [session_api_failed]; exit 20; picker NOT opened; create_session NOT called.
- test_picker_list_agents_network_error: list_agents raises ConnectError → stderr [network_error]; exit 21.
- test_picker_empty_list: list_agents returns [] → stderr [no_agents]; exit 13; picker NOT opened; create_session NOT called.
- test_agent_picker_app_renders_rows: AgentPickerApp with 3 agents → ListView has 3 ListItem children with expected text.
- test_agent_picker_app_pick_returns_agent_id: simulate Enter on highlighted row → exit value == agents[idx].agent_id.
- test_agent_picker_app_dismiss_returns_none: simulate Esc → exit value is None.
```
## Invariants
- **INV-005**: `AgentInfo` field defaults are origin-conditional (mirrors INV-001/002 from #2). Required → verbatim; optional → None / [] / {}.
- **INV-006**: `list_agents` failure modes route through `SessionApiFailed` only — no new exception type introduced.
- **INV-007**: Picker is a separate App, opened by `_resolve_then_run` BEFORE `RatatoskrApp`. Preserves issue #6's stderr-error invariant for `list_agents` failures.
- **INV-008**: `--agent` CLI requirement is mode-conditional: required only when `--send --new`; bare `--new` accepts None; `--session` always forbids it.
- **INV-009**: Picker dismissal (Esc/Ctrl-D) returns clean exit 0; no session created server-side.
- **INV-010**: Empty agent list is a clean stderr exit (code 13), not an open picker.
- **INV-011**: Single-agent response still opens the picker — no auto-select.
- **INV-012**: Two alt-screen cycles (picker + main App) is the explicit architectural tradeoff for preserving INV-007.
+86 -46
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr
_Last updated: 2026-05-23_
_Last updated: 2026-05-24_
This file captures durable intent and supporting evidence (goals, decisions,
foot-gun warnings, in-flight state) across context resets. Read it at session
@@ -32,61 +32,95 @@ separate dev team rather than an in-tree Worldtree tool.
## Current state / in-flight
_As of 2026-05-23 (end of day):_
_As of 2026-05-24 (post-v0.4.0 §5 entry point: layout reshape +
Tools pane):_
**Status: issues #5 + #6 + worldtree-dev consumer-API follow-up all
landed.** Six core issues complete (`sse_client` #1, `sessions` #2,
`cli` #3, `tui` #4, `--end-user-id` #5, TUI startup error visibility
#6) + robustness fix #7 (MalformedSseData + empty-skip).
188/188 tests GREEN; ruff clean.
**Status: v0.4.0 shipped.** Nine core issues 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)
+ robustness fix #7 (MalformedSseData + empty-skip) + v0.2.1 TUI
layout fix. 236/236 tests GREEN; ruff clean.
`--send` validated end-to-end against personal Worldtree
(`http://10.250.50.152:8081`, mimir on qwen3.6-35-a3b). Lofn smoke
parked on infra-ops's `agents.call:lofn` scope add (althing thread
`01KSBBHDWVZZ…`; infra-ops brokering to worldtree-dev because personal
Worldtree exposes no public scope-mutation endpoint).
**§5 v1 entry point shipped (issue #13).** TUI now Horizontal
two-column: left = chat surface (transcript + thinking-current +
prompt); right = TabbedContent with single Tools tab (RichLog
receiving ToolStart/ToolResult events). Routing-not-duplication:
tool events leave the main transcript entirely. Ctrl+1 activates
Tools tab without losing Input focus (INV-016). New `pane-name`
Static in the footer (static "Tools" v1; dynamic when more tabs
land). CLI mode (--send) unaffected by design — INV-018.
**In-flight:**
- **Lofn smoke** — blocked on the scope-add. Once infra-ops confirms
`agents.call:lofn` is live, run `ratatoskr --new --agent lofn
--end-user-id ratatoskr-tui --send "hello"` for end-to-end
verification.
- **Issue #8 (startup agent picker)** — filed but unscaffolded.
Worldtree-dev confirmed `GET /agents` requires no special scope
(any authenticated key works); issue is unblocked on auth side.
Depends on #5 composably (both thread through `ParsedArgs`
`_resolve_then_run`).
Last commits on `main`:
- v0.4.0 feat(tui): issue #13 — §5 layout reshape + Tools pane
- `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)
- `8282156` snapshot: persistent-memory Heimdall scope-model foot-gun
- `804c2df` feat(sessions,cli,tui): issues #5 + #6 + worldtree-dev follow-up (v0.1.0)
**Smoke status:**
- `--send --new --agent mimir` v0.3.0 smoke clean
(`[done] turn_id=141 model=qwen3.6-35-a3b duration=2.2s`).
- Live `list_agents` smoke against personal Worldtree returned 12
agents (actor, bragi, cara, domari, forseti, glados, leif, lofn,
mimir, soong, troi, saga).
- Picker end-to-end smoke against live Worldtree: bare `--new`
list_agents → picker (auto-picked lofn programmatically since
driving alt-screen interactively from CLI smoke isn't possible)
→ POST /sessions with end_user_id="ratatoskr-tui" succeeded;
RatatoskrApp constructed with agent_id="lofn".
- TUI v0.2.0 was visually broken (Input pane bouncing with thinking
runs); v0.2.1 fixed via dock-based layout. Operator confirmed
"a lot better" interactively.
**Outstanding operator-side todos:**
- **Interactive §5 layout eyeball** — `source env.sh && uv run
ratatoskr --new --agent mimir`, ask a tool-using question
("search your KB for X"). Confirm: left column shows chat /
thinking; right column's Tools tab shows tool_start +
tool_result with `· ` prefix; Ctrl+1 doesn't break input focus;
no width-clamp issues on the operator's terminal. Programmatic
smoke confirmed all the routing + binding; visual confirmation
pending.
- **Post-v0.2.1 TUI multi-turn eyeball** — confirm thinking-run
bouncing is gone across multiple turns; the layout fix has only
been confirmed for a single turn so far.
**Pending issues filed but not started:**
- **Issue #9 (spec-pin refresh v0.19.0 → v0.22.1)** — filed
2026-05-23. Documentation debt; pin lies about the surface we're
committed to. Worldtree v0.20.0 made `end_user_id` the partition
key; v0.21.0 added `memory_context` field; v0.22.0 strengthened
the `[MEMORY:DATA]` envelope. None break our existing surface.
- **Issue #10 (subject:{type,id} migration)** — filed 2026-05-23 to
track Worldtree #196's LOCKED-but-not-shipped breaking change.
Worldtree-dev was explicit: don't pre-implement; deprecation
warnings will fire per call as the heads-up when substrate ships.
2026-05-23. Documentation debt; defer unless we need a v0.20.0+
capability.
- **Issue #10 (subject:{type,id} migration)** — filed 2026-05-23
to track Worldtree #196. Don't pre-implement per worldtree-dev.
- **Issue #11 (AdminEvents pane auth prerequisite)** — filed
2026-05-23. Future side-pane requires `admin.events.read` scope;
documenting the gate so we don't forget when scheduling that pane.
2026-05-23. Future side-pane needs `admin.events.read` scope.
Branch: `main` (clean after this commit). Remote:
**Pending Worldtree-dev follow-up:**
- worldtree-dev committed (althing `01KSBKTG096Q…`) to file a
Worldtree-side issue for the stall-watchdog gap (cancel-check is
inside the engine-event loop, so a never-yielding first-LLM-call
bypasses the 300s watchdog). Will file after the immediate stall
is cleared.
- Ratatoskr-side companion (potential): a client-side stall watchdog
(e.g., 90s-no-events → `[server_stalled]` stderr label, keep
connection). Defer until recurrence; defense-in-depth regardless of
whether Worldtree fixes its own.
Branch: `main` (clean). Remote:
`origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
**Next natural moves:**
1. **Mimir regression smoke (operator-side)** — `source env.sh && uv
run ratatoskr --new --agent mimir --send "test"` (and the
`--send`-less TUI form) to verify backwards compat holds after
issues #5 + #6 land. env.sh now ships
`RATATOSKR_END_USER_ID="ratatoskr-tui"`.
2. **Lofn smoke** — when infra-ops confirms scope-add.
3. **Issue #8 (startup agent picker)** — scaffold + contract, then
TDD. Unblocked by both #5 (end_user_id wired through
`_resolve_then_run`) and worldtree-dev's auth confirmation for
`GET /agents`.
4. **Side-pane issues** — Persona pane first (file-tail, cheap).
5. **Issue #9 (spec-pin refresh)** — defer until we actually need a
v0.20.0+ capability, OR refresh now if doc-debt is bothering us.
1. **Interactive picker eyeball** — operator confirms the TUI
picker UX (rendering, Enter pick, Esc dismiss) against personal
Worldtree.
2. **§5 side-panes work** — Persona pane first per design-brief; the
collapsible Thinking pane + Debug pane proposals fold IN as
additional `TabbedContent` tabs alongside Persona/Tools/AdminEvents.
Reshapes layout from vertical-stack to Horizontal two-column.
3. **Issue #9 (spec-pin refresh)** — defer unless we need a v0.20.0+
capability (e.g., `memory_context` for Phase 2.1).
## Recent decisions
@@ -117,6 +151,10 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-05-23]` **Issue #5 (`--end-user-id`) implemented via TDD.** Small surface change across three modules (sessions, cli, tui): `create_session(client, agent_id, *, end_user_id=None)` widens with optional kwarg; body conditionally adds the field when non-None (INV-002: omitting != sending empty); PRE-003 asserts non-empty. `ParsedArgs.end_user_id: str | None = None` field; `--end-user-id` CLI flag with non-empty validation (mirrors `--send` check). `_amain` and `_resolve_then_run` thread `end_user_id=args.end_user_id` to their `create_session` calls. Post-#6 adjustment: the contract originally named `on_mount` as the TUI threading site, but #6 had moved session resolution to `_resolve_then_run` — same shape, different function. 7 new tests across the 3 modules.
- `[2026-05-23]` **Worldtree-dev consult landed authoritative consumer-API guidance** (althing thread `01KSBARG2B8M8C82H6AJGJWX1B`). Key takeaways shaped follow-on work: (1) `end_user_id` is a free-form partition key for long-term memory + persona/valence state; same value → same partition, different values → fully isolated. For Vuong-debugging-Worldtree the recommended posture is a project-stable default with `--end-user-id` override. (2) No programmatic `requires_end_user_id` discovery on `GET /agents` — "try and react to 422" remains the pattern. (3) Breaking-change #196 LOCKED but not shipped: `subject:{type,id}` replaces `end_user_id` at future v0.22.x or v0.23.0; don't pre-implement. (4) Spec pin (v0.19.0) is 3 minor versions stale (current v0.22.1); none of v0.20.0/v0.21.0/v0.22.0 break ratatoskr's surface but the pin lies about what we're committed to. (5) User-Agent header: send one (`ratatoskr/<version> (vh@phasefinal.com)`). (6) `agents.call:lofn` scope needed for lofn smoke. (7) `GET /agents` requires no special scope; issue #8 unblocked on auth.
- `[2026-05-23]` **Follow-up acted on:** User-Agent header added to both `_amain` and `_resolve_then_run` httpx.AsyncClient constructions (with `importlib.metadata` version lookup + fallback to `0.0.0`); `RATATOSKR_END_USER_ID` env-var fallback added to `_parse_args` (resolution: flag > env > None); env.sh ships `RATATOSKR_END_USER_ID="ratatoskr-tui"` as project-stable default. Original issue #5 posture rejected env-var fallback as "papering over isolation"; revised after worldtree-dev's guidance that the realistic single-operator use case wants partition continuity. Issue #5 + #3 contracts amended in-place to document the env-var fallback. Infra-ops pinged via althing for `agents.call:lofn` scope (broker pattern; they forwarded to worldtree-dev). Three Gitea issues filed: #9 (spec-pin refresh), #10 (subject:{type,id} migration tracking), #11 (AdminEvents pane auth prereq).
- `[2026-05-23]` **v0.2.1 layout fix: dock-anchored TUI chrome so Input never moves** (commit `c85f6bd`, tag `v0.2.1`). Reported during the v0.2.0 mimir TUI smoke: Input bouncing up/down throughout a turn, tokens landing at shifting screen positions. Cause: v0.2.0's `Static(id="thinking-current")` was yielded between `hint` and `Footer` in the auto-stacked vertical flow, so each `display=True/False` toggle per thinking-run shifted Input + identity + hint vertically; RichLog growth from streaming text also drifted Input downward. Fix: `RatatoskrApp.DEFAULT_CSS` docks the chrome to screen edges — `thinking-current` docks top under Header; `transcript` (RichLog) gets `height: 1fr` and absorbs all reflows internally via its scroll viewport; `prompt`, `identity`, `hint` all dock bottom (locked above Footer). Compose order moved `thinking-current` to position 2 (right after Header) so source-order matches the dock layout. **Operator-confirmed "a lot better"** interactively. Pure UI fix; no public API change; tests pass without modification. v0.2.0 → v0.2.1 (patch). I couldn't verify in a TTY from this non-interactive session — the design was sound enough to ship blind, with operator verification post-commit. Going forward: TUI-layout patches like this are "ship + operator verifies" since the TTY is the load-bearing test surface and respx + Pilot mocks can't catch screen-relative positioning bugs.
- `[2026-05-23]` **Sequencing decision: design-brief §5 side-panes work absorbs the inline collapsible-Thinking-pane + Debug-pane proposals; do issue #8 (startup agent picker) BEFORE §5.** Surfaced during the v0.2.1 follow-up discussion. The operator's proposal — "create a collapsible pane for all thinking tokens; text_boundary goes to a debug pane" — is exactly §5-shaped work (the design-brief proposes a `Horizontal` two-column layout with `TabbedContent` for Persona/Tools/AdminEvents/BifrostState/ServerLog). Building inline-Collapsibles now and then rebuilding as `TabbedContent` panes at §5 would be wasted work. So: do #8 first (independent surface, no layout overlap), then §5 (which folds in Thinking + Debug panes alongside the design-brief's named §5 panes). Interim acceptance: v0.2.1 fixes the structural layout-bouncing pain; transcript-dominated-by-thinking is still real but doesn't degrade further — operator can scroll back, Input doesn't move, tokens land predictably. The interim "noisy transcript" pain is real but bounded; §5 work resolves it cleanly.
- `[2026-05-23]` **Issue #12 (presenter contract semantics amendment) implemented via TDD.** Headline: thinking deltas render as ONE coalesced growing line (CLI) / one closed RichLog entry per run + live Static(id="thinking-current") widget per-delta (TUI), not 50 lines per turn. Introduced stateful per-turn presenters: `CliPresenterState` (cli.py) and `TuiPresenterState` (tui.py), both `@dataclass(slots=True)` with thinking_buffer + thinking_open (+ text_written_since_newline for CLI). Editorial promotion line settled: load-bearing = Text/Done/Error/Cancelled (no prefix); demoted telemetry = WorkerPhase/Thinking/TextBoundary/ToolStart/ToolResult (CLI `. ` ASCII prefix; TUI `· ` Unicode dim prefix). CLI stdout/stderr newline-boundary INV-005: when text was streamed mid-line, flush a `\n` to stdout before writing terminal labels to stderr; `text_written_since_newline = not event.content.endswith("\n")` per Volva F4 fix. Helpers `_format_duration_ms` (`347ms` / `5.5s` / `1.2m` autoscale) and `_format_usage` (`6756 in -> 126 out (6882 total, 0 cached)` with arrow="->" CLI or "→" TUI). Per Vor (eitri-smithy-dev cross-frontier consult, althing 01KSBE52YZR5) + Volva paraphrase (5 contract-text ambiguities all fixed in #12.contract.md). `[create_session]` lifecycle line demoted to `. create_session:` (written directly by `_amain`, bypasses state.render). Old `_render_event` / `_render_event_to_log` functions and their TestRenderEvent/TestRenderEventToLog classes removed (no-backwards-compat rule). Contracts amended: #3 (CliPresenterState block + `_run_turn` thread state + `_amain` create_session demotion + `_format_*` helper blocks), #4 (TuiPresenterState block + `_stream_turn_worker` state construction + `compose` Static widget addition). 39 new tests; 19 obsolete tests removed; net 208 GREEN. v0.1.0 → v0.2.0 (minor; pre-amendment output shape broken intentionally — scripts grepping `[thinking] '` no longer work; that's the intended cleanup). Cross-frontier design pass with eitri-smithy-dev returned 16-of-16 confirmed decisions + 4 material divergences applied (ASCII `· ` factual fix, RichLog-one-entry-per-run vs inline-mirror, presenter-state object vs stateless, "contract semantics amendment" framing not "polish"). Calibration note: eitri-smithy-dev's value here was *architectural* (state-object pattern + chronological-vs-live decoupling) not just *tactical*; the framing rename alone justified the consult. Volva paraphrase round added 5 prose-precision fixes (INV-001 "growing display" semantics, TUI hide mechanism unification, render_error security/readability tension, newline-tracking corner case, [create_session] integration path).
- `[2026-05-23]` **Forward direction: Ratatoskr will require `end_user_id` for EVERY access before too long.** Operator's call. Reasoning: even Tier 1 foundational agents (mimir, all Asgardians) that don't *require* `end_user_id` server-side currently fall back to a `_no_end_user` sentinel substrate partition — effectively pollution from a single-operator-debug-tool's perspective. The right shape is "every conversation has an explicit partition key." `RATATOSKR_END_USER_ID="ratatoskr-tui"` env-default in env.sh is the first step toward that posture; once we've validated the partition-isolation experience, the next move is making `end_user_id` mandatory (probably remove the `None`-default in `_parse_args`, fail-closed with a UsageError if neither flag nor env provides it). Consequence for cross-project asks: declined worldtree-dev's offer to ship `requires_end_user_id: bool` on `AgentInfoResponse` because we'd treat every value as true regardless; the try-and-react-to-422 pattern goes away from our side because we never send a request without the field. File a ratatoskr issue when scheduling the change — touches `_parse_args` validation + `_resolve_then_run` + `_amain` + tests + contract amendments to #3 / #5. Treat as a v0.2.0 minor (breaking: existing `--new --agent mimir` without env or flag would start failing). **Cross-frontier alignment (worldtree-dev ack 2026-05-23, althing 01KSBD9FPMCWJMBXNNS4B3MYBS):** the platform side agrees with this framing — `_no_end_user` is a substrate accommodation for identity-less transports, NOT a consumer model. The fallback's `_is_fallback=True` trap door (#185 INV-185-5/8) "could become operator-controlled later" per worldtree-dev, meaning Worldtree itself may tighten the substrate-fallback path. Ratatoskr's forward posture pre-empts that tightening — moving from "we send end_user_id when set" to "we never send a request without end_user_id" stays consumer-correct regardless of what Worldtree does with the fallback knob.
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log (commits `9703eb2..61c3941` carry the full per-issue trail with structured commit messages)._
@@ -134,3 +172,5 @@ defense against re-attempting the same cul-de-sac.
- `[2026-05-21]` **TUI session-identity rendering via `self.sub_title` + `self.hint` plain attributes.** Stored state but never rendered to a visible widget. The contract's "session-identity-always-visible" invariant was satisfied at the state-attribute level but not the user-visible-widget level. Tests asserted the attributes (which passed); Volva code-review flagged the gap. Fix: dedicated `Static(id="identity")` + `Static(id="hint")` widgets in compose; `_set_hint()` helper mirrors state → widget. Calibration evidence for the "TDD catches state, code-review catches whether the user can see it" pattern.
- `[2026-05-23]` **Using the cross-model review agent's name directly in composed prose.** The peer review agent's name (the `althing` handle starting with "V-o-l-v-a") is one letter from a body-part term. Anthropic's content classifier does fuzzy matching and intermittently blocks responses mid-stream when the name appears in composed prose sentences (especially in meta-commentary about the agent's work). Direct-quoted tool output (e.g., the `althing-cli thread` body) passes through fine. Mitigation: use role descriptions ("the cross-model reviewer," "the paraphrase peer") in prose rather than the name; quote content via tool output. Confirmed by switching to Sonnet 4.6 for a test read — same raw content read cleanly when fetched via Bash rather than composed into an LLM response. This is a persistent environmental constraint, not a one-off.
- `[2026-05-22]` **`json.loads(sse.data)` unguarded against empty data.** `_iter_events` unconditionally called `json.loads` on every dispatched `ServerSentEvent`. When `httpx_sse` surfaced a frame with `id:` present but `data:` empty (a known library-vs-spec divergence — RFC says don't dispatch; httpx_sse is permissive), `json.loads('')` raised `JSONDecodeError` → propagated through Textual's worker → app crash. Crashed mimir conversation at turn 93/seq 1078 after 1077 successful events. Fix: `if sse.data == '': continue` BEFORE `_parse_sse_id` (empty-data event with a malformed id is still a keepalive — don't reorder). Non-empty malformed data raises new `MalformedSseData(raw[:200])`. Don't reintroduce unconditional `json.loads(sse.data)`; always pre-check for the empty case.
- `[2026-05-23]` **Diagnostic shorthand: "2-events-then-silence" = Worldtree-side LLM-call wedge, not ratatoskr.** If a mimir `--send` smoke shows exactly two stderr events — `. create_session: ...` followed by `. worker_phase: phase=BuildingPrompt ...` — and then nothing for >60s, the root cause is upstream of ratatoskr. Worldtree's `service.py:2560` gates the `CallingLLM` event on the engine yielding its first LLM-provider chunk; if that provider connection is wedged at the TCP level, the `async for` never iterates and the SSE stream stays silent forever. ratatoskr's `read=None` httpx timeout (the issue #1 + #4 INV-007 fix for "5s default killed mid-stream during mimir's thinking") waits patiently as designed; there's no client-side stall watchdog above the read-timeout layer. Worldtree's OWN stall watchdog (300s `_start_stall_timer`) exists but its cancel-check is INSIDE the engine-event loop, so a never-yielding first-LLM-call bypasses it. Confirmed by worldtree-dev (althing thread `01KSBKTG096Q07JVRG41JXA1DD`). **Don't waste time bisecting ratatoskr code when this shape appears** — diagnose the LLM-provider connection state at Worldtree's host. Restarting the Worldtree service (`:8081` in our case) cleared a wedged llama-swap connection. Future ratatoskr issue worth filing if recurrence: client-side stall watchdog (e.g., 90s-no-events → `[server_stalled]` stderr label, keep connection open). Also worth knowing: 10.250.50.152 hosts 3 Worldtree instances (`:8080`, `:8081`, `:8082`) — each with its own DB and key namespace. Our key is valid only on `:8081`.
- `[2026-05-23]` **Phantom "per-Tier-1-agent scope add" pattern.** Issue #5's lofn 422 was initially diagnosed (with worldtree-dev's first reply) as needing `agents.call:lofn` added to ratatoskr's existing key. Routed through infra-ops via althing per the credential-brokerage rule; infra-ops discovered no public scope-mutation endpoint on personal Worldtree, brokered to worldtree-dev for the actual mechanism. Worldtree-dev came back with a correction: their first answer conflated two distinct Heimdall scope namespaces. **Tier 1 foundational agents** (mimir, lofn, soong, all Asgardians) are covered by a blanket `agent.call:*` (singular) baseline rule in `config/policies.yaml > tiers.<tier>.scopes` for ALL authenticated tiers including `user`. There is no per-agent grant for Tier 1 — the baseline rule covers it. **Tier 3 consumer-defined agents** (IDs containing `:`, like `vh:custom-bot`) use the plural `agents.call:<owner>:<agent>` shape granted implicitly via owning a `consumer_agents` DB row, registered through `POST /agents/define`. The two notations differ by one letter and that was the source of the confusion. **The actual lofn fix was issue #5's `--end-user-id` flag — it was always a request-body validation, not an auth-scope gate.** Don't ping infra-ops for "per-Tier-1-agent scope adds" again; the pattern is a phantom ask. Real future infra-ops asks: admin-tier key for the AdminEvents pane (`admin.events.read` scope, different tier), and Tier 3 custom-agent registration (different flow entirely, requires `POST /agents/define`).
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.1.0"
version = "0.4.0"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
+141 -43
View File
@@ -10,7 +10,7 @@ import asyncio
import os
import signal
import sys
from dataclasses import dataclass
from dataclasses import dataclass, field
from importlib.metadata import PackageNotFoundError, version
from typing import TextIO
@@ -124,8 +124,11 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
raise UsageError("pass exactly one of --session or --new")
if ns.session and ns.agent:
raise UsageError("--agent is required with --new and forbidden with --session")
if ns.new and not ns.agent:
raise UsageError("--agent is required when --new is passed")
if ns.new and not ns.agent and ns.send is not None:
# Issue #8: --agent stays required for --send --new (non-interactive,
# cannot prompt). Bare --new (TUI mode) accepts None — picker drives
# the choice via list_agents in _resolve_then_run.
raise UsageError("--agent is required when --new is passed in --send mode")
api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or ""
if not api_key:
@@ -150,45 +153,128 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
)
def _render_event(event: Event, *, stdout: TextIO, stderr: TextIO) -> None:
"""Pure event-to-output renderer per the contract STEPS table."""
assert isinstance(
event,
(WorkerPhase, Thinking, Text, TextBoundary, ToolStart, ToolResult, Done, Error, Cancelled),
# ---- Issue #12 presenter contract semantics amendment -------------------------
#
# CliPresenterState replaces the stateless _render_event with a stateful per-turn
# presenter that coalesces thinking runs and demotes telemetry events. One
# instance per `_amain` call.
def _format_duration_ms(ms: int) -> str:
"""Auto-scale duration formatting per issue #12 INV-006. Locale-blind."""
assert isinstance(ms, int) and ms >= 0
if ms < 1000:
return f"{ms}ms"
if ms < 60_000:
return f"{ms / 1000:.1f}s"
return f"{ms / 60_000:.1f}m"
def _format_usage(usage: dict[str, int], *, arrow: str) -> str:
"""Natural-language usage formatting per issue #12 INV-007.
`arrow="->"` for CLI (ASCII scriptability); `arrow=""` for TUI.
"""
# PRE-001: usage has the four expected keys (issue #12 contract).
assert all(
k in usage
for k in ("prompt_tokens", "completion_tokens", "total_tokens", "cached_input_tokens")
)
if isinstance(event, Text):
stdout.write(event.content)
stdout.flush()
elif isinstance(event, Done):
stdout.write("\n")
stdout.flush()
stderr.write(
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
f"duration_ms={event.duration_ms} usage={event.usage!r}\n"
)
elif isinstance(event, Error):
stderr.write(
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
f"message={event.message!r}\n"
)
elif isinstance(event, Cancelled):
stderr.write(
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
f"partial_message_id={event.partial_message_id}\n"
)
elif isinstance(event, WorkerPhase):
stderr.write(f"[worker_phase] phase={event.phase} turn_id={event.turn_id}\n")
elif isinstance(event, Thinking):
stderr.write(f"[thinking] {event.content[:200]!r}\n")
elif isinstance(event, TextBoundary):
stderr.write(f"[text_boundary] kind={event.kind} char_offset={event.char_offset}\n")
elif isinstance(event, ToolStart):
stderr.write(f"[tool_start] name={event.name} args={event.arguments!r}\n")
elif isinstance(event, ToolResult):
stderr.write(
f"[tool_result] name={event.name} duration_ms={event.duration_ms} "
f"result={event.result!r:.200}\n"
p = usage["prompt_tokens"]
c = usage["completion_tokens"]
t = usage["total_tokens"]
ci = usage["cached_input_tokens"]
return f"{p} in {arrow} {c} out ({t} total, {ci} cached)"
@dataclass(slots=True)
class CliPresenterState:
"""Per-turn presenter state for `--send` mode (issue #12).
See `docs/contracts/issues/12.contract.md` for the full spec.
"""
thinking_buffer: list[str] = field(default_factory=list)
thinking_open: bool = False
text_written_since_newline: bool = False
def render(self, event: Event, *, stdout: TextIO, stderr: TextIO) -> None:
"""Render one Worldtree SSE event with editorial hierarchy + coalescing."""
assert isinstance(
event,
(
WorkerPhase, Thinking, Text, TextBoundary,
ToolStart, ToolResult, Done, Error, Cancelled,
),
)
# Thinking events accumulate into the open run.
if isinstance(event, Thinking):
if not self.thinking_open:
stderr.write(". thinking: ")
self.thinking_open = True
stderr.write(event.content)
stderr.flush()
self.thinking_buffer.append(event.content)
return
# Non-thinking event: close any open thinking run first.
if self.thinking_open:
stderr.write("\n")
stderr.flush()
self.thinking_open = False
self.thinking_buffer.clear()
# Now render the new event.
if isinstance(event, Text):
stdout.write(event.content)
stdout.flush()
# POST-003: only set if cursor is mid-line (no trailing newline).
self.text_written_since_newline = not event.content.endswith("\n")
return
if isinstance(event, (Done, Error, Cancelled)):
# INV-005: ensure stdout newline boundary before stderr terminal label.
if self.text_written_since_newline:
stdout.write("\n")
stdout.flush()
self.text_written_since_newline = False
if isinstance(event, Done):
stderr.write(
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='->')}\n"
)
return
if isinstance(event, WorkerPhase):
stderr.write(
f". worker_phase: phase={event.phase} turn_id={event.turn_id}\n"
)
return
if isinstance(event, Error):
stderr.write(
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
f"message={event.message!r}\n"
)
return
if isinstance(event, Cancelled):
stderr.write(
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
f"partial_message_id={event.partial_message_id}\n"
)
return
if isinstance(event, ToolStart):
stderr.write(
f". tool_start: name={event.name} args={event.arguments!r}\n"
)
return
if isinstance(event, ToolResult):
stderr.write(
f". tool_result: name={event.name} duration_ms={event.duration_ms} "
f"result={event.result!r:.200}\n"
)
return
if isinstance(event, TextBoundary):
stderr.write(
f". text_boundary: kind={event.kind} char_offset={event.char_offset}\n"
)
return
async def _cancel_and_log(
@@ -215,12 +301,20 @@ async def _run_turn(
*,
stdout: TextIO,
stderr: TextIO,
state: CliPresenterState | None = None,
) -> int:
"""Drive stream_turn, render events, race against sigint_event for mid-stream cancel."""
"""Drive stream_turn, render events, race against sigint_event for mid-stream cancel.
Per issue #12: a `CliPresenterState` is passed in by `_amain` for stateful
coalescing + telemetry demotion. Callers that omit `state` get a fresh
instance (transitional convenience; tests construct their own to inspect).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert content and isinstance(content, str)
assert isinstance(sigint_event, asyncio.Event)
if state is None:
state = CliPresenterState()
last_turn_id: int | None = None
cancelling = False
@@ -271,7 +365,7 @@ async def _run_turn(
stderr.write(f"[turn_id_flip] expected={exc.established} got={exc.got}\n")
return 22
last_turn_id = event.sse_id.turn_id
_render_event(event, stdout=stdout, stderr=stderr)
state.render(event, stdout=stdout, stderr=stderr)
if isinstance(event, Done):
if sigint_task is not None and not cancelling:
sigint_task.cancel()
@@ -324,8 +418,10 @@ async def _amain(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
# Issue #12: demoted lifecycle line — written directly here (NOT via
# state.render, which only accepts SSE Event variants per PRE-001).
sys.stderr.write(
f"[create_session] session_id={info.session_id} agent_id={info.agent_id}\n"
f". create_session: session_id={info.session_id} agent_id={info.agent_id}\n"
)
session_id = info.session_id
else:
@@ -335,12 +431,14 @@ async def _amain(args: ParsedArgs) -> int:
sigint_event = asyncio.Event()
loop = asyncio.get_running_loop()
loop.add_signal_handler(signal.SIGINT, sigint_event.set)
state = CliPresenterState()
try:
return await _run_turn(
client,
session_id,
args.send_content,
sigint_event,
state=state,
stdout=sys.stdout,
stderr=sys.stderr,
)
+47
View File
@@ -39,6 +39,26 @@ class SessionPage:
next_cursor: str | None
@dataclass(frozen=True)
class AgentInfo:
"""Worldtree agent envelope from GET /agents (issue #8).
INV-005: required fields (`agent_id`, `name`, `description`) take the
response value verbatim. Optional fields default to None / [] / {} when
omitted by the server, mirroring SessionInfo's INV-001/INV-002
origin-conditional defaulting.
"""
agent_id: str
name: str
description: str
version: str | None
capabilities: list[str]
supported_models: list[str]
persona_traits: dict[str, Any]
ui_hints: dict[str, Any]
class AgentNotFound(Exception):
"""Raised on HTTP 404 from POST /sessions — unknown agent_id."""
@@ -153,3 +173,30 @@ async def create_session(
archived=False,
tags=[],
)
async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
"""GET /agents — list available agents. See contract FN list_agents (issue #8).
No request params, no pagination. Returns server-ordered list. Optional
fields are defaulted to None / [] / {} per INV-005.
"""
assert client is not None
resp = await client.get("/agents")
if resp.status_code != 200:
raise SessionApiFailed(status=resp.status_code, body=resp.content)
body = resp.json()
return [
AgentInfo(
agent_id=item["agent_id"],
name=item["name"],
description=item["description"],
version=item.get("version"),
capabilities=item.get("capabilities") or [],
supported_models=item.get("supported_models") or [],
persona_traits=item.get("persona_traits") or {},
ui_hints=item.get("ui_hints") or {},
)
for item in body
]
+365 -49
View File
@@ -10,15 +10,34 @@ from __future__ import annotations
import asyncio
import sys
from dataclasses import dataclass, field
from typing import ClassVar, Literal
import httpx
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import Footer, Header, Input, RichLog, Static
from textual.containers import Horizontal, Vertical
from textual.widgets import (
Footer,
Header,
Input,
Label,
ListItem,
ListView,
RichLog,
Static,
TabbedContent,
TabPane,
)
from ratatoskr.cli import USER_AGENT, ParsedArgs
from ratatoskr.sessions import AgentNotFound, SessionApiFailed, create_session
from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage
from ratatoskr.sessions import (
AgentInfo,
AgentNotFound,
SessionApiFailed,
create_session,
list_agents,
)
from ratatoskr.sse_client import (
CancelAlreadyCompleted,
CancelFailed,
@@ -42,51 +61,300 @@ from ratatoskr.sse_client import (
stream_turn,
)
# ---- Issue #12 presenter contract semantics amendment -------------------------
#
# TuiPresenterState replaces the stateless _render_event_to_log with a stateful
# per-turn presenter that coalesces thinking runs into ONE closed RichLog entry
# per run + per-delta live updates on the dedicated thinking-current Static
# widget. One instance per `_stream_turn_worker` invocation.
def _render_event_to_log(event: Event, *, log: RichLog, raw: bool) -> None:
"""Pure event-to-RichLog renderer per the contract STEPS table."""
assert isinstance(
event,
(WorkerPhase, Thinking, Text, TextBoundary, ToolStart, ToolResult, Done, Error, Cancelled),
)
def _plain_label(event: Event) -> str:
"""Pre-amendment labeled-line shape for INV-009 render-exception fallback.
Used by `TuiPresenterState.render` ONLY in the except branch, so a failed
state-based render still produces a readable transcript entry per the
pre-amendment behavior. Bracketed labels match the historical
`_render_event_to_log` output verbatim.
"""
if isinstance(event, Text):
log.write(event.content)
elif isinstance(event, Done):
log.write(
return event.content
if isinstance(event, Done):
return (
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
f"duration_ms={event.duration_ms} usage={event.usage!r}"
)
elif isinstance(event, Error):
log.write(
if isinstance(event, Error):
return (
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
f"message={event.message!r}"
)
elif isinstance(event, Cancelled):
log.write(
if isinstance(event, Cancelled):
return (
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
f"partial_message_id={event.partial_message_id}"
)
elif isinstance(event, WorkerPhase):
log.write(f"[worker_phase] phase={event.phase} turn_id={event.turn_id}")
elif isinstance(event, Thinking):
log.write(f"[thinking] {event.content[:200]!r}")
elif isinstance(event, TextBoundary):
log.write(f"[text_boundary] kind={event.kind} char_offset={event.char_offset}")
elif isinstance(event, ToolStart):
log.write(f"[tool_start] name={event.name} args={event.arguments!r}")
elif isinstance(event, ToolResult):
log.write(
if isinstance(event, WorkerPhase):
return f"[worker_phase] phase={event.phase} turn_id={event.turn_id}"
if isinstance(event, Thinking):
return f"[thinking] {event.content[:200]!r}"
if isinstance(event, TextBoundary):
return f"[text_boundary] kind={event.kind} char_offset={event.char_offset}"
if isinstance(event, ToolStart):
return f"[tool_start] name={event.name} args={event.arguments!r}"
if isinstance(event, ToolResult):
return (
f"[tool_result] name={event.name} duration_ms={event.duration_ms} "
f"result={event.result!r:.200}"
)
return f"[unknown_event] {type(event).__name__}"
@dataclass(slots=True)
class TuiPresenterState:
"""Per-turn presenter state for TUI mode (issue #12).
See `docs/contracts/issues/12.contract.md` for the full spec.
"""
thinking_buffer: list[str] = field(default_factory=list)
thinking_open: bool = False
def render(
self,
event: Event,
*,
log: RichLog,
thinking_widget: Static,
tools_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`.
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).
"""
assert isinstance(
event,
(
WorkerPhase, Thinking, Text, TextBoundary,
ToolStart, ToolResult, Done, Error, Cancelled,
),
)
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")
try:
# Thinking events: accumulate into buffer, update widget per delta.
if isinstance(event, Thinking):
if not self.thinking_open:
thinking_widget.display = True
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)
return
# Non-thinking event: close any open thinking run (one RichLog entry).
if self.thinking_open:
full_thinking = "".join(self.thinking_buffer)
log.write(_dim(f"· thinking: {full_thinking}"))
self.thinking_buffer.clear()
self.thinking_open = False
thinking_widget.update("")
thinking_widget.display = False
# Now render the non-thinking event itself.
if isinstance(event, Text):
# Streamed text content — no prefix, no demotion.
log.write(event.content)
return
if isinstance(event, (Done, Error, Cancelled)):
# Terminal events: load-bearing label (no demotion).
if isinstance(event, Done):
log.write(
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))
elif isinstance(event, Error):
log.write(
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
f"message={event.message!r}"
)
else: # Cancelled
log.write(
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
return
if isinstance(event, WorkerPhase):
log.write(_dim(
f"· worker_phase: phase={event.phase} turn_id={event.turn_id}"
))
return
if isinstance(event, ToolStart):
# Issue #13 INV-014: tool events route to the Tools pane.
tools_log.write(_dim(
f"· tool_start: name={event.name} args={event.arguments!r}"
))
return
if isinstance(event, ToolResult):
# Issue #13 INV-014: tool events route to the Tools pane.
tools_log.write(_dim(
f"· tool_result: name={event.name} duration_ms={event.duration_ms} "
f"result={event.result!r:.200}"
))
return
if isinstance(event, TextBoundary):
log.write(_dim(
f"· text_boundary: kind={event.kind} char_offset={event.char_offset}"
))
return
except Exception as exc:
# INV-009 + POST-007 fallback: write pre-amendment plain-label line for
# 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
target.write(_plain_label(event))
target.write(f"[render_error] {type(exc).__name__}")
class AgentPickerApp(App[str | None]):
"""Startup agent picker (issue #8). Opens before RatatoskrApp when --new
is passed without --agent. `run_async()` returns the chosen agent_id (str)
or None on Esc/Ctrl-D dismissal.
Architecturally separate from RatatoskrApp (deliberate per issue #8
INV-007): keeps list_agents failures landing on real stderr before any
alt-screen opens, preserving issue #6's invariant.
"""
DEFAULT_CSS = """
#picker-prompt {
dock: top;
height: 1;
padding: 0 1;
}
#agent-list {
height: 1fr;
}
"""
BINDINGS: ClassVar[list[Binding]] = [
Binding("enter", "pick", "Pick", priority=True),
Binding("escape", "dismiss", "Cancel", priority=True),
Binding("ctrl+d", "dismiss", "Cancel", priority=True),
Binding("ctrl+c", "dismiss", "Cancel", priority=True),
]
def __init__(self, agents: list[AgentInfo]) -> None:
super().__init__()
# PRE-002: caller (_resolve_then_run) checks for empty list and emits
# [no_agents] before constructing the picker.
assert agents
self.agents = agents
def compose(self) -> ComposeResult:
yield Header()
yield Static("Pick an agent for the new session:", id="picker-prompt")
yield ListView(
*[
ListItem(Label(f"{a.agent_id} · {a.name}{a.description}"))
for a in self.agents
],
id="agent-list",
)
yield Footer()
async def on_mount(self) -> None:
self.query_one("#agent-list", ListView).focus()
def action_pick(self) -> None:
lv = self.query_one("#agent-list", ListView)
idx = lv.index
if idx is None:
return # nothing highlighted; ignore
self.exit(self.agents[idx].agent_id)
def action_dismiss(self) -> None:
self.exit(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.
#
# 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.
DEFAULT_CSS = """
#main-row {
height: 1fr;
}
#left-column {
width: 2fr;
}
#right-column {
width: 1fr;
}
#thinking-current {
dock: top;
height: auto;
}
#transcript {
height: 1fr;
}
#prompt {
dock: bottom;
}
#identity {
dock: bottom;
height: 1;
}
#pane-name {
dock: bottom;
height: 1;
}
#hint {
dock: bottom;
height: 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.
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False),
]
HINT_IDLE = "Ctrl-C twice to exit"
@@ -113,17 +381,29 @@ class RatatoskrApp(App[int]):
def compose(self) -> ComposeResult:
yield Header()
# markup=False so labeled lines like "[cancel_failed] ..." render verbatim
# (Rich would otherwise interpret square-bracket spans as style markup and
# strip them). The post-Done markdown render uses Markdown() directly which
# is a Rich Renderable and renders correctly without widget-level markup=True.
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
yield Input(id="prompt", placeholder="Type a message and press Enter")
# INV-002 + INV-003: visible identity + hint widgets (Footer-area rendering).
# Textual's built-in Footer renders BINDINGS descriptions; these Static widgets
# carry the session-identity and Ctrl-C-state strings the contract requires be
# always-visible.
# 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.
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"):
with TabbedContent(id="side-panes"):
with TabPane("Tools", id="tools-tab"):
yield RichLog(
id="tools-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.
yield Static("", id="identity")
yield Static("Tools", id="pane-name")
yield Static(self.HINT_IDLE, id="hint")
yield Footer()
@@ -138,6 +418,8 @@ 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
self.state = "idle"
self._set_hint(self.HINT_IDLE)
@@ -171,25 +453,27 @@ class RatatoskrApp(App[int]):
)
async def _stream_turn_worker(self, content: str) -> None:
"""Drive stream_turn, render events, set active_turn_id, restore idle on terminal/error."""
"""Drive stream_turn, render events via TuiPresenterState (issue #12)."""
assert self.state == "streaming"
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)
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
_render_event_to_log(event, log=log, raw=self.args.raw)
if isinstance(event, Done):
if not self.args.raw:
from rich.markdown import Markdown
from rich.rule import Rule
log.write(Rule())
log.write(Markdown(event.response))
break
if isinstance(event, (Error, Cancelled)):
presenter.render(
event,
log=log,
thinking_widget=thinking_widget,
tools_log=tools_log,
raw=self.args.raw,
)
if isinstance(event, (Done, Error, Cancelled)):
break
except SseConnectFailed as exc:
log.write(f"[sse_connect_failed] status={exc.status} body={exc.body!r}")
@@ -238,6 +522,16 @@ class RatatoskrApp(App[int]):
self.stream_worker.cancel()
self.exit(0)
def action_focus_tools(self) -> None:
"""Issue #13: Ctrl+1 activates the Tools tab. INV-016 preserves Input focus.
Current Textual behavior preserves Input focus when TabbedContent.active is
set programmatically. If a future Textual regresses on that, add an
explicit `self.query_one('#prompt', Input).focus()` after the assignment
— `test_ctrl_1_preserves_input_focus` is the regression guard.
"""
self.query_one("#side-panes", TabbedContent).active = "tools-tab"
def run_tui(args: ParsedArgs) -> int:
"""Sync entry point — delegates to the async resolve-then-run flow.
@@ -273,11 +567,33 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
# Default 5s read timeout would kill mid-stream; disable it.
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
) as client:
# 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).
chosen_agent_id: str | None = args.agent_id
if args.new and args.agent_id is None:
try:
agents = await list_agents(client)
except SessionApiFailed as exc:
sys.stderr.write(
f"[session_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 not agents:
sys.stderr.write("[no_agents] server returned empty agent list\n")
return 13
picker = AgentPickerApp(agents)
chosen_agent_id = await picker.run_async()
if chosen_agent_id is None:
return 0 # Esc / Ctrl-D — clean exit, no session created
if args.new:
assert args.agent_id is not None
assert chosen_agent_id is not None
try:
info = await create_session(
client, args.agent_id, end_user_id=args.end_user_id
client, chosen_agent_id, end_user_id=args.end_user_id
)
except AgentNotFound as exc:
sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n")
+392 -115
View File
@@ -16,7 +16,6 @@ from ratatoskr.cli import (
_AuthError,
_cancel_and_log,
_parse_args,
_render_event,
_run_turn,
main,
)
@@ -177,11 +176,37 @@ class TestParseArgs:
with pytest.raises(UsageError, match="pass exactly one"):
_parse_args(["--send", "hi", "--api-key", "k"])
def test_usage_new_without_agent(self) -> None:
"""usage_new_without_agent: --new without --agent → UsageError."""
def test_usage_send_new_without_agent(self) -> None:
"""send_new_without_agent (issue #8): --send --new without --agent → UsageError.
--send mode is non-interactive — cannot prompt; --agent stays required.
"""
with pytest.raises(UsageError, match="--agent is required when --new"):
_parse_args(["--send", "hi", "--new", "--api-key", "k"])
def test_parse_bare_new_without_agent_accepted(self) -> None:
"""bare_new_without_agent (issue #8): --new without --send or --agent → agent_id=None.
TUI mode CAN prompt; startup picker handles the choice. _parse_args
accepts None here and the TUI's _resolve_then_run drives the picker.
"""
args = _parse_args(["--new", "--api-key", "k"])
assert args.new is True
assert args.agent_id is None
assert args.send_content is None
def test_parse_bare_new_with_agent_accepted(self) -> None:
"""bare_new_with_agent (issue #8): --new --agent mimir (no --send) → picker skipped.
Existing TUI launch path with an explicit agent_id continues to work
— _resolve_then_run sees `args.agent_id is not None` and skips the
picker entirely.
"""
args = _parse_args(["--new", "--agent", "mimir", "--api-key", "k"])
assert args.new is True
assert args.agent_id == "mimir"
assert args.send_content is None
def test_usage_session_with_agent(self) -> None:
"""usage_session_with_agent: --session AND --agent → UsageError."""
with pytest.raises(UsageError, match="forbidden with --session"):
@@ -237,132 +262,378 @@ class TestParseArgs:
SID = SseId(42, 5)
class TestRenderEvent:
def test_text_to_stdout_only(self) -> None:
"""text_to_stdout_only [happy,tracer]: Text → stdout=="hello"; stderr empty; flushed."""
stdout = _FlushCountingIO()
stderr = io.StringIO()
_render_event(Text(sse_id=SID, content="hello"), stdout=stdout, stderr=stderr)
assert stdout.getvalue() == "hello"
assert stderr.getvalue() == ""
assert stdout.flush_count == 1 # INV-010: per-chunk flush
# Issue #12 — presenter contract semantics amendment.
# CliPresenterState replaces the stateless _render_event with a stateful per-turn
# presenter that coalesces thinking runs and demotes telemetry events.
# (Pre-amendment TestRenderEvent class and `_render_event` function have been
# removed under the project's no-backwards-compatibility rule.)
def test_done_writes_newline_and_label(self) -> None:
"""done_writes_newline_and_label: stdout=="\\n" (flushed); stderr "[done]" labels."""
stdout = _FlushCountingIO()
SID42 = SseId(42, 1)
class TestCliPresenterState:
"""Tests for the new CliPresenterState — per issue #12 contract."""
def test_thinking_coalesce_single_run(self) -> None:
"""thinking_coalesce_single_run [happy,tracer]:
Thinking("hello") + Thinking(" world") + Done →
stderr has ". thinking: hello world\\n" followed by the [done] line.
"""
from ratatoskr.cli import CliPresenterState
stdout = io.StringIO()
stderr = io.StringIO()
evt = Done(
sse_id=SID,
phase="completed",
state = CliPresenterState()
state.render(Thinking(sse_id=SID42, content="hello"), stdout=stdout, stderr=stderr)
# After first delta: stderr has the open prefix + content, no \n yet.
assert stderr.getvalue() == ". thinking: hello"
state.render(Thinking(sse_id=SID42, content=" world"), stdout=stdout, stderr=stderr)
# After second delta: still the same growing logical line, still no \n.
assert stderr.getvalue() == ". thinking: hello world"
# Now a Done event closes the thinking run with \n then writes the terminal label.
done = Done(
sse_id=SID42,
phase="succeeded",
response="hi",
model="glm5-turbo",
duration_ms=1234,
usage={"prompt": 10, "completion": 5},
model="m",
duration_ms=1,
usage={
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"cached_input_tokens": 0,
},
)
_render_event(evt, stdout=stdout, stderr=stderr)
assert stdout.getvalue() == "\n"
assert stdout.flush_count == 1 # INV-010: post-Done newline flushed
out_err = stderr.getvalue()
assert out_err.startswith("[done]")
assert "turn_id=42" in out_err
assert "model=glm5-turbo" in out_err
assert "duration_ms=1234" in out_err
def test_error_to_stderr_only(self) -> None:
"""error_to_stderr_only: Error → stderr "[error]" with code; stdout empty."""
stdout = io.StringIO()
stderr = io.StringIO()
evt = Error(sse_id=SID, phase="failed", message="boom", error_code="llm_output_invalid")
_render_event(evt, stdout=stdout, stderr=stderr)
state.render(done, stdout=stdout, stderr=stderr)
captured = stderr.getvalue()
# Thinking run closed with \n; terminal label landed; no demotion prefix on [done].
assert captured.startswith(". thinking: hello world\n")
assert "[done]" in captured
# stdout untouched (no Text events were rendered)
assert stdout.getvalue() == ""
out_err = stderr.getvalue()
assert out_err.startswith("[error]")
assert "turn_id=42" in out_err
assert "code=llm_output_invalid" in out_err
def test_cancelled_to_stderr_only(self) -> None:
"""cancelled_to_stderr_only: Cancelled → stderr "[cancelled]" + reason + partial id."""
stdout = io.StringIO()
def test_thinking_closes_on_first_non_thinking_event(self) -> None:
"""thinking_closes_on_first_non_thinking_event [happy]:
Thinking → WorkerPhase → stderr has ". thinking: ...\\n" then ". worker_phase: ..."
"""
from ratatoskr.cli import CliPresenterState
stderr = io.StringIO()
evt = Cancelled(
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=7
state = CliPresenterState()
state.render(Thinking(sse_id=SID42, content="x"), stdout=io.StringIO(), stderr=stderr)
state.render(
WorkerPhase(sse_id=SID42, phase="streaming", turn_id=42),
stdout=io.StringIO(),
stderr=stderr,
)
_render_event(evt, stdout=stdout, stderr=stderr)
assert stdout.getvalue() == ""
out_err = stderr.getvalue()
assert out_err.startswith("[cancelled]")
assert "reason='user'" in out_err
assert "partial_message_id=7" in out_err
out = stderr.getvalue()
# Thinking run closed; worker_phase rendered with demotion prefix.
assert ". thinking: x\n" in out
assert ". worker_phase:" in out
# `[worker_phase]` (bracketed, pre-amendment shape) MUST NOT appear.
assert "[worker_phase]" not in out
def test_thinking_closes_on_error(self) -> None:
"""thinking_closes_on_error [error]:
Thinking → Error → thinking line closes with \\n, then [error] line rendered
(partial thinking content is NOT discarded — observability requirement).
"""
from ratatoskr.cli import CliPresenterState
stderr = io.StringIO()
state = CliPresenterState()
state.render(Thinking(sse_id=SID42, content="x"), stdout=io.StringIO(), stderr=stderr)
state.render(
Error(sse_id=SID42, phase="failed", message="boom", error_code="bad"),
stdout=io.StringIO(),
stderr=stderr,
)
out = stderr.getvalue()
# Partial thinking preserved with closing \n; error rendered without demotion prefix.
assert ". thinking: x\n" in out
assert "[error]" in out
# Demotion prefix MUST NOT precede [error]: it's load-bearing.
assert ". [error]" not in out
def test_cancelled_mid_thinking(self) -> None:
"""cancelled_mid_thinking [scenario]: Thinking → Cancelled → thinking closes with \\n;
then [cancelled] (no demotion prefix, partial thinking preserved).
"""
from ratatoskr.cli import CliPresenterState
stderr = io.StringIO()
state = CliPresenterState()
state.render(Thinking(sse_id=SID42, content="x"), stdout=io.StringIO(), stderr=stderr)
state.render(
Cancelled(
sse_id=SID42, phase="cancelled", turn_id=42, reason="user", partial_message_id=None
),
stdout=io.StringIO(),
stderr=stderr,
)
out = stderr.getvalue()
assert ". thinking: x\n" in out
assert "[cancelled]" in out
assert ". [cancelled]" not in out
def test_text_then_done_newline_boundary(self) -> None:
"""text_then_done_newline_boundary [trace]: Text("answer") → Done;
stdout receives "answer\\n" (the \\n is the INV-005 boundary), stderr has "[done] ...".
"""
from ratatoskr.cli import CliPresenterState
def test_worker_phase_to_stderr(self) -> None:
"""worker_phase_to_stderr: WorkerPhase → stderr "[worker_phase]"; stdout empty."""
stdout = io.StringIO()
stderr = io.StringIO()
evt = WorkerPhase(sse_id=SID, phase="streaming", turn_id=42)
_render_event(evt, stdout=stdout, stderr=stderr)
assert stdout.getvalue() == ""
assert stderr.getvalue().startswith("[worker_phase]")
state = CliPresenterState()
state.render(Text(sse_id=SID42, content="answer"), stdout=stdout, stderr=stderr)
state.render(_make_done(), stdout=stdout, stderr=stderr)
# INV-005: text without trailing \n → exactly one \n gets injected before terminal label
assert stdout.getvalue() == "answer\n"
assert "[done]" in stderr.getvalue()
def test_no_text_then_done_no_extra_newline(self) -> None:
"""no_text_then_done_no_extra_newline [trace]: Done with no preceding Text →
stdout untouched; stderr receives only "[done] ...".
"""
from ratatoskr.cli import CliPresenterState
def test_thinking_truncated(self) -> None:
"""thinking_truncated [trace]: …"""
stdout = io.StringIO()
stderr = io.StringIO()
_render_event(Thinking(sse_id=SID, content="a" * 500), stdout=stdout, stderr=stderr)
out_err = stderr.getvalue()
assert out_err.startswith("[thinking]")
assert "a" * 500 not in out_err
assert "a" * 200 in out_err
state = CliPresenterState()
state.render(_make_done(), stdout=stdout, stderr=stderr)
# INV-005 boundary fires ONLY when text was written; no text → no \n injection.
assert stdout.getvalue() == ""
assert "[done]" in stderr.getvalue()
def test_newline_terminated_text_then_done(self) -> None:
"""newline_terminated_text_then_done [trace]: Text("answer\\n") → Done;
stdout receives "answer\\n" exactly ONCE (no double-newline before [done]).
Tests the F4 Volva fix: text_written_since_newline tracks last-char-was-\\n.
"""
from ratatoskr.cli import CliPresenterState
def test_tool_start_to_stderr(self) -> None:
"""tool_start_to_stderr: ToolStart → stderr "[tool_start] name=... args=..."."""
stdout = io.StringIO()
stderr = io.StringIO()
evt = ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"})
_render_event(evt, stdout=stdout, stderr=stderr)
assert stdout.getvalue() == ""
out_err = stderr.getvalue()
assert out_err.startswith("[tool_start] name=read_file args=")
state = CliPresenterState()
state.render(Text(sse_id=SID42, content="answer\n"), stdout=stdout, stderr=stderr)
state.render(_make_done(), stdout=stdout, stderr=stderr)
# POST-003: content ends with \n → state.text_written_since_newline = False
# → INV-005 does NOT inject an extra \n before [done].
assert stdout.getvalue() == "answer\n"
def test_multiple_thinking_runs(self) -> None:
"""multiple_thinking_runs [scenario]: Thinking → Text → Thinking → Done →
TWO separate ". thinking: ..." runs in stderr; stdout has the text + INV-005 boundary.
"""
from ratatoskr.cli import CliPresenterState
stdout = io.StringIO()
stderr = io.StringIO()
state = CliPresenterState()
state.render(Thinking(sse_id=SID42, content="first"), stdout=stdout, stderr=stderr)
state.render(Text(sse_id=SID42, content="answer"), stdout=stdout, stderr=stderr)
state.render(Thinking(sse_id=SID42, content="second"), stdout=stdout, stderr=stderr)
state.render(_make_done(), stdout=stdout, stderr=stderr)
err = stderr.getvalue()
# Each thinking RUN gets its own ". thinking: " prefix.
assert err.count(". thinking: ") == 2
assert ". thinking: first" in err
assert ". thinking: second" in err
assert stdout.getvalue() == "answer\n"
assert "[done]" in err
def test_tool_start_demoted(self) -> None:
"""tool_start_demoted [trace]: ToolStart → stderr line starts with ". tool_start:" """
from ratatoskr.cli import CliPresenterState
stderr = io.StringIO()
state = CliPresenterState()
state.render(
ToolStart(sse_id=SID42, name="read_file", arguments={"path": "/x"}),
stdout=io.StringIO(),
stderr=stderr,
)
line = stderr.getvalue()
assert line.startswith(". tool_start:")
assert "[tool_start]" not in line
def test_tool_result_truncated(self) -> None:
"""tool_result_truncated [trace]: ToolResult.result repr truncated to ≤200 chars."""
stdout = io.StringIO()
stderr = io.StringIO()
evt = ToolResult(sse_id=SID, name="x", result="b" * 500, duration_ms=42)
_render_event(evt, stdout=stdout, stderr=stderr)
out_err = stderr.getvalue()
assert out_err.startswith("[tool_result]")
# the contract uses `{event.result!r:.200}` — 200 chars max of repr output
assert "b" * 500 not in out_err
"""tool_result_truncated [trace]: long result repr truncates to ≤200 chars."""
from ratatoskr.cli import CliPresenterState
def test_text_boundary_to_stderr(self) -> None:
"""text_boundary_to_stderr: TextBoundary → stderr "[text_boundary]"; stdout empty."""
stdout = io.StringIO()
stderr = io.StringIO()
evt = TextBoundary(sse_id=SID, kind="sentence", char_offset=128, ts="2026-05-21T00:00:00Z")
_render_event(evt, stdout=stdout, stderr=stderr)
assert stdout.getvalue() == ""
out_err = stderr.getvalue()
assert out_err.startswith("[text_boundary]")
assert "kind=sentence" in out_err
assert "char_offset=128" in out_err
state = CliPresenterState()
state.render(
ToolResult(sse_id=SID42, name="x", result="b" * 500, duration_ms=42),
stdout=io.StringIO(),
stderr=stderr,
)
line = stderr.getvalue()
assert line.startswith(". tool_result:")
# Full 500-char result MUST NOT fit; truncation applied.
assert "b" * 500 not in line
def test_invariant_inv003_stderr_only(self) -> None:
"""invariant_inv003_stderr_only [scenario]: …"""
for evt in [
WorkerPhase(sse_id=SID, phase="x", turn_id=42),
Thinking(sse_id=SID, content="x"),
TextBoundary(sse_id=SID, kind="x", char_offset=0, ts="t"),
ToolStart(sse_id=SID, name="x", arguments={}),
ToolResult(sse_id=SID, name="x", result=None, duration_ms=0),
Error(sse_id=SID, phase="failed", message="m", error_code="e"),
Cancelled(
sse_id=SID, phase="cancelled", turn_id=42, reason="r", partial_message_id=None
def test_text_boundary_demoted(self) -> None:
"""text_boundary_demoted [trace]: TextBoundary → stderr ". text_boundary:" prefix."""
from ratatoskr.cli import CliPresenterState
stderr = io.StringIO()
state = CliPresenterState()
state.render(
TextBoundary(sse_id=SID42, kind="sentence", char_offset=128, ts="2026-05-21T00:00:00Z"),
stdout=io.StringIO(),
stderr=stderr,
)
line = stderr.getvalue()
assert line.startswith(". text_boundary:")
assert "[text_boundary]" not in line
def test_duration_format_seconds(self) -> None:
"""duration_format_seconds [trace]: Done(duration_ms=5467) → "duration=5.5s"."""
from ratatoskr.cli import CliPresenterState
stderr = io.StringIO()
state = CliPresenterState()
state.render(_make_done(duration_ms=5467), stdout=io.StringIO(), stderr=stderr)
assert "duration=5.5s" in stderr.getvalue()
assert "duration_ms=5467" not in stderr.getvalue()
def test_duration_format_subsecond(self) -> None:
"""duration_format_subsecond [trace]: Done(duration_ms=347) → "duration=347ms"."""
from ratatoskr.cli import CliPresenterState
stderr = io.StringIO()
state = CliPresenterState()
state.render(_make_done(duration_ms=347), stdout=io.StringIO(), stderr=stderr)
assert "duration=347ms" in stderr.getvalue()
def test_duration_format_minutes(self) -> None:
"""duration_format_minutes [trace]: Done(duration_ms=72000) → "duration=1.2m"."""
from ratatoskr.cli import CliPresenterState
stderr = io.StringIO()
state = CliPresenterState()
state.render(_make_done(duration_ms=72000), stdout=io.StringIO(), stderr=stderr)
assert "duration=1.2m" in stderr.getvalue()
def test_usage_format_ascii_arrow(self) -> None:
"""usage_format_ascii_arrow [trace]: stderr label contains the natural-language
usage shape with ASCII arrow (-> not →) for CLI scriptability.
"""
from ratatoskr.cli import CliPresenterState
stderr = io.StringIO()
state = CliPresenterState()
state.render(
_make_done(
usage={
"prompt_tokens": 6756,
"completion_tokens": 126,
"total_tokens": 6882,
"cached_input_tokens": 0,
}
),
]:
stdout = io.StringIO()
stderr = io.StringIO()
_render_event(evt, stdout=stdout, stderr=stderr)
assert stdout.getvalue() == "", f"INV-002 violated for {type(evt).__name__}"
stdout=io.StringIO(),
stderr=stderr,
)
out = stderr.getvalue()
assert "usage 6756 in -> 126 out (6882 total, 0 cached)" in out
# Raw dict shape MUST NOT leak through.
assert "'prompt_tokens'" not in out
def test_state_reset_per_amain(self) -> None:
"""state_reset_per_amain [trace]: fresh CliPresenterState() starts with no thinking open."""
from ratatoskr.cli import CliPresenterState
# Simulate two _amain calls by constructing two independent states.
s1 = CliPresenterState()
s2 = CliPresenterState()
# Run thinking into s1 — it should NOT bleed into s2.
s1.render(Thinking(sse_id=SID42, content="x"), stdout=io.StringIO(), stderr=io.StringIO())
assert s1.thinking_open is True
assert s2.thinking_open is False
# s2's first render produces its own ". thinking: " prefix.
e2 = io.StringIO()
s2.render(Thinking(sse_id=SID42, content="y"), stdout=io.StringIO(), stderr=e2)
assert e2.getvalue() == ". thinking: y"
class TestFormatDurationMs:
"""Unit tests for _format_duration_ms per INV-006."""
def test_subsecond(self) -> None:
from ratatoskr.cli import _format_duration_ms
assert _format_duration_ms(347) == "347ms"
def test_exact_one_second(self) -> None:
from ratatoskr.cli import _format_duration_ms
assert _format_duration_ms(1000) == "1.0s"
def test_fractional_seconds(self) -> None:
from ratatoskr.cli import _format_duration_ms
assert _format_duration_ms(5467) == "5.5s"
def test_exact_one_minute(self) -> None:
from ratatoskr.cli import _format_duration_ms
assert _format_duration_ms(60000) == "1.0m"
def test_fractional_minutes(self) -> None:
from ratatoskr.cli import _format_duration_ms
assert _format_duration_ms(72000) == "1.2m"
def test_zero(self) -> None:
from ratatoskr.cli import _format_duration_ms
assert _format_duration_ms(0) == "0ms"
class TestFormatUsage:
"""Unit tests for _format_usage per INV-007."""
def test_ascii_arrow(self) -> None:
from ratatoskr.cli import _format_usage
usage = {
"prompt_tokens": 6756,
"completion_tokens": 126,
"total_tokens": 6882,
"cached_input_tokens": 0,
}
assert (
_format_usage(usage, arrow="->")
== "6756 in -> 126 out (6882 total, 0 cached)"
)
def test_unicode_arrow(self) -> None:
from ratatoskr.cli import _format_usage
usage = {
"prompt_tokens": 6756,
"completion_tokens": 126,
"total_tokens": 6882,
"cached_input_tokens": 0,
}
assert (
_format_usage(usage, arrow="")
== "6756 in → 126 out (6882 total, 0 cached)"
)
_USAGE_ZERO: dict[str, int] = {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"cached_input_tokens": 0,
}
def _make_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None) -> Done:
return Done(
sse_id=SID42,
phase="succeeded",
response="r",
model="m",
duration_ms=duration_ms,
usage=usage if usage is not None else _USAGE_ZERO,
)
class TestCancelAndLog:
@@ -813,7 +1084,11 @@ class TestRunTurn:
@respx.mock
async def test_render_called_once_per_event(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""render_called_once_per_event [trace]: spy → _render_event call_count == event count."""
"""render_called_once_per_event [trace]: spy on render; call_count == event count.
Per issue #12: rendering went from stateless `_render_event` to
`CliPresenterState.render`; the spy moves accordingly.
"""
chunks = [
_sse_chunk("42:1", {"type": "worker_phase", "phase": "streaming", "turn_id": 42}),
_sse_chunk("42:2", {"type": "text", "content": "hi"}),
@@ -825,17 +1100,17 @@ class TestRunTurn:
)
)
from ratatoskr.cli import CliPresenterState
call_count = 0
from ratatoskr import cli as cli_mod
original = CliPresenterState.render
original = cli_mod._render_event
def spy(event, **kw): # type: ignore[no-untyped-def]
def spy(self, event, **kw): # type: ignore[no-untyped-def]
nonlocal call_count
call_count += 1
return original(event, **kw)
return original(self, event, **kw)
monkeypatch.setattr(cli_mod, "_render_event", spy)
monkeypatch.setattr(CliPresenterState, "render", spy)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
@@ -921,9 +1196,11 @@ class TestAmain:
assert exit_code == 0
captured = capsys.readouterr()
err = captured.err
assert "[create_session]" in err
# Per issue #12: [create_session] lifecycle line demoted to `. create_session:`.
assert ". create_session:" in err
assert "[create_session]" not in err # pre-amendment shape forbidden
assert "[done]" in err
assert err.index("[create_session]") < err.index("[done]")
assert err.index(". create_session:") < err.index("[done]")
@respx.mock
async def test_happy_existing_session(self, capsys: pytest.CaptureFixture[str]) -> None:
+144
View File
@@ -5,11 +5,13 @@ import pytest
import respx
from ratatoskr.sessions import (
AgentInfo,
AgentNotFound,
InvalidCursor,
SessionApiFailed,
SessionPage,
create_session,
list_agents,
list_sessions,
)
@@ -412,3 +414,145 @@ class TestListSessions:
with pytest.raises(AssertionError):
await list_sessions(client, cursor="")
assert route.call_count == 0
# ---- Issue #8: list_agents + AgentInfo --------------------------------------
class TestListAgents:
@respx.mock
async def test_happy_full_shape(self) -> None:
"""happy_full_shape [happy,tracer]: spec full-shape mimir example → all fields."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{
"agent_id": "mimir",
"name": "Mimir",
"description": "Keeper of the Well of Knowledge.",
"version": "0.2.0",
"capabilities": ["knowledge_base", "semantic_search"],
"supported_models": ["default", "heavy"],
"persona_traits": {
"ocean": {
"openness": 0.7,
"conscientiousness": 0.9,
"extraversion": 0.1,
"agreeableness": 0.5,
"neuroticism": 0.3,
},
"vibe": "contemplative",
},
"ui_hints": {"icon": "well", "color_hint": "#5b8aa3"},
}
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert len(agents) == 1
a = agents[0]
assert isinstance(a, AgentInfo)
assert a.agent_id == "mimir"
assert a.name == "Mimir"
assert a.description == "Keeper of the Well of Knowledge."
assert a.version == "0.2.0"
assert a.capabilities == ["knowledge_base", "semantic_search"]
assert a.supported_models == ["default", "heavy"]
assert a.persona_traits["vibe"] == "contemplative"
assert a.ui_hints["icon"] == "well"
@respx.mock
async def test_happy_minimum_shape(self) -> None:
"""happy_minimum_shape: required-only agent → optional fields default."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{
"agent_id": "minimal",
"name": "Minimal Agent",
"description": "Just a sketch.",
}
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
a = agents[0]
assert a.agent_id == "minimal"
assert a.version is None
assert a.capabilities == []
assert a.supported_models == []
assert a.persona_traits == {}
assert a.ui_hints == {}
@respx.mock
async def test_happy_multi_agent(self) -> None:
"""happy_multi_agent: 3 agents preserve order."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{"agent_id": "a", "name": "A", "description": "x"},
{"agent_id": "b", "name": "B", "description": "y"},
{"agent_id": "c", "name": "C", "description": "z"},
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert [a.agent_id for a in agents] == ["a", "b", "c"]
@respx.mock
async def test_happy_empty(self) -> None:
"""happy_empty: 200 with [] returns empty list (no error)."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json=[])
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert agents == []
@respx.mock
async def test_omit_capabilities_empty_list(self) -> None:
"""omit_capabilities_empty: explicit [] from server still defaults to []."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{
"agent_id": "a",
"name": "A",
"description": "x",
"capabilities": [],
}
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert agents[0].capabilities == []
@respx.mock
async def test_500_raises_session_api_failed(self) -> None:
"""500 → SessionApiFailed with status=500."""
respx.get("https://w.example/agents").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 excinfo:
await list_agents(client)
assert excinfo.value.status == 500
@respx.mock
async def test_401_raises_session_api_failed(self) -> None:
"""401 → SessionApiFailed with status=401."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(401, content=b'{"error":"unauthorized"}')
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as excinfo:
await list_agents(client)
assert excinfo.value.status == 401
+1001 -197
View File
File diff suppressed because it is too large Load Diff
Generated
+1 -1
View File
@@ -968,7 +968,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.1.0"
version = "0.4.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },