fix(cli): re-render assistant messages after streaming to fix fenced code disappearance (#3293)

Adds a full `Markdown.update()` pass once streaming finishes to work
around Textualize/textual#6518, where incremental
`MarkdownFence._update_from_block` refreshes the visible `Label` but
leaves `_highlighted_code` pinned to the first chunk. Any later
recompose (click, focus change, theme update) re-yields the stale value
and wrapped fenced-code bodies vanish. A full re-parse rebuilds every
fence with correct internal state.
This commit is contained in:
Mason Daugherty
2026-05-10 18:25:10 -04:00
committed by GitHub
parent 503453c06f
commit 01b0a44353
2 changed files with 71 additions and 14 deletions
+15 -5
View File
@@ -584,7 +584,13 @@ class AssistantMessage(_TimestampClickMixin, Vertical):
"""Widget displaying an assistant message with markdown support.
Uses MarkdownStream for smoother streaming instead of re-rendering
the full content on each update.
the full content on each update. Once a stream finishes, the message
is re-rendered from the complete source via `Markdown.update()` to
work around Textualize/textual#6518: `MarkdownFence._update_from_block`
refreshes the visible `Label` but leaves `_highlighted_code` pinned to
the first chunk, so any later recompose (click, focus change, theme
update) re-yields the stale value and wrapped fenced-code bodies vanish.
A full re-parse rebuilds every fence with correct internal state.
"""
DEFAULT_CSS = """
@@ -670,24 +676,28 @@ class AssistantMessage(_TimestampClickMixin, Vertical):
async def write_initial_content(self) -> None:
"""Write initial content if provided at construction time."""
if self._content:
stream = self._ensure_stream()
await stream.write(self._content)
await self._get_markdown().update(self._content)
async def stop_stream(self) -> None:
"""Stop the streaming and finalize the content."""
if self._stream is not None:
await self._stream.stop()
self._stream = None
await self._get_markdown().update(self._content)
async def set_content(self, content: str) -> None:
"""Set the full message content.
This stops any active stream and sets content directly.
Cancels any active stream and renders the new content with a
single `Markdown.update()` (avoiding a redundant intermediate
update of the in-flight content).
Args:
content: The markdown content to display
"""
await self.stop_stream()
if self._stream is not None:
await self._stream.stop()
self._stream = None
self._content = content
if self._markdown:
await self._markdown.update(content)
+56 -9
View File
@@ -1,6 +1,6 @@
"""Unit tests for message widgets markup safety."""
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from rich.style import Style
@@ -208,16 +208,10 @@ class TestMutedRichMarkdown:
def test_strips_heading_and_table_colors(self) -> None:
"""Muted wrapper should drop magenta/cyan from headings and tables."""
from rich.markdown import Markdown as RichMarkdown
baseline = self._render(RichMarkdown(self._DOC))
muted = self._render(_MutedRichMarkdown(self._DOC))
# Default Rich theme paints `markdown.h3` magenta (ANSI code 35)
# and `markdown.table.*` cyan (ANSI code 36).
assert "\x1b[1;35m" in baseline
assert "\x1b[36m" in baseline
# Some Rich versions paint headings/tables magenta/cyan by default.
# The wrapper should not emit those hues regardless of Rich's baseline.
assert "\x1b[35m" not in muted
assert ";35m" not in muted
assert "\x1b[36m" not in muted
@@ -248,6 +242,59 @@ class TestMutedRichMarkdown:
assert "body" in rendered
class TestAssistantMessageMarkdownRendering:
"""Tests for assistant markdown render lifecycle."""
async def test_write_initial_content_uses_full_markdown_update(self) -> None:
"""Preloaded assistant messages should not keep stream state alive."""
msg = AssistantMessage("```python\nprint('hello')\n```")
markdown = MagicMock()
markdown.update = AsyncMock()
msg._markdown = markdown
await msg.write_initial_content()
markdown.update.assert_awaited_once_with("```python\nprint('hello')\n```")
assert msg._stream is None
async def test_stop_stream_rerenders_complete_markdown(self) -> None:
"""Completed streams should get a full parse after incremental updates."""
msg = AssistantMessage()
markdown = MagicMock()
markdown.update = AsyncMock()
stream = MagicMock()
stream.stop = AsyncMock()
msg._markdown = markdown
msg._stream = stream
msg._content = "```python\nprint('wrapped text')\n```"
await msg.stop_stream()
stream.stop.assert_awaited_once_with()
markdown.update.assert_awaited_once_with(
"```python\nprint('wrapped text')\n```"
)
assert msg._stream is None
async def test_set_content_replaces_stream_with_single_update(self) -> None:
"""Replacing content should cancel the stream and update exactly once."""
msg = AssistantMessage()
markdown = MagicMock()
markdown.update = AsyncMock()
stream = MagicMock()
stream.stop = AsyncMock()
msg._markdown = markdown
msg._stream = stream
msg._content = "old streamed content"
await msg.set_content("```python\nnew content\n```")
stream.stop.assert_awaited_once_with()
markdown.update.assert_awaited_once_with("```python\nnew content\n```")
assert msg._stream is None
assert msg._content == "```python\nnew content\n```"
class TestSummarizationMessage:
"""Tests for summarization notification widget."""