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).
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+61
-42
@@ -32,61 +32,78 @@ 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-23 (end of day, post-#12 implementation, pre-commit):_
|
||||
|
||||
**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: issue #12 (presenter contract semantics amendment)
|
||||
TDD-complete, in working tree, awaiting commit.** Seven 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) + robustness fix #7 (MalformedSseData
|
||||
+ empty-skip). 208/208 tests GREEN; ruff clean. pyproject.toml bumped
|
||||
to v0.2.0; `uv.lock` refreshed. Working tree has 9 modified files +
|
||||
the new `docs/contracts/issues/12.contract.md` (untracked); commit not
|
||||
yet authored.
|
||||
|
||||
`--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).
|
||||
Last commits on `main`:
|
||||
- `8282156` snapshot: persistent-memory Heimdall scope-model foot-gun (post-v0.1.0)
|
||||
- `804c2df` feat(sessions,cli,tui): issues #5 + #6 + worldtree-dev consumer-API follow-up (tagged v0.1.0)
|
||||
|
||||
**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.
|
||||
`--send` validated end-to-end against personal Worldtree at v0.1.0
|
||||
(`http://10.250.50.152:8081`, mimir on qwen3.6-35-a3b, 2026-05-23 smoke
|
||||
returned `[done] turn_id=116 duration_ms=5467`). Lofn smoke is
|
||||
**auth-unblocked** as of 2026-05-23 — worldtree-dev confirmed our key
|
||||
(`c990f0be`) already covers Tier 1 agents via the `agent.call:*`
|
||||
baseline policy; the initial "scope-add needed" diagnosis was a phantom
|
||||
ask (see Tried-and-abandoned). The actual lofn fix shipped as issue #5
|
||||
(`--end-user-id` flag).
|
||||
|
||||
**Async cross-frontier activity in flight:**
|
||||
- Issue #12 code-review consult posted to volva 2026-05-23 (althing
|
||||
thread `01KSBH8GYH4G3H03T767X613W7`). Reply pending in inbox.
|
||||
|
||||
**Outstanding operator-side todos:**
|
||||
- **Commit issue #12 work** + tag v0.2.0 + push. 9 modified files +
|
||||
new `12.contract.md` ready.
|
||||
- **Post-v0.2.0 mimir smoke (the visual one)** — `source env.sh && uv
|
||||
run ratatoskr --new --agent mimir --send "test"` to eyeball the new
|
||||
rendering (`. thinking: ...` coalesce, `. worker_phase: ...` demotion,
|
||||
`duration=5.5s` formatting, `usage 6756 in -> 126 out (...)` shape).
|
||||
The v0.1.0 mimir smoke confirmed wire-level backwards compat but
|
||||
did NOT exercise the v0.2.0 rendering.
|
||||
- **Post-v0.2.0 lofn smoke** — `source env.sh && uv run ratatoskr
|
||||
--new --agent lofn --send "hello"` (env.sh ships
|
||||
`RATATOSKR_END_USER_ID="ratatoskr-tui"`). Now unblocked on auth.
|
||||
|
||||
**Pending issues filed but not started:**
|
||||
- **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`).
|
||||
`GET /agents` is free to call (worldtree-dev confirmed); auth side
|
||||
is unblocked. Depends on #5 composably (both thread through
|
||||
`ParsedArgs` → `_resolve_then_run`).
|
||||
- **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.
|
||||
2026-05-23. Documentation debt. None of the v0.20.0/v0.21.0/v0.22.0
|
||||
changes break ratatoskr's existing surface; the pin lies about
|
||||
what we've committed to.
|
||||
- **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.
|
||||
Don't pre-implement per worldtree-dev's explicit guidance.
|
||||
- **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 requires `admin.events.read` scope.
|
||||
|
||||
Branch: `main` (clean after this commit). Remote:
|
||||
Branch: `main` (dirty with #12 work pending commit). 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. **Triage volva's #12 code-review** when the reply lands in the
|
||||
inbox; apply tactical fixes inline, surface architectural calls.
|
||||
2. **Commit + tag v0.2.0 + push.**
|
||||
3. **Post-v0.2.0 smokes** — mimir (visual), lofn (newly unblocked).
|
||||
4. **Issue #8 (startup agent picker)** — scaffold + contract, then
|
||||
TDD. Composes with the forward end_user_id direction (see Recent
|
||||
decisions).
|
||||
5. **Side-pane issues** — Persona pane first (file-tail, cheap).
|
||||
6. **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 +134,8 @@ 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]` **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)._
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+136
-41
@@ -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
|
||||
|
||||
@@ -150,45 +150,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 +298,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 +362,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 +415,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 +428,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,
|
||||
)
|
||||
|
||||
+166
-35
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
import httpx
|
||||
@@ -17,7 +18,7 @@ from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.widgets import Footer, Header, Input, RichLog, Static
|
||||
|
||||
from ratatoskr.cli import USER_AGENT, ParsedArgs
|
||||
from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage
|
||||
from ratatoskr.sessions import AgentNotFound, SessionApiFailed, create_session
|
||||
from ratatoskr.sse_client import (
|
||||
CancelAlreadyCompleted,
|
||||
@@ -42,43 +43,172 @@ 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,
|
||||
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`.
|
||||
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):
|
||||
log.write(_dim(
|
||||
f"· tool_start: name={event.name} args={event.arguments!r}"
|
||||
))
|
||||
return
|
||||
if isinstance(event, ToolResult):
|
||||
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.
|
||||
log.write(_plain_label(event))
|
||||
log.write(f"[render_error] {type(exc).__name__}")
|
||||
|
||||
|
||||
class RatatoskrApp(App[int]):
|
||||
@@ -125,6 +255,9 @@ class RatatoskrApp(App[int]):
|
||||
# always-visible.
|
||||
yield Static("", id="identity")
|
||||
yield Static(self.HINT_IDLE, id="hint")
|
||||
# Issue #12: live thinking widget — hidden by default, shown per-delta
|
||||
# during a thinking run, cleared+hidden at turn terminal.
|
||||
yield Static("", id="thinking-current")
|
||||
yield Footer()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
@@ -138,6 +271,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 +306,21 @@ 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)
|
||||
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, 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}")
|
||||
|
||||
+364
-113
@@ -16,7 +16,6 @@ from ratatoskr.cli import (
|
||||
_AuthError,
|
||||
_cancel_and_log,
|
||||
_parse_args,
|
||||
_render_event,
|
||||
_run_turn,
|
||||
main,
|
||||
)
|
||||
@@ -237,132 +236,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 +1058,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 +1074,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 +1170,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:
|
||||
|
||||
+363
-87
@@ -14,13 +14,11 @@ from ratatoskr.sse_client import (
|
||||
Error,
|
||||
SseId,
|
||||
Text,
|
||||
TextBoundary,
|
||||
Thinking,
|
||||
ToolResult,
|
||||
ToolStart,
|
||||
WorkerPhase,
|
||||
)
|
||||
from ratatoskr.tui import RatatoskrApp, _cancel_via_sse, _render_event_to_log
|
||||
from ratatoskr.tui import RatatoskrApp, _cancel_via_sse
|
||||
|
||||
_CANCEL_OK_RESP = {"turn_id": 42, "cancelled": True, "reason": None, "partial_message_id": None}
|
||||
_CREATE_OK_RESP = {
|
||||
@@ -103,101 +101,377 @@ def _resolved_app(
|
||||
SID = SseId(42, 5)
|
||||
|
||||
|
||||
class TestRenderEventToLog:
|
||||
def test_text_renders_raw_delta(self) -> None:
|
||||
"""text_renders_raw_delta [happy,tracer]: Text → log.write('hello')."""
|
||||
log = MagicMock()
|
||||
_render_event_to_log(Text(sse_id=SID, content="hello"), log=log, raw=False)
|
||||
log.write.assert_called_once_with("hello")
|
||||
# Issue #12 — TuiPresenterState replaces _render_event_to_log with a stateful
|
||||
# per-turn presenter. (Pre-amendment TestRenderEventToLog class and
|
||||
# `_render_event_to_log` function have been removed under the project's
|
||||
# no-backwards-compatibility rule.)
|
||||
|
||||
|
||||
class TestTuiPresenterState:
|
||||
"""Tests for the new TuiPresenterState — per issue #12 contract."""
|
||||
|
||||
def test_thinking_coalesce_single_widget_update(self) -> None:
|
||||
"""thinking_coalesce_single_widget_update [happy,tracer]:
|
||||
3 Thinking events → thinking_widget.update called 3 times with cumulative content;
|
||||
RichLog has 0 thinking entries (closure hasn't fired yet).
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
def test_done_renders_label_only(self) -> None:
|
||||
"""done_renders_label_only: …"""
|
||||
log = MagicMock()
|
||||
evt = Done(
|
||||
sse_id=SID,
|
||||
phase="completed",
|
||||
response="hi there",
|
||||
model="glm5-turbo",
|
||||
duration_ms=1234,
|
||||
usage={"prompt": 1, "completion": 2},
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(Thinking(sse_id=SID, content="a"), log=log, thinking_widget=widget, raw=False)
|
||||
state.render(Thinking(sse_id=SID, content="b"), log=log, thinking_widget=widget, raw=False)
|
||||
state.render(Thinking(sse_id=SID, content="c"), log=log, thinking_widget=widget, raw=False)
|
||||
# Widget updated 3 times — once per delta — with cumulative content
|
||||
assert widget.update.call_count == 3
|
||||
# Latest call shows the full accumulated content (under 200 chars so no truncation)
|
||||
assert widget.update.call_args_list[-1][0][0] == "abc"
|
||||
# Widget became visible at first delta
|
||||
assert widget.display is True
|
||||
# No RichLog write yet — closure hasn't fired
|
||||
assert log.write.call_count == 0
|
||||
|
||||
def test_thinking_closes_one_richlog_entry(self) -> None:
|
||||
"""thinking_closes_one_richlog_entry [happy]: 2x Thinking + WorkerPhase →
|
||||
RichLog has ONE closed thinking entry + one worker_phase entry; widget cleared+hidden.
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(Thinking(sse_id=SID, content="a"), log=log, thinking_widget=widget, raw=False)
|
||||
state.render(Thinking(sse_id=SID, content="b"), log=log, thinking_widget=widget, raw=False)
|
||||
state.render(
|
||||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
raw=False,
|
||||
)
|
||||
_render_event_to_log(evt, log=log, raw=False)
|
||||
log.write.assert_called_once()
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[done]")
|
||||
assert "turn_id=42" in line
|
||||
assert "model=glm5-turbo" in line
|
||||
# POST-003: the Done line is labels only; markdown render is the caller's job
|
||||
assert "hi there" not in line
|
||||
# Closure wrote "· thinking: ab"; then worker_phase wrote "· worker_phase: ..."
|
||||
assert log.write.call_count == 2
|
||||
# First write = closed thinking entry containing the full accumulated text
|
||||
assert "· thinking: ab" in log.write.call_args_list[0][0][0]
|
||||
# Second write = worker_phase with demotion prefix
|
||||
assert "· worker_phase:" in log.write.call_args_list[1][0][0]
|
||||
# Widget cleared + hidden
|
||||
widget.update.assert_called_with("")
|
||||
assert widget.display is False
|
||||
|
||||
def test_error_renders_label(self) -> None:
|
||||
"""error_renders_label: Error → log line starts with [error]."""
|
||||
log = MagicMock()
|
||||
evt = Error(sse_id=SID, phase="failed", message="boom", error_code="llm_output_invalid")
|
||||
_render_event_to_log(evt, log=log, raw=False)
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[error]")
|
||||
assert "turn_id=42" in line
|
||||
assert "code=llm_output_invalid" in line
|
||||
def test_thinking_widget_truncation(self) -> None:
|
||||
"""thinking_widget_truncation [trace]: buffer 500 chars → widget shows "…" + last 200."""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
def test_cancelled_renders_label(self) -> None:
|
||||
"""cancelled_renders_label: Cancelled → log line starts with [cancelled]."""
|
||||
log = MagicMock()
|
||||
evt = Cancelled(
|
||||
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=7
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
# Push 500 chars across multiple deltas.
|
||||
long = "x" * 500
|
||||
state.render(Thinking(sse_id=SID, content=long), log=log, thinking_widget=widget, raw=False)
|
||||
last_update = widget.update.call_args_list[-1][0][0]
|
||||
# …-prefix + last-200 = 201 chars
|
||||
assert last_update.startswith("…")
|
||||
assert len(last_update) == 201
|
||||
|
||||
def test_thinking_widget_visibility_lifecycle(self) -> None:
|
||||
"""thinking_widget_visibility_lifecycle [trace]: hidden at start; visible during thinking;
|
||||
hidden after closing event.
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
widget = MagicMock()
|
||||
widget.display = False # initial state (composed hidden)
|
||||
state = TuiPresenterState()
|
||||
# First thinking delta → widget visible
|
||||
state.render(Thinking(sse_id=SID, content="x"), log=log, thinking_widget=widget, raw=False)
|
||||
assert widget.display is True
|
||||
# Closure (WorkerPhase) → widget hidden
|
||||
state.render(
|
||||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
raw=False,
|
||||
)
|
||||
_render_event_to_log(evt, log=log, raw=False)
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[cancelled]")
|
||||
assert "reason='user'" in line
|
||||
assert "partial_message_id=7" in line
|
||||
assert widget.display is False
|
||||
|
||||
def test_worker_phase_renders_label(self) -> None:
|
||||
"""worker_phase_renders_label: WorkerPhase → log line starts with [worker_phase]."""
|
||||
log = MagicMock()
|
||||
evt = WorkerPhase(sse_id=SID, phase="streaming", turn_id=42)
|
||||
_render_event_to_log(evt, log=log, raw=False)
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[worker_phase]")
|
||||
assert "phase=streaming" in line
|
||||
def test_multiple_thinking_runs_each_get_richlog_entry(self) -> None:
|
||||
"""multiple_thinking_runs_each_get_richlog_entry [scenario]:
|
||||
Thinking → Text → Thinking → Done → TWO closed thinking RichLog entries.
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
def test_thinking_truncated(self) -> None:
|
||||
"""thinking_truncated [trace]: …"""
|
||||
log = MagicMock()
|
||||
_render_event_to_log(Thinking(sse_id=SID, content="a" * 500), log=log, raw=False)
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[thinking]")
|
||||
assert "a" * 500 not in line
|
||||
assert "a" * 200 in line
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="first"), log=log, thinking_widget=widget, raw=False
|
||||
)
|
||||
state.render(Text(sse_id=SID, content="hi"), log=log, thinking_widget=widget, raw=False)
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="second"), log=log, thinking_widget=widget, raw=False
|
||||
)
|
||||
# Close the second run with a Done.
|
||||
state.render(_make_tui_done(), log=log, thinking_widget=widget, raw=True)
|
||||
# Count closed thinking entries — now dim RichText; plain text starts with "· thinking:".
|
||||
thinking_entries = [_text_of(call[0][0]) for call in log.write.call_args_list]
|
||||
thinking_entries = [t for t in thinking_entries if t.startswith("· thinking:")]
|
||||
assert len(thinking_entries) == 2
|
||||
assert "first" in thinking_entries[0]
|
||||
assert "second" in thinking_entries[1]
|
||||
|
||||
def test_tool_start_renders_label(self) -> None:
|
||||
"""tool_start_renders_label: ToolStart → [tool_start] name=... args=..."""
|
||||
log = MagicMock()
|
||||
evt = ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"})
|
||||
_render_event_to_log(evt, log=log, raw=False)
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[tool_start] name=read_file args=")
|
||||
def test_render_exception_fallback(self) -> None:
|
||||
"""render_exception_fallback [adversarial]:
|
||||
widget.update raises → RichLog gets BOTH a plain-labeled fallback line for
|
||||
the original event AND a `[render_error] <ExceptionClassName>` line
|
||||
(NO exception message per INV-009 security clause); state does NOT propagate.
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
def test_tool_result_truncated(self) -> None:
|
||||
"""tool_result_truncated [trace]: …"""
|
||||
log = MagicMock()
|
||||
evt = ToolResult(sse_id=SID, name="x", result="b" * 500, duration_ms=42)
|
||||
_render_event_to_log(evt, log=log, raw=False)
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[tool_result]")
|
||||
# The whole repr-portion of the result is truncated to 200; the full 500-b
|
||||
# string can never fit in line whole.
|
||||
assert "b" * 500 not in line
|
||||
widget = MagicMock()
|
||||
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
|
||||
state = TuiPresenterState()
|
||||
# Should not raise; should write a fallback labeled line + a [render_error] line.
|
||||
state.render(Thinking(sse_id=SID, content="x"), log=log, thinking_widget=widget, raw=False)
|
||||
writes = [call[0][0] for call in log.write.call_args_list if isinstance(call[0][0], str)]
|
||||
# POST-007: plain-label fallback for the original Thinking event (pre-amendment shape).
|
||||
assert any(w.startswith("[thinking]") for w in writes), writes
|
||||
# POST-007: render_error line with class name ONLY.
|
||||
assert any(w == "[render_error] AttributeError" for w in writes), writes
|
||||
# Critical: exception message MUST NOT appear in any write (INV-009 security).
|
||||
assert not any("widget gone" in w for w in writes), writes
|
||||
|
||||
def test_state_reset_per_worker(self) -> None:
|
||||
"""state_reset_per_worker [trace]: fresh TuiPresenterState() starts no thinking open."""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
s1 = TuiPresenterState()
|
||||
s1.render(
|
||||
Thinking(sse_id=SID, content="x"),
|
||||
log=MagicMock(),
|
||||
thinking_widget=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
s2 = TuiPresenterState()
|
||||
assert s1.thinking_open is True
|
||||
assert s2.thinking_open is False
|
||||
|
||||
def test_cancelled_mid_thinking_closes(self) -> None:
|
||||
"""cancelled_mid_thinking_closes [scenario]:
|
||||
Thinking, Cancelled → ONE closed thinking entry + a [cancelled] entry; widget hidden.
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
def test_text_boundary_renders_label(self) -> None:
|
||||
"""text_boundary_renders_label: TextBoundary → [text_boundary] kind=... char_offset=..."""
|
||||
log = MagicMock()
|
||||
evt = TextBoundary(sse_id=SID, kind="sentence", char_offset=128, ts="2026-05-21T00:00:00Z")
|
||||
_render_event_to_log(evt, log=log, raw=False)
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="partial"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
raw=False,
|
||||
)
|
||||
state.render(
|
||||
Cancelled(
|
||||
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=None
|
||||
),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
raw=False,
|
||||
)
|
||||
# Closed thinking entries are now dim RichText; terminal labels are plain str.
|
||||
writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
||||
assert any(w.startswith("· thinking: partial") for w in writes)
|
||||
assert any(w.startswith("[cancelled]") for w in writes)
|
||||
assert widget.display is False
|
||||
|
||||
def test_done_renders_markdown_after_label(self) -> None:
|
||||
"""done_renders_markdown_after_label [happy]:
|
||||
Text("hi"), Done(response="hi") with raw=False → [done] label, Rule, Markdown in RichLog.
|
||||
"""
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(Text(sse_id=SID, content="hi"), log=log, thinking_widget=widget, raw=False)
|
||||
state.render(_make_tui_done(), log=log, thinking_widget=widget, raw=False)
|
||||
writes = [c[0][0] for c in log.write.call_args_list]
|
||||
# Text stream wrote "hi" with no prefix.
|
||||
assert "hi" in writes
|
||||
# [done] label wrote.
|
||||
assert any(isinstance(w, str) and w.startswith("[done]") for w in writes)
|
||||
# Rule + Markdown render present (post-Done body re-render per issue #4 INV-005).
|
||||
assert any(isinstance(w, Rule) for w in writes)
|
||||
assert any(isinstance(w, Markdown) for w in writes)
|
||||
|
||||
def test_raw_flag_skips_markdown(self) -> None:
|
||||
"""raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown."""
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(Text(sse_id=SID, content="hi"), log=log, thinking_widget=widget, raw=True)
|
||||
state.render(_make_tui_done(), log=log, thinking_widget=widget, raw=True)
|
||||
writes = [c[0][0] for c in log.write.call_args_list]
|
||||
assert not any(isinstance(w, Rule) for w in writes)
|
||||
assert not any(isinstance(w, Markdown) for w in writes)
|
||||
|
||||
def test_worker_phase_demoted(self) -> None:
|
||||
"""worker_phase_demoted [trace]: WorkerPhase → RichLog "· worker_phase:" prefix
|
||||
rendered with dim Rich style (INV-003: dim style + `· ` prefix in TUI).
|
||||
"""
|
||||
from rich.text import Text as RichText
|
||||
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||||
log=log,
|
||||
thinking_widget=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
renderable = log.write.call_args[0][0]
|
||||
# INV-003: must be a dim-styled Rich Text renderable, not a plain str.
|
||||
assert isinstance(renderable, RichText), type(renderable)
|
||||
assert renderable.style == "dim"
|
||||
text = renderable.plain
|
||||
assert text.startswith("· worker_phase:")
|
||||
assert "[worker_phase]" not in text
|
||||
|
||||
def test_terminal_events_belt_and_braces_widget_cleanup(self) -> None:
|
||||
"""terminal_events_belt_and_braces_widget_cleanup [trace]:
|
||||
Done / Error / Cancelled MUST clear+hide the thinking widget even when
|
||||
thinking_open is False (Volva F3 fix; POST-005 + STEPS 5-6).
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
for terminal in (
|
||||
_make_tui_done(),
|
||||
Error(sse_id=SID, phase="failed", message="boom", error_code="x"),
|
||||
Cancelled(
|
||||
sse_id=SID, phase="cancelled", turn_id=42, reason="r", partial_message_id=None
|
||||
),
|
||||
):
|
||||
log = MagicMock()
|
||||
widget = MagicMock()
|
||||
widget.display = True # pre-set to non-default to detect the clear
|
||||
state = TuiPresenterState()
|
||||
# thinking_open is False (state just constructed).
|
||||
state.render(terminal, log=log, thinking_widget=widget, raw=True)
|
||||
# Belt-and-braces: widget cleared + hidden on EVERY terminal event.
|
||||
widget.update.assert_called_with("")
|
||||
assert widget.display is False, type(terminal).__name__
|
||||
|
||||
def test_tool_start_demoted(self) -> None:
|
||||
"""tool_start_demoted [trace]: ToolStart → RichLog line starts with "· tool_start:" """
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
|
||||
log=log,
|
||||
thinking_widget=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
# Demoted telemetry is wrapped in dim RichText; check plain content.
|
||||
assert _text_of(log.write.call_args[0][0]).startswith("· tool_start:")
|
||||
|
||||
def test_text_no_prefix(self) -> None:
|
||||
"""text_no_prefix [trace]: Text → RichLog line has no `·` prefix, no demotion."""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Text(sse_id=SID, content="hello"), log=log, thinking_widget=MagicMock(), raw=False
|
||||
)
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[text_boundary]")
|
||||
assert "kind=sentence" in line
|
||||
assert "char_offset=128" in line
|
||||
# Pure content, no demotion prefix.
|
||||
assert line == "hello"
|
||||
|
||||
def test_duration_format_seconds(self) -> None:
|
||||
"""duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s"."""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
_make_tui_done(duration_ms=5467), log=log, thinking_widget=MagicMock(), raw=True
|
||||
)
|
||||
done_line = next(
|
||||
c[0][0] for c in log.write.call_args_list
|
||||
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
|
||||
)
|
||||
assert "duration=5.5s" in done_line
|
||||
assert "duration_ms=5467" not in done_line
|
||||
|
||||
def test_usage_format_unicode_arrow(self) -> None:
|
||||
"""usage_format_unicode_arrow [trace]: TUI Done label uses → (Unicode), not -> (ASCII)."""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
usage = {
|
||||
"prompt_tokens": 6756,
|
||||
"completion_tokens": 126,
|
||||
"total_tokens": 6882,
|
||||
"cached_input_tokens": 0,
|
||||
}
|
||||
state.render(
|
||||
_make_tui_done(usage=usage), log=log, thinking_widget=MagicMock(), raw=True
|
||||
)
|
||||
done_line = next(
|
||||
c[0][0] for c in log.write.call_args_list
|
||||
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
|
||||
)
|
||||
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
|
||||
|
||||
|
||||
def _text_of(write_arg: object) -> str:
|
||||
"""Extract plain text from a RichLog.write() arg (str or rich.text.Text).
|
||||
|
||||
Issue #12 wraps demoted-telemetry entries in `rich.text.Text(..., style="dim")`
|
||||
so the RichLog can apply dim styling; non-demoted writes stay as plain str.
|
||||
Tests that want to assert against content need both shapes flattened.
|
||||
"""
|
||||
from rich.text import Text as RichText
|
||||
|
||||
if isinstance(write_arg, RichText):
|
||||
return write_arg.plain
|
||||
if isinstance(write_arg, str):
|
||||
return write_arg
|
||||
return "" # Markdown / Rule / etc. — not text content
|
||||
|
||||
|
||||
def _make_tui_done(
|
||||
*, duration_ms: int = 1, usage: dict[str, int] | None = None
|
||||
) -> Done:
|
||||
return Done(
|
||||
sse_id=SID,
|
||||
phase="succeeded",
|
||||
response="r",
|
||||
model="m",
|
||||
duration_ms=duration_ms,
|
||||
usage=usage if usage is not None else {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"cached_input_tokens": 0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestCancelViaSse:
|
||||
@@ -711,17 +985,19 @@ class TestStreamTurnWorker:
|
||||
|
||||
).mock(return_value=_sse_resp(chunks))
|
||||
|
||||
from ratatoskr import tui as tui_mod
|
||||
# Per issue #12: rendering went from stateless _render_event_to_log to
|
||||
# TuiPresenterState.render; the spy moves to the new method.
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
call_count = 0
|
||||
original = tui_mod._render_event_to_log
|
||||
original = TuiPresenterState.render
|
||||
|
||||
def spy(event, *, log, raw):
|
||||
def spy(self, event, **kw): # type: ignore[no-untyped-def]
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return original(event, log=log, raw=raw)
|
||||
return original(self, event, **kw)
|
||||
|
||||
monkeypatch.setattr(tui_mod, "_render_event_to_log", spy)
|
||||
monkeypatch.setattr(TuiPresenterState, "render", spy)
|
||||
app = _resolved_app(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
Reference in New Issue
Block a user