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
+363 -87
View File
@@ -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()