Compare commits

..

2 Commits

Author SHA1 Message Date
vh 489cfee1f0 fix(tui): drop post-Done Markdown body re-render (v0.8.2)
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.
2026-05-24 21:53:20 -07:00
vh 11ef6830ab fix(tui,sse): inline Text streaming + empty-id keepalive skip (v0.8.1)
Two related fixes for the same user-reported bug pattern from a
running session against ratatoskr:sindra (qwen3.6-35-a3b-heretic):

## 1. Streaming text overlapping the transcript

Operator: "new text comes at the bottom and overwrites the existing
pane information instead of pushing it up naturally."

Root cause: the v0.6.0 `#current-text` Static was `dock: bottom`
with `height: auto`, sitting between the transcript RichLog (1fr)
and the prompt Input (dock: bottom). As text streamed, the Static
grew UPWARD but Textual didn't dynamically resize the 1fr transcript
to accommodate — the growing Static visually OVERLAPPED the
transcript's bottom rows. On Done, `current_text.update("")` snapped
it to height 0 and the transcript re-laid-out — "boom, everything
updates."

Fix: remove `#current-text` Static entirely. Apply the same
coalesce-on-newline pattern v0.7.1 used for thinking — Text deltas
accumulate in `TuiPresenterState.text_chunk_buffer`, flushing whole
lines (each `\n` boundary) directly to `log` (transcript). On Done:
flush remaining tail, then [done] label + Rule + Markdown body.

Trade-off accepted: streamed lines + post-Done Markdown body are
both in the transcript (some content duplication). The Markdown
body re-renders the same content with proper formatting (lists,
bold, code blocks). Acceptable — operator gets both the live-progress
streaming AND the canonical rendered version.

## 2. MalformedSseId raw='' crashing every turn

Operator: "current session is erroring on every turn with
[malformed_sse_id] raw=''"

Worldtree's qwen3.6-35-a3b-heretic provider emits some events
without `id:` lines (observed 2026-05-25 mid-stream). When the FIRST
such event arrives before any prior id has been seen, httpx_sse's
`ServerSentEvent.id` is `""`. `_parse_sse_id('')` raised ValueError
→ MalformedSseId → turn worker bailed → operator saw the label
every turn.

Per SSE RFC, events without `id:` are legitimate (they just don't
update Last-Event-ID). Issue #7 already covered the empty-DATA
keepalive case with skip-silently semantics. Empty-id is the same
shape of wire weirdness; same fix shape:

  if sse.id == "":
      continue  # treat as keepalive

Ordered AFTER the empty-data branch so an empty-data + empty-id
event still gets skipped on the data check.

## Tests + smoke

287/287 GREEN (was 286, +1 for empty-id skip; +1 net Text-flow test
adjustments). Ruff clean.

Verified Worldtree alive when the user hit the empty-id bug
(/healthz returned ok in 18ms) — not a server-down issue, just
wire-format mid-stream.

## Caveats

The fix doesn't recover content from the dropped empty-id event.
If the event happened to carry meaningful data (not a true
keepalive), we silently lose it. Acceptable trade-off: pre-v0.8.1
EVERY turn died on the offending agent; post-v0.8.1 the turn
continues and any single dropped frame is recoverable from logs if
debugging. Worldtree-side fix (always emit ids) is the right
upstream answer; ratatoskr just stops panicking on wire weirdness.

Patch bump (v0.8.0 → v0.8.1) — both fixes are bug fixes; no public
API change. The `TuiPresenterState.render` signature loses the
`current_text` parameter (was added v0.6.0), but presenter is an
internal contract; no external callers.
2026-05-24 21:39:02 -07:00
7 changed files with 169 additions and 131 deletions
+5 -3
View File
@@ -32,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool.
## Current state / in-flight ## Current state / in-flight
_As of 2026-05-25 (post-v0.8.0 local tier-3 agent index in picker):_ _As of 2026-05-25 (post-v0.8.2 drop double-print; v0.9.0 live-md next):_
**Status: v0.8.0 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 #1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
startup error visibility #6, presenter contract semantics amendment startup error visibility #6, presenter contract semantics amendment
#12, startup agent picker #8, §5 layout reshape + Tools pane #13) #12, startup agent picker #8, §5 layout reshape + Tools pane #13)
@@ -51,7 +51,9 @@ Static in the footer (static "Tools" v1; dynamic when more tabs
land). CLI mode (--send) unaffected by design — INV-018. land). CLI mode (--send) unaffected by design — INV-018.
Last commits on `main`: Last commits on `main`:
- v0.8.0 feat(local_agents): JSON-backed local tier-3 index + picker merge - 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) - `9918c10` fix(tui): coalesce thinking deltas on `\n` (v0.7.1)
- `c086ae2` feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0) - `c086ae2` feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0)
- `d356990` refactor(tui): thinking streams into thinking-log (v0.6.5) - `d356990` refactor(tui): thinking streams into thinking-log (v0.6.5)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ratatoskr" name = "ratatoskr"
version = "0.8.0" version = "0.8.2"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+8
View File
@@ -309,6 +309,14 @@ async def _iter_events(
# with a bad id is still a keepalive). Don't reorder. # with a bad id is still a keepalive). Don't reorder.
if sse.data == "": if sse.data == "":
continue continue
# v0.8.1: empty-id frames are also treated as keepalives. Worldtree
# SOMETIMES emits events without an `id:` line (observed mid-stream
# on the qwen3.6-35-a3b-heretic provider, 2026-05-25). Per the SSE
# RFC, events without ids are legitimate (they just don't update
# Last-Event-ID); the previous strict behavior crashed every turn
# on the offending agent. Treat same as empty-data: skip silently.
if sse.id == "":
continue
try: try:
sse_id = _parse_sse_id(sse.id) sse_id = _parse_sse_id(sse.id)
except ValueError as exc: except ValueError as exc:
+40 -50
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import asyncio import asyncio
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import ClassVar, Literal from typing import ClassVar, Literal
import httpx import httpx
@@ -186,9 +186,6 @@ class TuiPresenterState:
""" """
thinking_open: bool = False thinking_open: bool = False
# v0.6.0: per-turn streaming text buffer. Text deltas accumulate here
# and update `current_text` Static in place — no per-token RichLog spam.
text_buffer: list[str] = field(default_factory=list)
# Thinking-run counter for turn-scoped start/end markers. # Thinking-run counter for turn-scoped start/end markers.
thinking_run_index: int = 0 thinking_run_index: int = 0
# v0.7.1: thinking-content accumulator. Worldtree emits Thinking deltas # v0.7.1: thinking-content accumulator. Worldtree emits Thinking deltas
@@ -197,13 +194,20 @@ class TuiPresenterState:
# only on `\n` boundaries (one written line per natural paragraph) or # only on `\n` boundaries (one written line per natural paragraph) or
# when the run closes (any leftover tail). # when the run closes (any leftover tail).
thinking_chunk_buffer: str = "" thinking_chunk_buffer: str = ""
# v0.8.1: same pattern for Text deltas. Pre-v0.8.1 the Text deltas
# streamed into a dedicated #current-text Static below the transcript;
# that Static (docked-bottom, height: auto) grew during streaming and
# visually OVERLAPPED the transcript above (Textual didn't dynamically
# resize the 1fr transcript while the dock-bottom child expanded).
# The Static is gone in v0.8.1 — Text deltas coalesce on `\n` and write
# directly to `log` (transcript), the same shape thinking uses.
text_chunk_buffer: str = ""
def render( def render(
self, self,
event: Event, event: Event,
*, *,
log: RichLog, log: RichLog,
current_text: Static,
tools_log: RichLog, tools_log: RichLog,
debug_log: RichLog, debug_log: RichLog,
thinking_log: RichLog, thinking_log: RichLog,
@@ -211,18 +215,14 @@ class TuiPresenterState:
) -> None: ) -> None:
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing. """Render one Worldtree SSE event with the TUI hierarchy + coalescing.
v0.6.5 routing: v0.8.1 routing:
- `log` (transcript) = content only: user-prompt echo (written - `log` (transcript) = chat content: user-prompt echo (written
outside the presenter), terminal labels, post-Done Markdown body. outside the presenter), coalesced Text deltas, terminal labels,
- `current_text` (Static below transcript) = live-streaming Text optional post-Done Markdown body.
deltas accumulated into one growing line; cleared on terminal.
- `tools_log` = ToolStart + ToolResult. - `tools_log` = ToolStart + ToolResult.
- `debug_log` = WorkerPhase + TextBoundary. - `debug_log` = WorkerPhase + TextBoundary.
- `thinking_log` = streaming Thinking deltas inline (each chunk = - `thinking_log` = streaming Thinking deltas inline (coalesced on
one line in the scrollable log). Rule(start)/Rule(end) markers `\n`). Rule(start)/Rule(end) wrap each run.
wrap each run. The whole pane scrolls naturally — no separate
tail-scrolling Static at the bottom (v0.6.5 removed
`thinking-current`).
Exceptions caught at the presenter boundary (INV-009 fallback). Exceptions caught at the presenter boundary (INV-009 fallback).
""" """
@@ -284,19 +284,23 @@ class TuiPresenterState:
self.thinking_open = False self.thinking_open = False
# Now render the non-thinking event itself. # Now render the non-thinking event itself.
if isinstance(event, Text): if isinstance(event, Text):
# v0.6.0: streaming text accumulates into current_text Static # v0.8.1: stream Text deltas into transcript directly,
# — one growing live line, NOT per-delta RichLog entries. # coalesced on `\n`. Same pattern as Thinking (v0.7.1).
self.text_buffer.append(event.content) # The pre-v0.8.1 #current-text Static is gone — its dock-
current_text.update("".join(self.text_buffer)) # bottom growth was overlapping the transcript visually.
self.text_chunk_buffer += event.content
while "\n" in self.text_chunk_buffer:
line, _, rest = self.text_chunk_buffer.partition("\n")
if line:
log.write(line)
self.text_chunk_buffer = rest
return return
if isinstance(event, (Done, Error, Cancelled)): if isinstance(event, (Done, Error, Cancelled)):
# Terminal event: clear the streaming Static first so the # Terminal event: flush any remaining text tail before the
# live-preview band collapses. Then write the colored label # label / Markdown body lands.
# + (non-raw) Markdown body / (raw) accumulated plain text if self.text_chunk_buffer:
# to the transcript. log.write(self.text_chunk_buffer)
accumulated = "".join(self.text_buffer) self.text_chunk_buffer = ""
self.text_buffer.clear()
current_text.update("")
# Terminal labels tinted per outcome (Aurora green / Dawn red # Terminal labels tinted per outcome (Aurora green / Dawn red
# / Dawn yellow) for at-a-glance scanning. # / Dawn yellow) for at-a-glance scanning.
if isinstance(event, Done): if isinstance(event, Done):
@@ -306,17 +310,13 @@ class TuiPresenterState:
f"usage {_format_usage(event.usage, arrow='')}", f"usage {_format_usage(event.usage, arrow='')}",
style=_AU_SUCCESS, style=_AU_SUCCESS,
)) ))
if raw: # v0.8.2: post-Done Markdown body re-render dropped. Pre-
# Raw mode: emit the accumulated streamed text verbatim # v0.8.2 the transcript got BOTH the streamed text AND
# so the operator has a record after the Static clears. # the Markdown(response) re-render — same content twice,
if accumulated: # operator-flagged as "double prints". The streamed text
log.write(accumulated) # IS the response now; markdown formatting (bold, lists,
else: # code) renders as plain text. Matches thinking pane's
from rich.markdown import Markdown # stream-as-content semantics (no post-close re-render).
from rich.rule import Rule
log.write(Rule(style=_AU_DEMOTED))
log.write(Markdown(event.response))
elif isinstance(event, Error): elif isinstance(event, Error):
log.write(RichText( log.write(RichText(
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} " f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
@@ -569,16 +569,9 @@ class RatatoskrApp(App[int]):
background: $background; background: $background;
padding: 0 1; padding: 0 1;
} }
/* v0.6.0: streaming-text Static carries in-flight assistant tokens. /* v0.8.1: #current-text Static removed. Streaming text now coalesces
Replaces per-token RichLog spam — one growing line that updates in on `\n` and writes directly to #transcript (same pattern as v0.7.1
place. Cleared on terminal event; final Markdown body lands in the thinking fix). Eliminates the dock-bottom-growth-overlap bug. */
transcript. */
#current-text {
dock: bottom;
height: auto;
background: $background;
padding: 0 1;
}
#tools-log, #debug-log, #thinking-log { #tools-log, #debug-log, #thinking-log {
background: $background; background: $background;
padding: 0 1; padding: 0 1;
@@ -678,7 +671,6 @@ class RatatoskrApp(App[int]):
with Horizontal(id="main-row"): with Horizontal(id="main-row"):
with Vertical(id="left-column"): with Vertical(id="left-column"):
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False) yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
yield Static("", id="current-text")
yield Input(id="prompt", placeholder="Type a message and press Enter") yield Input(id="prompt", placeholder="Type a message and press Enter")
with Vertical(id="right-column"): with Vertical(id="right-column"):
with TabbedContent(id="side-panes"): with TabbedContent(id="side-panes"):
@@ -798,7 +790,6 @@ class RatatoskrApp(App[int]):
assert self.client is not None assert self.client is not None
assert content assert content
log = self.query_one("#transcript", RichLog) log = self.query_one("#transcript", RichLog)
current_text = self.query_one("#current-text", Static)
tools_log = self.query_one("#tools-log", RichLog) tools_log = self.query_one("#tools-log", RichLog)
debug_log = self.query_one("#debug-log", RichLog) debug_log = self.query_one("#debug-log", RichLog)
thinking_log = self.query_one("#thinking-log", RichLog) thinking_log = self.query_one("#thinking-log", RichLog)
@@ -814,7 +805,6 @@ class RatatoskrApp(App[int]):
presenter.render( presenter.render(
event, event,
log=log, log=log,
current_text=current_text,
tools_log=tools_log, tools_log=tools_log,
debug_log=debug_log, debug_log=debug_log,
thinking_log=thinking_log, thinking_log=thinking_log,
+41
View File
@@ -711,6 +711,47 @@ def _sse_raw_chunk(sse_id: str, raw_data: str) -> bytes:
return f"id: {sse_id}\ndata: {raw_data}\n\n".encode() return f"id: {sse_id}\ndata: {raw_data}\n\n".encode()
def _sse_no_id_chunk(data: str) -> bytes:
"""SSE frame with NO id line + arbitrary data (v0.8.1: keepalive shape)."""
return f"data: {data}\n\n".encode()
class TestEmptyIdSkipped:
@respx.mock
async def test_empty_id_on_first_event_skipped(self) -> None:
"""empty_id_on_first_event_skipped [v0.8.1]: stream starts with an
event carrying NO `id:` line → httpx_sse exposes sse.id == ''
(no prior id to inherit). Pre-v0.8.1: MalformedSseId raw='' crashed
the turn. v0.8.1: treat same as empty-data keepalive — skip silently.
Observed 2026-05-25 on Worldtree's qwen3.6-35-a3b-heretic provider:
the first stream frame had no id line, every turn died with
`[malformed_sse_id] raw=''`.
"""
from ratatoskr.sse_client import Done as _Done
from ratatoskr.sse_client import Text as _Text
# First frame: no id line (httpx_sse → sse.id = ""). Skip it.
# Subsequent frames have ids; normal processing resumes.
stream = (
_sse_no_id_chunk('{"type":"keepalive"}') # ← skipped (sse.id == "")
+ _sse_chunk("42:1", {"type": "text", "content": "first"})
+ _sse_chunk("42:2", _DONE_42_6)
)
respx.post("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=stream
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_turn(client, "s1", "hi")]
# 2 events — the no-id frame is invisible (no MalformedSseId crash).
assert len(events) == 2
assert isinstance(events[0], _Text)
assert events[0].content == "first"
assert isinstance(events[1], _Done)
class TestEmptyDataSkipped: class TestEmptyDataSkipped:
@respx.mock @respx.mock
async def test_empty_data_skipped(self) -> None: async def test_empty_data_skipped(self) -> None:
+73 -76
View File
@@ -135,7 +135,6 @@ class TestTuiPresenterState:
log=log, log=log,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(), debug_log=MagicMock(),
current_text=MagicMock(),
thinking_log=thinking_log, thinking_log=thinking_log,
raw=False, raw=False,
) )
@@ -160,7 +159,6 @@ class TestTuiPresenterState:
log=MagicMock(), log=MagicMock(),
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(), debug_log=MagicMock(),
current_text=MagicMock(),
thinking_log=thinking_log, thinking_log=thinking_log,
raw=False, raw=False,
) )
@@ -190,7 +188,6 @@ class TestTuiPresenterState:
log=log, log=log,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log, debug_log=debug_log,
current_text=MagicMock(),
thinking_log=thinking_log, thinking_log=thinking_log,
raw=False, raw=False,
) )
@@ -199,7 +196,6 @@ class TestTuiPresenterState:
log=log, log=log,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log, debug_log=debug_log,
current_text=MagicMock(),
thinking_log=thinking_log, thinking_log=thinking_log,
raw=False, raw=False,
) )
@@ -217,10 +213,12 @@ class TestTuiPresenterState:
# and test_thinking_widget_visibility_lifecycle deleted (no longer apply). # and test_thinking_widget_visibility_lifecycle deleted (no longer apply).
def test_multiple_thinking_runs_each_get_thinking_log_section(self) -> None: def test_multiple_thinking_runs_each_get_thinking_log_section(self) -> None:
"""multiple_thinking_runs_each_get_section [scenario, v0.6.5]: """multiple_thinking_runs_each_get_section [scenario, v0.8.1]:
Thinking → Text → Thinking → Done → TWO start/end Rule pairs in Thinking → Text → Thinking → Done → TWO start/end Rule pairs in
thinking_log, each wrapping their delta lines. Text goes to thinking_log (deltas coalesced into tail-flushes per run).
current_text (buffered). Transcript: [done] + Markdown body. Text deltas now stream into the transcript via coalesce-on-newline
(no current-text Static); "hi" with no `\\n` stays buffered until
Done's tail-flush.
""" """
from rich.rule import Rule from rich.rule import Rule
@@ -228,7 +226,6 @@ class TestTuiPresenterState:
log = MagicMock() log = MagicMock()
thinking_log = MagicMock() thinking_log = MagicMock()
current_text = MagicMock()
state = TuiPresenterState() state = TuiPresenterState()
for evt in ( for evt in (
Thinking(sse_id=SID, content="first"), Thinking(sse_id=SID, content="first"),
@@ -238,25 +235,24 @@ class TestTuiPresenterState:
state.render( state.render(
evt, log=log, evt, log=log,
tools_log=MagicMock(), debug_log=MagicMock(), tools_log=MagicMock(), debug_log=MagicMock(),
current_text=current_text, thinking_log=thinking_log, raw=False, thinking_log=thinking_log, raw=False,
) )
state.render( state.render(
_make_tui_done(), _make_tui_done(),
log=log, log=log,
tools_log=MagicMock(), debug_log=MagicMock(), tools_log=MagicMock(), debug_log=MagicMock(),
current_text=current_text, thinking_log=thinking_log, raw=False, thinking_log=thinking_log, raw=False,
) )
# v0.6.5: thinking_log holds 4 Rules (start + end per run) + 2 delta lines. # thinking_log: 4 Rules (start+end per run) + 2 tail-flush strings.
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list] thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
rules = [w for w in thinking_writes if isinstance(w, Rule)] rules = [w for w in thinking_writes if isinstance(w, Rule)]
delta_strs = [w for w in thinking_writes if isinstance(w, str)] delta_strs = [w for w in thinking_writes if isinstance(w, str)]
assert len(rules) == 4, f"expected 4 Rules (2 start + 2 end), got {len(rules)}" assert len(rules) == 4, f"expected 4 Rules (2 start + 2 end), got {len(rules)}"
assert "first" in delta_strs assert "first" in delta_strs
assert "second" in delta_strs assert "second" in delta_strs
# Text "hi" went to current_text (buffered), not the transcript directly. # v0.8.1: Text "hi" flushes as a line in transcript on Done.
current_text.update.assert_any_call("hi")
# Transcript: [done] label + Markdown(response) (raw=False).
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list] log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
assert "hi" in log_writes
assert any(w.startswith("[done]") for w in log_writes if isinstance(w, str)) assert any(w.startswith("[done]") for w in log_writes if isinstance(w, str))
def test_render_exception_fallback(self) -> None: def test_render_exception_fallback(self) -> None:
@@ -281,7 +277,6 @@ class TestTuiPresenterState:
log=log, log=log,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(), debug_log=MagicMock(),
current_text=MagicMock(),
thinking_log=thinking_log, thinking_log=thinking_log,
raw=False, raw=False,
) )
@@ -301,7 +296,6 @@ class TestTuiPresenterState:
log=MagicMock(), log=MagicMock(),
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(), debug_log=MagicMock(),
current_text=MagicMock(),
thinking_log=MagicMock(), thinking_log=MagicMock(),
raw=False, raw=False,
) )
@@ -323,8 +317,7 @@ class TestTuiPresenterState:
Thinking(sse_id=SID, content="partial"), Thinking(sse_id=SID, content="partial"),
log=log, log=log,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(), debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
) )
state.render( state.render(
Cancelled( Cancelled(
@@ -332,8 +325,7 @@ class TestTuiPresenterState:
), ),
log=log, log=log,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(), debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
) )
# v0.6.5: streamed thinking + Rule(end) in thinking_log; [cancelled] in transcript. # v0.6.5: streamed thinking + Rule(end) in thinking_log; [cancelled] in transcript.
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list] log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
@@ -341,11 +333,12 @@ class TestTuiPresenterState:
# thinking_log got at least Rule(start) + "partial" delta + Rule(end) # thinking_log got at least Rule(start) + "partial" delta + Rule(end)
assert thinking_log.write.call_count >= 3 assert thinking_log.write.call_count >= 3
def test_done_renders_markdown_after_label(self) -> None: def test_done_flushes_tail_and_writes_label(self) -> None:
"""done_renders_markdown_after_label [happy, v0.6.0]: """done_flushes_tail_and_writes_label [happy, v0.8.2]:
Text("hi") accumulates into current_text Static (buffered streaming); Text("hi") buffers in text_chunk_buffer (no `\\n`). Done flushes
Done(response="hi") with raw=False → [done] label + Rule + Markdown "hi" tail to transcript, then writes [done] label. v0.8.2 drops
in transcript. current_text cleared on terminal. the post-Done Markdown body re-render — streamed text is the
canonical content (no double-print).
""" """
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.rule import Rule from rich.rule import Rule
@@ -353,34 +346,33 @@ class TestTuiPresenterState:
from ratatoskr.tui import TuiPresenterState from ratatoskr.tui import TuiPresenterState
log = MagicMock() log = MagicMock()
current_text = MagicMock()
state = TuiPresenterState() state = TuiPresenterState()
state.render( state.render(
Text(sse_id=SID, content="hi"), Text(sse_id=SID, content="hi"),
log=log, log=log,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(), debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(), thinking_log=MagicMock(),
raw=False, raw=False,
) )
# Text accumulated to current_text, NOT written to log. assert not log.write.called
current_text.update.assert_any_call("hi") assert state.text_chunk_buffer == "hi"
state.render( state.render(
_make_tui_done(), _make_tui_done(),
log=log, log=log,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(), debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(), thinking_log=MagicMock(),
raw=False, raw=False,
) )
# Done cleared current_text and wrote [done] label + Rule + Markdown. # On Done: tail flush "hi" + [done] label. No Markdown, no Rule.
current_text.update.assert_any_call("")
writes = [c[0][0] for c in log.write.call_args_list] 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(_text_of(w).startswith("[done]") for w in writes)
assert any(isinstance(w, Rule) for w in writes) # v0.8.2: no post-Done re-render — no duplicate content.
assert any(isinstance(w, Markdown) for w in writes) 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: def test_raw_flag_skips_markdown(self) -> None:
"""raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown.""" """raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown."""
@@ -395,15 +387,13 @@ class TestTuiPresenterState:
Text(sse_id=SID, content="hi"), Text(sse_id=SID, content="hi"),
log=log, log=log,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
) )
state.render( state.render(
_make_tui_done(), _make_tui_done(),
log=log, log=log,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
) )
writes = [c[0][0] for c in log.write.call_args_list] 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, Rule) for w in writes)
@@ -425,8 +415,7 @@ class TestTuiPresenterState:
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42), WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log, log=log,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log, debug_log=debug_log, thinking_log=MagicMock(), raw=False,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
) )
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript. # v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
assert not log.write.called assert not log.write.called
@@ -461,8 +450,7 @@ class TestTuiPresenterState:
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}), ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
log=log, log=log,
tools_log=tools_log, tools_log=tools_log,
debug_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
) )
# INV-014: write went to tools_log # INV-014: write went to tools_log
assert tools_log.write.called assert tools_log.write.called
@@ -481,56 +469,55 @@ class TestTuiPresenterState:
ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12), ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12),
log=log, log=log,
tools_log=tools_log, tools_log=tools_log,
debug_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
) )
assert tools_log.write.called assert tools_log.write.called
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:") assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
assert not log.write.called assert not log.write.called
def test_text_event_buffers_into_current_text(self) -> None: def test_text_event_buffers_until_newline(self) -> None:
"""text_event_buffers_into_current_text [v0.6.0]: Text → current_text Static """text_event_buffers_until_newline [v0.8.1]: Text deltas without
(accumulated), NOT log or tools_log. Streaming UX fix — no per-token spam. `\\n` accumulate in text_chunk_buffer; no log write yet.
""" """
from ratatoskr.tui import TuiPresenterState from ratatoskr.tui import TuiPresenterState
log = MagicMock() log = MagicMock()
tools_log = MagicMock() tools_log = MagicMock()
current_text = MagicMock()
state = TuiPresenterState() state = TuiPresenterState()
state.render( state.render(
Text(sse_id=SID, content="hello"), Text(sse_id=SID, content="hello"),
log=log, log=log,
tools_log=tools_log, tools_log=tools_log,
debug_log=MagicMock(), debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(), thinking_log=MagicMock(),
raw=False, raw=False,
) )
current_text.update.assert_called_once_with("hello") # v0.8.1: buffered, not written until `\n` or Done.
assert state.text_chunk_buffer == "hello"
assert not log.write.called assert not log.write.called
assert not tools_log.write.called assert not tools_log.write.called
def test_text_deltas_accumulate(self) -> None: def test_text_flushes_on_newline(self) -> None:
"""text_deltas_accumulate [v0.6.0]: multiple Text deltas → current_text shows """text_flushes_on_newline [v0.8.1]: a delta carrying `\\n` flushes
concatenated content, NOT separate per-delta lines. the accumulated buffer as ONE line to log (transcript).
""" """
from ratatoskr.tui import TuiPresenterState from ratatoskr.tui import TuiPresenterState
current_text = MagicMock() log = MagicMock()
state = TuiPresenterState() state = TuiPresenterState()
for tok in ("Hel", "lo", " ", "world"): for tok in ("Hel", "lo", " ", "world", "\n"):
state.render( state.render(
Text(sse_id=SID, content=tok), Text(sse_id=SID, content=tok),
log=MagicMock(), log=log,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(), debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(), thinking_log=MagicMock(),
raw=False, raw=False,
) )
# Final update reflects the full concatenation. writes = [c[0][0] for c in log.write.call_args_list]
assert current_text.update.call_args_list[-1][0][0] == "Hello world" # "Hello world" coalesces to ONE log entry.
assert writes == ["Hello world"]
assert state.text_chunk_buffer == ""
def test_duration_format_seconds(self) -> None: def test_duration_format_seconds(self) -> None:
"""duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s".""" """duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s"."""
@@ -542,8 +529,7 @@ class TestTuiPresenterState:
_make_tui_done(duration_ms=5467), _make_tui_done(duration_ms=5467),
log=log, log=log,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
) )
done_line = next( done_line = next(
_text_of(c[0][0]) _text_of(c[0][0])
@@ -569,8 +555,7 @@ class TestTuiPresenterState:
_make_tui_done(usage=usage), _make_tui_done(usage=usage),
log=log, log=log,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
) )
done_line = next( done_line = next(
_text_of(c[0][0]) _text_of(c[0][0])
@@ -857,7 +842,8 @@ class TestLayoutShape:
log=log, log=log,
tools_log=app.query_one("#tools-log", RichLog), tools_log=app.query_one("#tools-log", RichLog),
debug_log=app.query_one("#debug-log", RichLog), debug_log=app.query_one("#debug-log", RichLog),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True, thinking_log=MagicMock(),
raw=True,
) )
done = next( done = next(
c for c in seen c for c in seen
@@ -1074,10 +1060,12 @@ async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None:
class TestStreamTurnWorker: class TestStreamTurnWorker:
@respx.mock @respx.mock
async def test_happy_text_done_renders_markdown(self, monkeypatch: pytest.MonkeyPatch) -> None: async def test_happy_text_done_no_double_print(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""happy_text_done_renders_markdown [happy,tracer, v0.6.0]: """happy_text_done_no_double_print [happy,tracer, v0.8.2]:
Text deltas go to current_text (not transcript); on Done, transcript Text("hello") buffers; on Done, "hello" flushes as tail to transcript
gets turn-header Rule, [done] label, post-Done Rule + Markdown body. + [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( stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk(
"42:2", _DONE_BODY "42:2", _DONE_BODY
@@ -1093,16 +1081,17 @@ class TestStreamTurnWorker:
await pilot.pause() await pilot.pause()
await _submit_and_wait(app, pilot, "hi") await _submit_and_wait(app, pilot, "hi")
assert app.state == "idle" assert app.state == "idle"
# v0.6.0: Text("hello") goes to current_text Static, NOT log.
# writes spy captures RichLog.write only, so "hello" SHOULD NOT appear.
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.rule import Rule
assert not any(w == "hello" for w in writes) # "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) assert any("[done]" in str(w) for w in writes)
# Post-Done: Markdown body + Rule + turn-header Rule all present. # v0.8.2: NO Markdown body re-render (was the duplicate).
assert any(isinstance(w, Markdown) for w in writes) assert not any(isinstance(w, Markdown) for w in writes)
assert any(isinstance(w, Rule) 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 @respx.mock
async def test_raw_flag_skips_markdown_render(self, monkeypatch: pytest.MonkeyPatch) -> None: async def test_raw_flag_skips_markdown_render(self, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -2254,8 +2243,16 @@ class TestResolveThenRunWithPicker:
self, self,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str], capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None: ) -> None:
"""list_agents returns [] → stderr [no_agents]; exit 13; picker NOT opened.""" """list_agents returns [] AND no local tier-3 entries → stderr
[no_agents]; exit 13; picker NOT opened. Isolate
$RATATOSKR_LOCAL_AGENTS so the operator's real local index
doesn't merge in and turn this into a non-empty list."""
# v0.8.0 isolation: point local agents at an empty tmp file.
monkeypatch.setenv(
"RATATOSKR_LOCAL_AGENTS", str(tmp_path / "empty_local_agents.json")
)
respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[])) respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[]))
from ratatoskr.tui import AgentPickerApp from ratatoskr.tui import AgentPickerApp
Generated
+1 -1
View File
@@ -968,7 +968,7 @@ wheels = [
[[package]] [[package]]
name = "ratatoskr" name = "ratatoskr"
version = "0.8.0" version = "0.8.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },