fix(tui): coalesce thinking deltas on \n (v0.7.1)
Operator: "thinking tokens seem to be split by token — each on a
newline, is that correct? We don't want that."
Root cause: v0.6.5 wrote each Thinking SSE delta as its own
`thinking_log.write(event.content)` call. Worldtree emits Thinking
events at token granularity (per-token or per-few-tokens), so EACH
token became its own RichLog line — visually choppy, one short
fragment per visual row. Wrong UX.
## Fix: coalesce-on-newline
Thinking deltas accumulate in `TuiPresenterState.thinking_chunk_buffer`
(new str field). On each Thinking event:
1. Append delta content to buffer.
2. Flush every COMPLETE line (chars before each `\n`) as one
thinking_log.write(line) call.
3. Leave the post-final-`\n` tail in the buffer for the next delta.
On any non-thinking event (run close):
1. Flush remaining buffer tail (if any) as one final line.
2. Write Rule(end).
Empty lines (blank paragraph separators in the model's `\n\n` flow)
are skipped — they'd render as no-content RichLog entries which
just add vertical noise. Natural paragraph breaks become single
visible lines; multi-paragraph thinking renders top-to-bottom.
## Verified live (tier-3 smoke against personal Worldtree)
Defined a `thinky-smoke` agent via `python -m ratatoskr.tier3 define`,
asked "What is 12 times 13?". Thinking pane rendered with natural
paragraph chunks:
── turn N · thinking #1 start ──
Thinking Process:
1. **Analyze the Request:** The user wants to know the result of $12 \times 13$.
2. **Calculate:**
* Method 1: Standard multiplication.
$$12 \times 10 = 120$$
$$12 \times 3 = 36$$
$$120 + 36 = 156$$
* Method 2: $(10 + 2)(10 + 3) = 100 + 30 + 20 + 6 = 156$.
── turn N · thinking #1 end ──
Each line = one natural paragraph or list item. No per-token fragments.
## Edge cases noted
- Long-running thinking with NO `\n` at all stays buffered until run
close → operator sees nothing until close. Possible follow-up: add
a length-threshold flush (e.g., > 500 chars → flush at the last
space). For now this is acceptable; thinking content typically has
`\n` breaks every few sentences.
- Empty deltas (`""`) are ignored implicitly — no buffer growth, no
flush.
- `\n` at the very start of a delta flushes whatever was buffered
before, then leaves the empty post-`\n` tail (empty string) in the
buffer, which doesn't show up as an empty line because of the
`if line:` guard.
## Contract amendment
docs/contracts/issues/13.contract.md INV-022 amended for v0.7.1
coalesce semantics. Drift-check clean.
## Tests
265/265 GREEN; ruff clean. Two updated tests:
- `test_thinking_streams_into_thinking_log` → renamed
`test_thinking_coalesces_until_newline`: 3 token-shaped deltas
with no `\n` → only Rule(start) writes, buffer holds accumulated.
- NEW `test_thinking_flushes_on_newline`: delta carrying `\n` →
Rule(start) + accumulated line + clear buffer.
- `test_thinking_closes_to_thinking_log`: 2 deltas "a", "b" +
close → Rule(start) + tail-flush "ab" + Rule(end) = 3 writes
(was 4 with per-delta).
Patch bump (v0.7.0 → v0.7.1) — internal presenter routing change;
no public-API or layout change.
This commit is contained in:
+41
-19
@@ -115,10 +115,11 @@ SID = SseId(42, 5)
|
||||
class TestTuiPresenterState:
|
||||
"""Tests for the new TuiPresenterState — per issue #12 contract."""
|
||||
|
||||
def test_thinking_streams_into_thinking_log(self) -> None:
|
||||
"""thinking_streams_into_thinking_log [happy,tracer, v0.6.5]:
|
||||
3 Thinking deltas → thinking_log gets Rule(start) + 3 delta lines.
|
||||
Transcript untouched; no thinking-current Static involved.
|
||||
def test_thinking_coalesces_until_newline(self) -> None:
|
||||
"""thinking_coalesces_until_newline [happy,tracer, v0.7.1]:
|
||||
Per-token deltas accumulate in the buffer; flush only on `\\n`.
|
||||
Three short token-shaped deltas without `\\n` → thinking_log gets
|
||||
ONLY Rule(start); content stays buffered.
|
||||
"""
|
||||
from rich.rule import Rule
|
||||
|
||||
@@ -127,7 +128,7 @@ class TestTuiPresenterState:
|
||||
log = MagicMock()
|
||||
thinking_log = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
for chunk in ("a", "b", "c"):
|
||||
for chunk in ("Let", " me", " think"):
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content=chunk),
|
||||
log=log,
|
||||
@@ -138,19 +139,41 @@ class TestTuiPresenterState:
|
||||
raw=False,
|
||||
)
|
||||
writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||||
# 1 Rule(start) + 3 content lines = 4 writes
|
||||
assert len(writes) == 4
|
||||
# Only Rule(start) — content stays buffered (no `\n` seen).
|
||||
assert len(writes) == 1
|
||||
assert isinstance(writes[0], Rule)
|
||||
assert writes[1] == "a"
|
||||
assert writes[2] == "b"
|
||||
assert writes[3] == "c"
|
||||
# Transcript untouched during thinking streaming.
|
||||
assert state.thinking_chunk_buffer == "Let me think"
|
||||
assert log.write.call_count == 0
|
||||
|
||||
def test_thinking_flushes_on_newline(self) -> None:
|
||||
"""thinking_flushes_on_newline [happy, v0.7.1]:
|
||||
Delta carrying `\\n` flushes the accumulated buffer as ONE line.
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
thinking_log = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
for chunk in ("Hello", " world", "\n"):
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content=chunk),
|
||||
log=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(),
|
||||
thinking_log=thinking_log,
|
||||
raw=False,
|
||||
)
|
||||
writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||||
# Rule(start) + "Hello world" (one coalesced line) = 2 writes
|
||||
assert len(writes) == 2
|
||||
assert writes[1] == "Hello world"
|
||||
assert state.thinking_chunk_buffer == ""
|
||||
|
||||
def test_thinking_closes_to_thinking_log(self) -> None:
|
||||
"""thinking_closes_to_thinking_log [happy, v0.6.5]: 2x Thinking + WorkerPhase →
|
||||
thinking_log gets Rule(start) + 2 delta lines + Rule(end); debug_log gets
|
||||
the worker_phase line; transcript untouched.
|
||||
"""thinking_closes_to_thinking_log [happy, v0.7.1]: 2x Thinking + WorkerPhase →
|
||||
v0.7.1 coalesces "a"+"b" into one buffered string; the close flushes
|
||||
"ab" as a single line before Rule(end). Result: Rule(start) + "ab" +
|
||||
Rule(end) = 3 writes. debug_log gets worker_phase; transcript untouched.
|
||||
"""
|
||||
from rich.rule import Rule
|
||||
|
||||
@@ -180,12 +203,11 @@ class TestTuiPresenterState:
|
||||
raw=False,
|
||||
)
|
||||
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||||
# 1 Rule(start) + 2 delta lines + 1 Rule(end) = 4 writes
|
||||
assert len(thinking_writes) == 4
|
||||
# v0.7.1: 1 Rule(start) + 1 coalesced "ab" tail-flush + 1 Rule(end) = 3 writes
|
||||
assert len(thinking_writes) == 3
|
||||
assert isinstance(thinking_writes[0], Rule)
|
||||
assert thinking_writes[1] == "a"
|
||||
assert thinking_writes[2] == "b"
|
||||
assert isinstance(thinking_writes[3], Rule)
|
||||
assert thinking_writes[1] == "ab"
|
||||
assert isinstance(thinking_writes[2], Rule)
|
||||
# worker_phase still goes to debug_log; transcript untouched.
|
||||
assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[-1][0][0])
|
||||
assert not log.write.called
|
||||
|
||||
Reference in New Issue
Block a user