Compare commits

...

1 Commits

Author SHA1 Message Date
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 156 additions and 118 deletions
+4 -3
View File
@@ -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.0 local tier-3 agent index in picker):_
_As of 2026-05-25 (post-v0.8.1 text streams inline, no overlap):_
**Status: v0.8.0 shipped.** Eleven core features complete (`sse_client`
**Status: v0.8.1 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.0 feat(local_agents): JSON-backed local tier-3 index + picker merge
- v0.8.1 fix(tui): kill current-text Static; Text streams inline via coalesce
- `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)
- `d356990` refactor(tui): thinking streams into thinking-log (v0.6.5)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.8.0"
version = "0.8.1"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
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.
if sse.data == "":
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:
sse_id = _parse_sse_id(sse.id)
except ValueError as exc:
+40 -45
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import asyncio
import sys
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import ClassVar, Literal
import httpx
@@ -186,9 +186,6 @@ class TuiPresenterState:
"""
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_index: int = 0
# 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
# when the run closes (any leftover tail).
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(
self,
event: Event,
*,
log: RichLog,
current_text: Static,
tools_log: RichLog,
debug_log: RichLog,
thinking_log: RichLog,
@@ -211,18 +215,14 @@ class TuiPresenterState:
) -> None:
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
v0.6.5 routing:
- `log` (transcript) = content only: user-prompt echo (written
outside the presenter), terminal labels, post-Done Markdown body.
- `current_text` (Static below transcript) = live-streaming Text
deltas accumulated into one growing line; cleared on terminal.
v0.8.1 routing:
- `log` (transcript) = chat content: user-prompt echo (written
outside the presenter), coalesced Text deltas, terminal labels,
optional post-Done Markdown body.
- `tools_log` = ToolStart + ToolResult.
- `debug_log` = WorkerPhase + TextBoundary.
- `thinking_log` = streaming Thinking deltas inline (each chunk =
one line in the scrollable log). Rule(start)/Rule(end) markers
wrap each run. The whole pane scrolls naturally — no separate
tail-scrolling Static at the bottom (v0.6.5 removed
`thinking-current`).
- `thinking_log` = streaming Thinking deltas inline (coalesced on
`\n`). Rule(start)/Rule(end) wrap each run.
Exceptions caught at the presenter boundary (INV-009 fallback).
"""
@@ -284,19 +284,23 @@ class TuiPresenterState:
self.thinking_open = False
# Now render the non-thinking event itself.
if isinstance(event, Text):
# v0.6.0: streaming text accumulates into current_text Static
# — one growing live line, NOT per-delta RichLog entries.
self.text_buffer.append(event.content)
current_text.update("".join(self.text_buffer))
# v0.8.1: stream Text deltas into transcript directly,
# coalesced on `\n`. Same pattern as Thinking (v0.7.1).
# The pre-v0.8.1 #current-text Static is gone — its dock-
# 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
if isinstance(event, (Done, Error, Cancelled)):
# Terminal event: clear the streaming Static first so the
# live-preview band collapses. Then write the colored label
# + (non-raw) Markdown body / (raw) accumulated plain text
# to the transcript.
accumulated = "".join(self.text_buffer)
self.text_buffer.clear()
current_text.update("")
# Terminal event: flush any remaining text tail before the
# label / Markdown body lands.
if self.text_chunk_buffer:
log.write(self.text_chunk_buffer)
self.text_chunk_buffer = ""
# Terminal labels tinted per outcome (Aurora green / Dawn red
# / Dawn yellow) for at-a-glance scanning.
if isinstance(event, Done):
@@ -306,12 +310,13 @@ class TuiPresenterState:
f"usage {_format_usage(event.usage, arrow='')}",
style=_AU_SUCCESS,
))
if raw:
# Raw mode: emit the accumulated streamed text verbatim
# so the operator has a record after the Static clears.
if accumulated:
log.write(accumulated)
else:
# 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
@@ -569,16 +574,9 @@ class RatatoskrApp(App[int]):
background: $background;
padding: 0 1;
}
/* v0.6.0: streaming-text Static carries in-flight assistant tokens.
Replaces per-token RichLog spam — one growing line that updates in
place. Cleared on terminal event; final Markdown body lands in the
transcript. */
#current-text {
dock: bottom;
height: auto;
background: $background;
padding: 0 1;
}
/* v0.8.1: #current-text Static removed. Streaming text now coalesces
on `\n` and writes directly to #transcript (same pattern as v0.7.1
thinking fix). Eliminates the dock-bottom-growth-overlap bug. */
#tools-log, #debug-log, #thinking-log {
background: $background;
padding: 0 1;
@@ -678,7 +676,6 @@ class RatatoskrApp(App[int]):
with Horizontal(id="main-row"):
with Vertical(id="left-column"):
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")
with Vertical(id="right-column"):
with TabbedContent(id="side-panes"):
@@ -798,7 +795,6 @@ class RatatoskrApp(App[int]):
assert self.client is not None
assert content
log = self.query_one("#transcript", RichLog)
current_text = self.query_one("#current-text", Static)
tools_log = self.query_one("#tools-log", RichLog)
debug_log = self.query_one("#debug-log", RichLog)
thinking_log = self.query_one("#thinking-log", RichLog)
@@ -814,7 +810,6 @@ class RatatoskrApp(App[int]):
presenter.render(
event,
log=log,
current_text=current_text,
tools_log=tools_log,
debug_log=debug_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()
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:
@respx.mock
async def test_empty_data_skipped(self) -> None:
+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
Generated
+1 -1
View File
@@ -968,7 +968,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.8.0"
version = "0.8.1"
source = { editable = "." }
dependencies = [
{ name = "httpx" },