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.
This commit is contained in:
vh
2026-05-24 21:39:02 -07:00
parent 9fade55901
commit 11ef6830ab
7 changed files with 156 additions and 118 deletions
+61 -68
View File
@@ -135,7 +135,6 @@ class TestTuiPresenterState:
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(),
thinking_log=thinking_log,
raw=False,
)
@@ -160,7 +159,6 @@ class TestTuiPresenterState:
log=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(),
thinking_log=thinking_log,
raw=False,
)
@@ -190,7 +188,6 @@ class TestTuiPresenterState:
log=log,
tools_log=MagicMock(),
debug_log=debug_log,
current_text=MagicMock(),
thinking_log=thinking_log,
raw=False,
)
@@ -199,7 +196,6 @@ class TestTuiPresenterState:
log=log,
tools_log=MagicMock(),
debug_log=debug_log,
current_text=MagicMock(),
thinking_log=thinking_log,
raw=False,
)
@@ -217,10 +213,12 @@ class TestTuiPresenterState:
# and test_thinking_widget_visibility_lifecycle deleted (no longer apply).
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_log, each wrapping their delta lines. Text goes to
current_text (buffered). Transcript: [done] + Markdown body.
thinking_log (deltas coalesced into tail-flushes per run).
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
@@ -228,7 +226,6 @@ class TestTuiPresenterState:
log = MagicMock()
thinking_log = MagicMock()
current_text = MagicMock()
state = TuiPresenterState()
for evt in (
Thinking(sse_id=SID, content="first"),
@@ -238,25 +235,24 @@ class TestTuiPresenterState:
state.render(
evt, log=log,
tools_log=MagicMock(), debug_log=MagicMock(),
current_text=current_text, thinking_log=thinking_log, raw=False,
thinking_log=thinking_log, raw=False,
)
state.render(
_make_tui_done(),
log=log,
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]
rules = [w for w in thinking_writes if isinstance(w, Rule)]
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 "first" in delta_strs
assert "second" in delta_strs
# Text "hi" went to current_text (buffered), not the transcript directly.
current_text.update.assert_any_call("hi")
# Transcript: [done] label + Markdown(response) (raw=False).
# v0.8.1: Text "hi" flushes as a line in transcript on Done.
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))
def test_render_exception_fallback(self) -> None:
@@ -281,7 +277,6 @@ class TestTuiPresenterState:
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(),
thinking_log=thinking_log,
raw=False,
)
@@ -301,7 +296,6 @@ class TestTuiPresenterState:
log=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(),
thinking_log=MagicMock(),
raw=False,
)
@@ -323,8 +317,7 @@ class TestTuiPresenterState:
Thinking(sse_id=SID, content="partial"),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
)
state.render(
Cancelled(
@@ -332,8 +325,7 @@ class TestTuiPresenterState:
),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
)
# 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]
@@ -342,10 +334,10 @@ class TestTuiPresenterState:
assert thinking_log.write.call_count >= 3
def test_done_renders_markdown_after_label(self) -> None:
"""done_renders_markdown_after_label [happy, v0.6.0]:
Text("hi") accumulates into current_text Static (buffered streaming);
Done(response="hi") with raw=False → [done] label + Rule + Markdown
in transcript. current_text cleared on terminal.
"""done_renders_markdown_after_label [happy, v0.8.1]:
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).
"""
from rich.markdown import Markdown
from rich.rule import Rule
@@ -353,34 +345,33 @@ class TestTuiPresenterState:
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
current_text = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hi"),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(),
raw=False,
)
# Text accumulated to current_text, NOT written to log.
current_text.update.assert_any_call("hi")
# 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(
_make_tui_done(),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(),
raw=False,
)
# Done cleared current_text and wrote [done] label + Rule + Markdown.
current_text.update.assert_any_call("")
# On Done: tail flush + [done] + Rule + Markdown body.
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)
assert state.text_chunk_buffer == ""
def test_raw_flag_skips_markdown(self) -> None:
"""raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown."""
@@ -395,15 +386,13 @@ class TestTuiPresenterState:
Text(sse_id=SID, content="hi"),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
)
state.render(
_make_tui_done(),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
)
writes = [c[0][0] for c in log.write.call_args_list]
assert not any(isinstance(w, Rule) for w in writes)
@@ -425,8 +414,7 @@ class TestTuiPresenterState:
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
tools_log=MagicMock(),
debug_log=debug_log,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
)
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
assert not log.write.called
@@ -461,8 +449,7 @@ class TestTuiPresenterState:
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
log=log,
tools_log=tools_log,
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
)
# INV-014: write went to tools_log
assert tools_log.write.called
@@ -481,56 +468,55 @@ class TestTuiPresenterState:
ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12),
log=log,
tools_log=tools_log,
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
)
assert tools_log.write.called
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
assert not log.write.called
def test_text_event_buffers_into_current_text(self) -> None:
"""text_event_buffers_into_current_text [v0.6.0]: Text → current_text Static
(accumulated), NOT log or tools_log. Streaming UX fix — no per-token spam.
def test_text_event_buffers_until_newline(self) -> None:
"""text_event_buffers_until_newline [v0.8.1]: Text deltas without
`\\n` accumulate in text_chunk_buffer; no log write yet.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
tools_log = MagicMock()
current_text = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hello"),
log=log,
tools_log=tools_log,
debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(),
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 tools_log.write.called
def test_text_deltas_accumulate(self) -> None:
"""text_deltas_accumulate [v0.6.0]: multiple Text deltas → current_text shows
concatenated content, NOT separate per-delta lines.
def test_text_flushes_on_newline(self) -> None:
"""text_flushes_on_newline [v0.8.1]: a delta carrying `\\n` flushes
the accumulated buffer as ONE line to log (transcript).
"""
from ratatoskr.tui import TuiPresenterState
current_text = MagicMock()
log = MagicMock()
state = TuiPresenterState()
for tok in ("Hel", "lo", " ", "world"):
for tok in ("Hel", "lo", " ", "world", "\n"):
state.render(
Text(sse_id=SID, content=tok),
log=MagicMock(),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(),
raw=False,
)
# Final update reflects the full concatenation.
assert current_text.update.call_args_list[-1][0][0] == "Hello world"
writes = [c[0][0] for c in log.write.call_args_list]
# "Hello world" coalesces to ONE log entry.
assert writes == ["Hello world"]
assert state.text_chunk_buffer == ""
def test_duration_format_seconds(self) -> None:
"""duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s"."""
@@ -542,8 +528,7 @@ class TestTuiPresenterState:
_make_tui_done(duration_ms=5467),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
)
done_line = next(
_text_of(c[0][0])
@@ -569,8 +554,7 @@ class TestTuiPresenterState:
_make_tui_done(usage=usage),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
)
done_line = next(
_text_of(c[0][0])
@@ -857,7 +841,8 @@ class TestLayoutShape:
log=log,
tools_log=app.query_one("#tools-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(
c for c in seen
@@ -1075,9 +1060,10 @@ 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.6.0]:
Text deltas go to current_text (not transcript); on Done, transcript
gets turn-header Rule, [done] label, post-Done Rule + Markdown body.
"""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.
"""
stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk(
"42:2", _DONE_BODY
@@ -1093,12 +1079,11 @@ class TestStreamTurnWorker:
await pilot.pause()
await _submit_and_wait(app, pilot, "hi")
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.rule import Rule
assert not any(w == "hello" for w in writes)
# v0.8.1: "hello" appears in transcript as a tail-flush on Done.
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)
@@ -2254,8 +2239,16 @@ class TestResolveThenRunWithPicker:
self,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> 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=[]))
from ratatoskr.tui import AgentPickerApp