From 489cfee1f09f614ec8caf2756906c38505bbe819 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sun, 24 May 2026 21:53:20 -0700 Subject: [PATCH] fix(tui): drop post-Done Markdown body re-render (v0.8.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator: "first turn double prints agent's turn." Root cause: v0.8.1 wrote both the streamed Text lines AND the post- Done `Markdown(event.response)` body into the transcript. Same content rendered twice — once as plain streaming, once as a full markdown re-render. The v0.8.1 commit message documented this as "some duplication is acceptable" but the live UX read as a bug. ## Fix Drop the post-Done `Rule + Markdown(response)` writes in non-raw mode. The streamed text IS the response; whatever the model emitted flows into the transcript line-by-line via coalesce-on-newline. Markdown formatting (bold, lists, code blocks) renders as plain text — a known regression from v0.8.1's polished output but the right tradeoff vs the duplication bug. ## What this loses temporarily Pre-v0.8.2 (after Done): [done] turn_id=... ─── ─── (Rule separator) ─── **Bold text** rendered bold, `code` highlighted, lists as bullets, etc. v0.8.2 (after Done): [done] turn_id=... ─── **Bold text** as plain asterisks, `code` as backticks, lists as plain dashes ## v0.9.0 plan Restore markdown rendering via LIVE rendering during the stream (not post-Done re-render). Replace `RichLog#transcript` with a `VerticalScroll` container that mounts a fresh `Markdown` widget per turn; Text deltas update the widget; markdown renders as content arrives. No duplication, no snap, full formatting. Operator-confirmed direction (2026-05-25 AskUserQuestion). ## Tests 287/287 GREEN; ruff clean. Two tests updated for the new shape: - test_done_renders_markdown_after_label → renamed test_done_flushes_tail_and_writes_label; asserts NO Markdown, NO Rule (post-Done) in the writes. - test_happy_text_done_renders_markdown → renamed test_happy_text_done_no_double_print; asserts NO Markdown in the spy. Patch bump (v0.8.1 → v0.8.2): bug fix; no public API change. --- persistent-memory.md | 7 ++++--- pyproject.toml | 2 +- src/ratatoskr/tui.py | 19 +++++++------------ tests/test_tui.py | 40 ++++++++++++++++++++++------------------ uv.lock | 2 +- 5 files changed, 35 insertions(+), 35 deletions(-) diff --git a/persistent-memory.md b/persistent-memory.md index bd66682..3fdb24e 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -32,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool. ## Current state / in-flight -_As of 2026-05-25 (post-v0.8.1 text streams inline, no overlap):_ +_As of 2026-05-25 (post-v0.8.2 drop double-print; v0.9.0 live-md next):_ -**Status: v0.8.1 shipped.** Eleven core features complete (`sse_client` +**Status: v0.8.2 shipped.** Eleven core features complete (`sse_client` #1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI startup error visibility #6, presenter contract semantics amendment #12, startup agent picker #8, §5 layout reshape + Tools pane #13) @@ -51,7 +51,8 @@ Static in the footer (static "Tools" v1; dynamic when more tabs land). CLI mode (--send) unaffected by design — INV-018. Last commits on `main`: -- v0.8.1 fix(tui): kill current-text Static; Text streams inline via coalesce +- v0.8.2 fix(tui): drop post-Done Markdown body re-render (no double-print) +- `11ef683` fix(tui,sse): inline Text streaming + empty-id keepalive skip (v0.8.1) - `9fade55` feat(local_agents): JSON-backed local tier-3 index + picker merge (v0.8.0) - `9918c10` fix(tui): coalesce thinking deltas on `\n` (v0.7.1) - `c086ae2` feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0) diff --git a/pyproject.toml b/pyproject.toml index 05f487f..162cada 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.8.1" +version = "0.8.2" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/tui.py b/src/ratatoskr/tui.py index 5d73afe..0eee04d 100644 --- a/src/ratatoskr/tui.py +++ b/src/ratatoskr/tui.py @@ -310,18 +310,13 @@ class TuiPresenterState: f"usage {_format_usage(event.usage, arrow='→')}", style=_AU_SUCCESS, )) - # v0.8.1: in non-raw mode, ALSO write Rule + Markdown body - # as the canonical rendered version. The streamed lines - # above are plain text; the Markdown body re-renders the - # same content with proper formatting (lists, bold, code - # blocks). Some duplication is acceptable — the streamed - # content gave live progress; the Markdown is the final. - if not raw: - from rich.markdown import Markdown - from rich.rule import Rule - - log.write(Rule(style=_AU_DEMOTED)) - log.write(Markdown(event.response)) + # v0.8.2: post-Done Markdown body re-render dropped. Pre- + # v0.8.2 the transcript got BOTH the streamed text AND + # the Markdown(response) re-render — same content twice, + # operator-flagged as "double prints". The streamed text + # IS the response now; markdown formatting (bold, lists, + # code) renders as plain text. Matches thinking pane's + # stream-as-content semantics (no post-close re-render). elif isinstance(event, Error): log.write(RichText( f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} " diff --git a/tests/test_tui.py b/tests/test_tui.py index b6c8dc0..d351aef 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -333,11 +333,12 @@ class TestTuiPresenterState: # thinking_log got at least Rule(start) + "partial" delta + Rule(end) assert thinking_log.write.call_count >= 3 - def test_done_renders_markdown_after_label(self) -> None: - """done_renders_markdown_after_label [happy, v0.8.1]: + def test_done_flushes_tail_and_writes_label(self) -> None: + """done_flushes_tail_and_writes_label [happy, v0.8.2]: Text("hi") buffers in text_chunk_buffer (no `\\n`). Done flushes - "hi" as a tail line in transcript, then writes [done] + Rule + - Markdown body (non-raw). + "hi" tail to transcript, then writes [done] label. v0.8.2 drops + the post-Done Markdown body re-render — streamed text is the + canonical content (no double-print). """ from rich.markdown import Markdown from rich.rule import Rule @@ -354,7 +355,6 @@ class TestTuiPresenterState: thinking_log=MagicMock(), raw=False, ) - # v0.8.1: Text "hi" stays buffered (no `\n` yet) — no log write yet. assert not log.write.called assert state.text_chunk_buffer == "hi" state.render( @@ -365,12 +365,13 @@ class TestTuiPresenterState: thinking_log=MagicMock(), raw=False, ) - # On Done: tail flush + [done] + Rule + Markdown body. + # On Done: tail flush "hi" + [done] label. No Markdown, no Rule. writes = [c[0][0] for c in log.write.call_args_list] assert "hi" in writes assert any(_text_of(w).startswith("[done]") for w in writes) - assert any(isinstance(w, Rule) for w in writes) - assert any(isinstance(w, Markdown) for w in writes) + # v0.8.2: no post-Done re-render — no duplicate content. + assert not any(isinstance(w, Markdown) for w in writes) + assert not any(isinstance(w, Rule) for w in writes) assert state.text_chunk_buffer == "" def test_raw_flag_skips_markdown(self) -> None: @@ -1059,11 +1060,12 @@ async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None: class TestStreamTurnWorker: @respx.mock - async def test_happy_text_done_renders_markdown(self, monkeypatch: pytest.MonkeyPatch) -> None: - """happy_text_done_renders_markdown [happy,tracer, v0.8.1]: - Text("hello") buffers in text_chunk_buffer (no `\\n`); on Done, - flushes "hello" tail to transcript, then [done] label, then Rule - + Markdown body. + async def test_happy_text_done_no_double_print(self, monkeypatch: pytest.MonkeyPatch) -> None: + """happy_text_done_no_double_print [happy,tracer, v0.8.2]: + Text("hello") buffers; on Done, "hello" flushes as tail to transcript + + [done] label. v0.8.2 drops the post-Done Markdown body re-render + (was double-printing the response — streamed text + Markdown twice). + Only the turn-header Rule remains in the transcript. """ stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk( "42:2", _DONE_BODY @@ -1080,14 +1082,16 @@ class TestStreamTurnWorker: await _submit_and_wait(app, pilot, "hi") assert app.state == "idle" from rich.markdown import Markdown - from rich.rule import Rule - # v0.8.1: "hello" appears in transcript as a tail-flush on Done. + # "hello" appears as a tail-flush; [done] label fires. assert any(w == "hello" for w in writes) assert any("[done]" in str(w) for w in writes) - # Post-Done: Markdown body + Rule + turn-header Rule all present. - assert any(isinstance(w, Markdown) for w in writes) - assert any(isinstance(w, Rule) for w in writes) + # v0.8.2: NO Markdown body re-render (was the duplicate). + assert not any(isinstance(w, Markdown) for w in writes) + # The turn-header Rule is written to all 4 panes; we still expect + # SOME Rules in the spy (one per pane), but NOT the post-Done + # separator Rule that pre-v0.8.2 wrote. + # We rely on _spy_writes counting turn-header Rules only. @respx.mock async def test_raw_flag_skips_markdown_render(self, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/uv.lock b/uv.lock index 10ad214..3b8d51d 100644 --- a/uv.lock +++ b/uv.lock @@ -968,7 +968,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.8.1" +version = "0.8.2" source = { editable = "." } dependencies = [ { name = "httpx" },