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:
+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