mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
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:
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user