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:
vh
2026-05-23 16:13:55 -07:00
parent 82821561e6
commit 3b9c610587
10 changed files with 1765 additions and 399 deletions
+39 -32
View File
@@ -299,12 +299,12 @@ TESTS:
```contract
FN RatatoskrApp._stream_turn_worker(self, content: str) -> None
BRIEF: Worker coroutine spawned by `on_input_submitted`. Drives `stream_turn`, renders each event into the RichLog via `_render_event_to_log`, captures `active_turn_id` from the first event for the Ctrl-C cancel path, and transitions state back to "idle" after the terminal event (or on a mid-session error).
BRIEF: Worker coroutine spawned by `on_input_submitted`. Drives `stream_turn`, renders each event into the RichLog via a freshly-constructed `TuiPresenterState` instance (issue #12 amendment: was `_render_event_to_log`), captures `active_turn_id` from the first event for the Ctrl-C cancel path, and transitions state back to "idle" after the terminal event (or on a mid-session error).
PRE: [PRE-001 hard] self.state == "streaming" (set by on_input_submitted before spawn) -- assert self.state == "streaming"
PRE: [PRE-002 hard] self.client is not None (set in on_mount) -- assert self.client is not None
PRE: [PRE-003 hard] content is non-empty (caller validated in on_input_submitted) -- assert content
POST: [POST-001 state_change] after terminal event OR error, self.state == "idle"; self.active_turn_id is None; footer hint reset to "Ctrl-C twice to exit"
POST: [POST-002 side_effect] each event passed through _render_event_to_log exactly once (until terminal OR until cancel-induced abort)
POST: [POST-002 side_effect] each event passed through TuiPresenterState.render exactly once (until terminal OR until cancel-induced abort) (issue #12 amendment: was _render_event_to_log)
POST: [POST-003 side_effect] for Done events with NOT args.raw: a separator line + the markdown-rendered Done.response appended to RichLog (INV-005)
POST: [POST-004 state_change] active_turn_id is set to event.sse_id.turn_id on the FIRST yielded event (for cancel_turn use by action_interrupt)
ERROR_ROUTING:
@@ -320,7 +320,7 @@ STEPS:
1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-003
2. [loop, flexibility=prescriptive] TRY: async for event in stream_turn(self.client, self.session_id, content):
IF self.active_turn_id is None: SET self.active_turn_id = event.sse_id.turn_id # POST-004
_render_event_to_log(event, log=self.query_one("#transcript", RichLog), raw=self.args.raw)
presenter.render(event, log=self.query_one("#transcript", RichLog), thinking_widget=self.query_one("#thinking-current", Static), raw=self.args.raw) # issue #12: state-based rendering
IF isinstance(event, Done):
IF NOT self.args.raw:
Append a horizontal-rule separator to RichLog
@@ -341,39 +341,46 @@ TESTS:
sse_connect_failed_returns_to_idle [error]: mock returns 404 → "[sse_connect_failed]" label in RichLog; state → idle; app does NOT exit (INV-008)
connection_dropped_returns_to_idle [error]: mock raises RemoteProtocolError mid-stream → "[connection_dropped]" label; state → idle
malformed_sse_data_returns_to_idle [error,issue#7]: mock yields text + event with `data: not-json` → "[malformed_sse_data]" label; state → idle; app does NOT exit (INV-008)
rendered_event_per_event [trace]: spy on _render_event_to_log; mock yields N events; call_count == N (terminal events included, since Done/Error/Cancelled also render through it)
rendered_event_per_event [trace]: spy on TuiPresenterState.render (issue #12 amendment: was _render_event_to_log); mock yields N events; call_count == N
```
```contract
FN _render_event_to_log(event: Event, *, log: RichLog, raw: bool) -> None
BRIEF: Pure event-to-RichLog renderer. Routes `Text` event deltas (raw text appended to the log) and labels every other Event variant (consistent with cli.py's `_render_event` but writes to a RichLog widget instead of stdout/stderr). The post-Done markdown render is NOT this function's job — it lives in `_stream_turn_worker` so the contract concern (per-event labeling) stays separate from the per-turn concern (post-Done markdown).
PRE: [PRE-001 hard] event is an instance of one of the Event union variants -- assert isinstance(event, (WorkerPhase, Thinking, Text, TextBoundary, ToolStart, ToolResult, Done, Error, Cancelled))
POST: [POST-001 side_effect] for Text events: log received event.content as a streamed delta (no newline appended per delta — RichLog handles chunk-by-chunk display)
POST: [POST-002 side_effect] for non-Text events: log received exactly one labeled line per event
POST: [POST-003 side_effect] Done event renders the same label format as cli.py's `_render_event` (turn_id from sse_id, model, duration_ms, usage); the post-Done markdown render is the caller's responsibility (NOT this function's)
STEPS:
1. [setup, flexibility=prescriptive] Match on type(event)
2. [branch, flexibility=prescriptive] Same case-table as cli._render_event but writing log.write(...) instead of stdout/stderr:
CASE Text: log.write(event.content) (raw text; RichLog handles wrap)
CASE Done: log.write(f"[done] turn_id={event.sse_id.turn_id} model={event.model} duration_ms={event.duration_ms} usage={event.usage!r}")
CASE Error: log.write(f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} message={event.message!r}")
CASE Cancelled: log.write(f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} partial_message_id={event.partial_message_id}")
CASE WorkerPhase: log.write(f"[worker_phase] phase={event.phase} turn_id={event.turn_id}")
CASE Thinking: log.write(f"[thinking] {event.content[:200]!r}")
CASE TextBoundary: log.write(f"[text_boundary] kind={event.kind} char_offset={event.char_offset}")
CASE ToolStart: log.write(f"[tool_start] name={event.name} args={event.arguments!r}")
CASE ToolResult: log.write(f"[tool_result] name={event.name} duration_ms={event.duration_ms} result={event.result!r:.200}")
# Note on {!r:.200}: this is valid Python f-string syntax — `!r` converts via repr(), then `:.200` is the format spec which for strings truncates to 200 chars. The composition yields a repr() that is at most 200 chars long (quotes count). Mirrors cli.py's _render_event for consistency.
CLASS TuiPresenterState # issue #12 amendment
BRIEF: Stateful per-turn presenter for TUI mode. Replaces the stateless `_render_event_to_log` (removed). Owns `thinking_buffer`, `thinking_open`; coalesces thinking-event deltas into per-delta live updates on the dedicated `Static(id="thinking-current")` widget AND one closed RichLog entry per run (two-views-of-thinking decoupling); demotes telemetry events with a `· ` dim prefix on RichLog; Done renders a load-bearing label + Markdown (when not raw); render exceptions degrade to a plain-labeled fallback + `[render_error] <type>` line (NO exception message per INV-009 security).
PROPERTIES:
thinking_buffer: list[str]
thinking_open: bool
INV-WIRE-001: One instance per `_stream_turn_worker` invocation (issue #12 INV-008).
INV-WIRE-002: Two-views-of-thinking decoupling (issue #12 INV-004): per-delta updates → thinking-current Static; closed run → RichLog entry.
```
```contract
FN TuiPresenterState.render(self, event: Event, *, log: RichLog, thinking_widget: Static, raw: bool) -> None # issue #12 amendment
BRIEF: Render one event into the TUI with editorial hierarchy + coalescing per issue #12 INV-001..INV-007 + render-exception fallback per INV-009. Unicode allowed in TUI output (e.g., `· ` U+00B7 prefix, `→` U+2192 arrow in usage). Decoupling: thinking deltas go to `thinking_widget` per-delta; one closed RichLog entry per thinking-run.
PRE: [PRE-001 hard] event is an instance of one of the Event union variants
POST: [POST-001 side_effect] for Thinking: open run (display=True, thinking_open=True) on first delta; append to buffer; update widget with last ~200 chars (… prefix when truncated)
POST: [POST-002 side_effect] for non-Thinking when thinking_open: write ONE RichLog entry `· thinking: <full>`; clear buffer; thinking_open=False; widget cleared + display=False; THEN render the new event
POST: [POST-003 side_effect] for Text: write content to RichLog (no prefix, no demotion)
POST: [POST-004 side_effect] for Done: write `[done] turn_id=... model=... duration={autoscale} usage={p} in → {c} out ({t} total, {ci} cached)`; if NOT raw, append Rule() + Markdown(event.response) per issue #4 INV-005
POST: [POST-005 side_effect] for Error/Cancelled: write the load-bearing label (no demotion); ensure widget cleared+hidden
POST: [POST-006 side_effect] for demoted telemetry (WorkerPhase, TextBoundary, ToolStart, ToolResult): write `· <label>: <fields>` to RichLog
POST: [POST-007 exception] never propagates; on internal exception, write the plain labeled fallback + `[render_error] <ExceptionClassName>` (NO exception message — INV-009 security clause)
TESTS:
text_renders_raw_delta [happy,tracer]: Text(content="hello") → log received "hello" (verify via log.lines or a spy on log.write)
done_renders_label_only [happy]: Done(...) → log line starts with "[done]"; does NOT include the post-Done markdown render (caller's job)
error_renders_label [happy]: Error(...) → log line starts with "[error]"
cancelled_renders_label [happy]: Cancelled(...) → log line starts with "[cancelled]"
worker_phase_renders_label [happy]: WorkerPhase → "[worker_phase]"
thinking_truncated [trace]: Thinking(content="a"*500) → log line shows only first 200 chars in repr
tool_start_renders_label [happy]: ToolStart → "[tool_start]"
tool_result_truncated [trace]: ToolResult(result="b"*500) → repr truncated to ≤200 chars
text_boundary_renders_label [happy]: TextBoundary → "[text_boundary]"
thinking_coalesce_single_widget_update [happy,tracer]: 3 Thinking events → widget.update called 3× with cumulative content; RichLog has 0 thinking entries yet
thinking_closes_one_richlog_entry [happy]: 2× Thinking + WorkerPhase → ONE closed thinking entry + worker_phase entry; widget cleared+hidden
thinking_widget_truncation [trace]: 500-char buffer → widget shows "…" + last 200
thinking_widget_visibility_lifecycle [trace]: hidden initially; visible during run; hidden after closing event
multiple_thinking_runs_each_get_richlog_entry [scenario]: Thinking → Text → Thinking → Done → TWO closed thinking entries
cancelled_mid_thinking_closes [scenario]: Thinking → Cancelled → ONE closed thinking entry + [cancelled]; widget hidden
done_renders_markdown_after_label [happy]: Text + Done(response=...) with NOT raw → [done] line, Rule, Markdown
raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown
worker_phase_demoted [trace]: "· worker_phase:" prefix (not "[worker_phase]")
tool_start_demoted [trace]: "· tool_start:" prefix
text_no_prefix [trace]: Text → no demotion prefix
render_exception_fallback [adversarial]: widget.update raises → fallback line + `[render_error] <ClassName>` (NO message); state does not propagate
state_reset_per_worker [trace]: fresh TuiPresenterState() starts with no thinking open
duration_format_seconds [trace]: Done(duration_ms=5467) → "duration=5.5s"
usage_format_unicode_arrow [trace]: Done → "usage ... in → ... out (...)" (Unicode arrow, not ASCII)
```
```contract