Skip to content

Fix duplicated thinking rows after mid-stream transcript redraws - #631

Closed
rian-dolphin wants to merge 6 commits into
huggingface:mainfrom
rian-dolphin:fix-streaming-thinking-dup
Closed

Fix duplicated thinking rows after mid-stream transcript redraws#631
rian-dolphin wants to merge 6 commits into
huggingface:mainfrom
rian-dolphin:fix-streaming-thinking-dup

Conversation

@rian-dolphin

@rian-dolphin rian-dolphin commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Problem

The TUI can show one streamed message or thinking messages two times. The copies stay on the screen. In the worst case, the thinking block and the answer text both show two times. The session file is correct in all cases. Only the display is not correct.

The problem occurs when the transcript does a new render while a message streams or completes. These actions start such a render:

  • Each slash command, after it completes. Examples: /session and /model.
  • Ctrl+T.
  • A resize of the terminal.
  • A change of theme.

Example before:
You can see the thinking block duplicated twice here.
Screenshot 2026-08-21 at 17 30 56

Cause

The fault has two windows.

  1. Between stream events. A render here shows the unfinished thinking rows as permanent rows. The cleanup step at the end of the message does not know these rows. As a result, a copy of the thinking block stays.
  2. In the cleanup step. The cleanup step pauses between two operations: it removes the streamed rows, and then it shows the final rows. A render in this pause shows the final message one time. The cleanup step then continues and shows the final message again. As a result, the full message shows two times. A live test with a screenshot confirmed this window.

Fix

  • Part one sets a mark on each unfinished thinking row. The mark keeps the row in the set of live rows. The cleanup step removes all live rows. This is the same method the TUI uses for streamed answer text.
  • Part two makes the cleanup step safe against a render in its pause. The cleanup step finds that a render has shown the final message. The cleanup step then stops.

See the code for the details.

Tests

We wrote the tests first. Four tests caused the first fault on the main branch, through the real event path: a render, Ctrl+T, and /session, with the thinking shown and hidden. All four failed. Then we applied part one of the fix.

Reviews found more edge cases and one regression. A live test found the second window. We added a test for each case before each correction: eleven more tests. They cover interrupted and replaced streams, toggles after text, placeholder survival, and renders in the cleanup pause.

All fifteen tests pass. The full test suite, ruff, and mypy show no errors.

Same example on fix branch:
Screenshot 2026-08-21 at 17 31 33

A full transcript redraw while an assistant message streams (any slash
command triggers one via _refresh(), as do Ctrl+T, resize, and theme
changes) remounted the provisional thinking item as a durable widget.
Finalization only removes widgets tracked as live streaming state, so
the remounted row was orphaned and the thinking block or its hidden
placeholder appeared twice, permanently.

Mark adapter-created provisional items on ChatItem and make _redraw and
update_thinking_visibility treat them as live streaming state: their
widgets register in _active_message_widgets, the trailing provisional
thinking row is rebuilt as a streaming widget seeded from a copied item,
and hidden-placeholder runs split when provisional-ness changes so a
provisional run never shares a placeholder with durable history. The
adapter clears provisional flags at assistant MessageStart and
MessageEnd, and cancellation/error paths convert leftovers to durable
rows so the next finalization cannot delete an interrupted turn's
thinking. Also mount a mid-stream thinking toggle's trailing row above
the live assistant widget instead of below the streamed answer text.
The review of the mid-stream fix found a regression: mounting a toggled
thinking row above the live assistant widget means the placeholder is no
longer the last child, so the child-inspection check reported it as not
visible and the next thinking delta mounted a second placeholder. Derive
visibility from the window items instead, mirroring _redraw, and drop
the now-unused last-child helper.
finish_structured_assistant_message awaits between removing streamed
widgets and mounting canonical blocks. A refresh or thinking toggle
processed while the coroutine was suspended rendered the already
canonical state, and the resumed finalization mounted a second full
copy of the message: thinking and answer text both duplicated. This is
the whole-message duplication seen live; the earlier commits' fix only
covered redraws landing between events.

Reset live-widget bookkeeping before the awaits so interleaved callers
never observe half-finalized state, detect that a concurrent render
already mounted this message's canonical items and let that render win
instead of mounting a duplicate, and anchor update_thinking_visibility
on the live assistant widget only while it is still mounted so a toggle
interleaved with finalization cannot raise MountError.
A new assistant MessageStart without a prior MessageEnd converts the
old stream's provisional rows to durable history, but their widgets
stayed in the live set, so the next finalization removed the durable
thinking row from the transcript. Release the live set at the stream
boundary instead. Also track provisional items in their own list so
converting them stops scanning the whole transcript.
A replacement MessageStart overwrote assistant_buffer, so the old
stream's text had no state row behind its mounted widget and the next
redraw dropped it. Flush the buffer into a durable assistant row first,
the same policy _flush applies at AgentEnd and tool start.
@rian-dolphin

Copy link
Copy Markdown
Contributor Author

Closed in favor of #632, which fixes the same two fault windows (mid-stream redraws and the finalization pause) with the ownership model from Pi: one cumulative active-assistant draft as the only provisional state, one owner for all live tail widgets that every redraw reconstructs, and stream/render generation guards for stale async work.

All of this PR's test scenarios pass against #632, and its two strongest ones (a thinking toggle interleaved into a suspended finalization, and interrupted rows surviving the next turn's completion) were ported there as permanent regression tests. The failure analysis here directly informed the follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant