deer-flow/backend/tests/test_tui_render.py
Hyeonsang Cho e7d96fe051
fix(tui): append streamed assistant deltas verbatim (#7325)
* fix(tui): append streamed assistant deltas verbatim

The reducer merged each delta by comparing it with the row text: an
equal delta was a no-op, a delta starting with the row replaced it, and
a delta the row started with was dropped. Those rules absorbed re-sends,
replayed history and cumulative snapshots, none of which
DeerFlowClient.stream emits any more, so they only deleted real tokens
("ha" + "ha" -> "ha", "1" + "10" -> "10") and every standalone Markdown
marker an answer opened with (closing **, list bullets, table pipes,
closing code fences).

Append every delta on both the id-keyed and anonymous paths, and drop
_merge_stream_text. Rewrite the tests that pinned the re-send premise
as append contracts, run the merge cases through reduce() on both
paths, and drive the real client stream into the reducer.

* docs(changelog): note TUI streamed-delta fix (#7325)

* docs(changelog): drop trailing spaces after the #7325 entries

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-23 18:29:08 +08:00

117 lines
4.2 KiB
Python

"""Smoke tests for the pure Rich renderers — they must render without error
and include the expected text."""
from rich.console import Console
from deerflow.tui.render import render_header, render_status, render_transcript
from deerflow.tui.view_state import (
AssistantDelta,
RunEnded,
RunStarted,
SystemMessage,
ToolResult,
ToolStarted,
UserSubmitted,
initial_state,
reduce,
)
def _render_to_text(renderable) -> str:
console = Console(width=100, no_color=True)
with console.capture() as capture:
console.print(renderable)
return capture.get()
def test_render_empty_transcript_shows_hint():
out = _render_to_text(render_transcript(initial_state()))
assert "Type a message" in out
def test_render_transcript_includes_all_row_kinds():
state = initial_state()
state = reduce(state, UserSubmitted("hello there"))
state = reduce(state, AssistantDelta(id="m1", text="hi back"))
state = reduce(state, ToolStarted(tool_call_id="t1", tool_name="read_file", args={"path": "a.py"}))
state = reduce(state, ToolResult(tool_call_id="t1", content="file body", is_error=False))
state = reduce(state, SystemMessage("a note"))
out = _render_to_text(render_transcript(state))
assert "hello there" in out
assert "hi back" in out
assert "Read" in out and "a.py" in out
assert "file body" in out
assert "a note" in out
def test_finalized_assistant_renders_markdown():
state = initial_state()
state = reduce(state, AssistantDelta(id="m1", text="**bold** text\n\n## A Heading\n\n- item one"))
out = _render_to_text(render_transcript(state))
# Markdown is rendered: the syntax markers are consumed, the content remains.
assert "bold" in out
assert "**bold**" not in out
assert "A Heading" in out
assert "## A Heading" not in out
assert "item one" in out
def test_actively_streaming_assistant_stays_plain():
state = initial_state()
state = reduce(state, RunStarted())
state = reduce(state, AssistantDelta(id="m1", text="**partial heading ##"))
out = _render_to_text(render_transcript(state))
# The streaming row must NOT be markdown-rendered (avoids reflow jumpiness).
assert "**partial heading ##" in out
def test_prior_message_stays_markdown_when_a_followup_run_starts():
# Regression: sending a follow-up must NOT revert a finalized markdown answer
# to raw text. Between RunStarted and the new answer's first delta, the
# previous answer is the last assistant row — it must still render as Markdown.
state = initial_state()
state = reduce(state, AssistantDelta(id="m1", text="**bold answer**"))
state = reduce(state, RunEnded())
state = reduce(state, UserSubmitted("follow up"))
state = reduce(state, RunStarted()) # no new answer yet
out = _render_to_text(render_transcript(state))
assert "**bold answer**" not in out # still Markdown-rendered
assert "bold answer" in out
def test_only_the_actively_streaming_message_is_plain():
state = initial_state()
state = reduce(state, AssistantDelta(id="m1", text="**done**"))
state = reduce(state, RunEnded())
state = reduce(state, RunStarted())
state = reduce(state, AssistantDelta(id="m2", text="**streaming now"))
out = _render_to_text(render_transcript(state))
assert "**streaming now" in out # active m2 stays plain
assert "**done**" not in out # finalized m1 stays Markdown
assert "done" in out
def test_render_status_ready_and_working():
ready = _render_to_text(render_status(initial_state(), model="gpt", thread_label="new"))
assert "ready" in ready
state = reduce(initial_state(), RunStarted())
working = _render_to_text(render_status(state, model="gpt", thread_label="new", spinner="*"))
assert "working" in working
def test_render_status_shows_token_usage():
state = reduce(initial_state(), RunEnded(usage={"total_tokens": 42}))
out = _render_to_text(render_status(state, model="gpt", thread_label="t1"))
assert "42 tok" in out
def test_render_header_includes_model_and_cwd():
out = _render_to_text(render_header(model="claude", thread_label="new", cwd="/tmp/proj", skills=3))
assert "DeerFlow" in out
assert "claude" in out
assert "/tmp/proj" in out