mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
* feat(tui): add Hermes-like terminal workbench backed by DeerFlowClient Implements the `deerflow` TUI from RFC #3540: a terminal-native, embedded workbench over the existing harness (no Gateway/frontend/nginx/Docker), built Python-native with Textual and learning UX patterns from tao-pi. Architecture — every layer except the Textual app is pure and unit-tested: - view_state.py: ViewState + reduce(state, action), the testable heart - runtime.py: StreamEvent -> reducer actions (pure translate + threaded driver) - message_format / command_registry / input_history / render / theme: pure - app.py: Textual App; runs the sync DeerFlowClient.stream() on a worker thread and marshals actions back to the UI thread. Slash command palette, model and thread modal pickers, ↑/↓ history, Ctrl+C interrupt, TTY-aware fallback. - cli.py: pure launch-mode planning + headless --print/--json + `deerflow` console script (textual is an optional [tui] extra; degrades to headless help) Web UI visibility (the RFC's key decision): persistence.py writes a threads_meta row under the local default user into the same DB the Gateway reads, so terminal sessions appear in the Web UI sidebar without running the Gateway. Best-effort, no-op on the memory backend; all DB work on one long-lived background loop. Tests: 95 TUI tests — pure layers via pytest, app/palette/overlays via Textual's pilot harness with a fake session, and a threads_meta read/write round-trip. ruff clean; respects the harness->app import boundary. Docs: backend/docs/TUI.md plus CLAUDE.md/README updates and preview screenshots. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): de-duplicate streamed assistant text and tool cards; keep Tab in composer Self-test surfaced three issues, all root-caused to consuming non-strict streaming from DeerFlowClient (proven by the client's own test_dedup_requires_messages_before_values_invariant, which shows the client can re-emit a message id's full content twice): - Assistant text was doubled (e.g. "answer answer") because the reducer blindly concatenated same-id deltas. Now merges by content: a re-send or cumulative snapshot replaces; only genuine increments append. - Tool activity showed duplicate and empty "gear" cards from partial/re-emitted tool-call chunks. ToolStarted now de-dupes by tool_call_id, drops id-less noise chunks, and fills the name on a later chunk; a tool result with no prior card still surfaces as a completed card. - Tab moved focus off the composer to the scroll region (felt like broken cursor logic). Tab is now consumed by the composer (completes a command when the palette is open, no-op otherwise). Adds reducer tests for each case plus a Tab-focus test; 102 TUI tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): make Esc interrupt an active run (matches the status hint) The status line advertised "esc interrupt" but Esc was only wired to close the slash palette, so it did nothing during a run. Esc now: closes the palette when open, interrupts the active run when streaming, and is a no-op when idle. The interrupt logic is shared with Ctrl+C via _interrupt_run(). Adds a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): stop prior answers duplicating on threads with history On a thread with history, DeerFlowClient re-emits every prior message on each new turn (its streamed_ids dedup is per-stream-call), and a re-emitted older message can arrive after a newer message has already started. The reducer only matched the *most recent* assistant row by id and otherwise appended, so each re-emitted older answer was duplicated verbatim at the end of the transcript. Match an assistant row by id anywhere in the transcript and merge in place. Tool cards already de-dupe by call id globally, so they were unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): correct CJK cursor drift in the composer Confirmed a Textual Input bug (latest 8.2.7): Input._cursor_offset adds an unconditional +1 at the end of the value, overshooting by one cell after double-width (CJK) characters. That misplaces the hardware/IME cursor — the drift seen when typing Chinese in iTerm2 (the on-screen block cursor, drawn separately in render_line, is fine; English doesn't use an IME so it looks correct). Reproduced with a bare Input, so it's upstream, not our layout. Add ComposerInput(Input) overriding _cursor_offset to the true cell position and use it for the composer. Numeric tests pin the CJK end/mid and ASCII cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tui): render finalized assistant messages as Markdown The transcript showed raw Markdown (literal **bold**, ## headings, - lists, links). Finalized assistant messages now render as Rich Markdown — headings, bold/italic, lists, inline code + code blocks, blockquotes, horizontal rules and links — with the ● speaker marker aligned to the top of the body. The actively-streaming message stays plain text so partial Markdown doesn't reflow/jump, then snaps to its rendered form when the run ends. Transcript re-renders are coalesced on a ~60ms timer (dirty flag) so per-token Markdown re-parsing stays smooth on long threads. Tests cover both the rendered and the streaming-plain paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style(tui): apply ruff format CI lint runs `ruff format --check` via uvx (latest ruff); apply the formatter so the lint-backend job passes. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(tui): address code-quality review comments From github-code-quality[bot] on #3760: - runtime.py: give the `_ClientLike` Protocol method a docstring body instead of a bare `...` (flagged as a no-effect statement), matching the harness convention for Protocol stubs (e.g. SafetyTerminationDetector). - test_tui_cli_main.py: drop the unnecessary `lambda: _FakeSession()` wrappers in monkeypatch.setattr; pass `_FakeSession` directly (same behavior). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): keep history Markdown-rendered when a follow-up run starts Previously the transcript rendered "the last assistant row" as plain text while streaming. But when a follow-up turn starts, the last assistant row is the *previous, finalized* answer until the new message begins — and the client re-emits prior messages early in the turn — so sending a follow-up reverted the previous answer from rendered Markdown back to raw text. Track the actively-streaming message id in ViewState instead: it's reset on RunStarted, set only when an AssistantDelta actually adds new content (history re-emits are no-ops and don't mark it), and cleared on RunEnded. The renderer keeps only that one message plain; all history stays Markdown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(readme): add Terminal Workbench (TUI) section to root README Mention the new `deerflow` TUI alongside the Embedded Python Client in the root README.md and README_zh.md (install, launch/headless commands, feature summary, Web UI visibility), with a ToC entry and a preview screenshot. Links to backend/docs/TUI.md for the full guide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): address review feedback (willem-bd) Ten findings from the TUI code review: 1. /resume was dead-ended — registered + in /help + tested as a builtin, but no dispatch branch. Wired it to thread resolution / the switcher. 2. --resume <title> was forwarded raw into the checkpointer (blank thread). Added Session.resolve_ref() to resolve id-or-title via list_threads; used by --resume and /resume. 3. str(get("id","")) returned "None" for an explicit id:None (truthy), defeating the empty-id guard so unrelated null-id tool calls collapsed into one card. Coerce via a None-safe helper. 4. Headless --print/--json no longer spin up the persistence loop/engine/pool (open_session(persistence=False)). 5. _LoopThread + engine are now closed: Session.close() (dispose engine + stop loop) called from a try/finally around app.run(). 6. --cli --continue (and piped --cli) now run headless instead of erroring. 7. Cancelled runs no longer persist a truncated title (guard on _cancelled). 8. Palette highlight resets to the top when the filter set changes. 9. Dropped the never-populated tools count from the header. 10. Documented the `not row.error` merge guard. Adds regression tests for each; 126 TUI tests pass, ruff check + format clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
145 lines
5.5 KiB
Python
145 lines
5.5 KiB
Python
"""Tests for the runtime bridge: StreamEvent -> reducer actions.
|
|
|
|
The translation layer is pure and is exercised here against real
|
|
``StreamEvent`` objects plus a fake client, with no Textual involved.
|
|
"""
|
|
|
|
from deerflow.client import StreamEvent
|
|
from deerflow.tui.runtime import stream_actions, translate
|
|
from deerflow.tui.view_state import (
|
|
AssistantDelta,
|
|
AssistantError,
|
|
RunEnded,
|
|
RunStarted,
|
|
ThreadTitle,
|
|
ToolResult,
|
|
ToolStarted,
|
|
initial_state,
|
|
reduce,
|
|
)
|
|
|
|
|
|
def test_translate_ai_text_delta():
|
|
event = StreamEvent(type="messages-tuple", data={"type": "ai", "content": "Hello", "id": "m1"})
|
|
actions = translate(event)
|
|
assert actions == [AssistantDelta(id="m1", text="Hello")]
|
|
|
|
|
|
def test_translate_ai_tool_call_emits_tool_started_not_empty_delta():
|
|
event = StreamEvent(
|
|
type="messages-tuple",
|
|
data={
|
|
"type": "ai",
|
|
"content": "",
|
|
"id": "m1",
|
|
"tool_calls": [{"name": "read_file", "args": {"path": "a.py"}, "id": "t1"}],
|
|
},
|
|
)
|
|
actions = translate(event)
|
|
assert actions == [ToolStarted(tool_call_id="t1", tool_name="read_file", args={"path": "a.py"})]
|
|
|
|
|
|
def test_translate_ai_content_blocks_list_extracts_text():
|
|
event = StreamEvent(
|
|
type="messages-tuple",
|
|
data={"type": "ai", "content": [{"type": "text", "text": "abc"}, {"type": "text", "text": "def"}], "id": "m9"},
|
|
)
|
|
actions = translate(event)
|
|
assert actions == [AssistantDelta(id="m9", text="abcdef")]
|
|
|
|
|
|
def test_translate_tool_call_with_none_id_yields_empty_id():
|
|
# Some providers' first tool-call chunk has id=None; it must coerce to "" (not
|
|
# "None"), so the empty-id guard in the reducer drops the noise chunk.
|
|
event = StreamEvent(
|
|
type="messages-tuple",
|
|
data={"type": "ai", "content": "", "id": "m1", "tool_calls": [{"id": None, "name": None, "args": {}}]},
|
|
)
|
|
assert translate(event) == [ToolStarted(tool_call_id="", tool_name="", args={})]
|
|
|
|
|
|
def test_translate_tool_result_with_none_id_yields_empty_id():
|
|
event = StreamEvent(type="messages-tuple", data={"type": "tool", "content": "x", "name": None, "tool_call_id": None})
|
|
assert translate(event) == [ToolResult(tool_call_id="", content="x", is_error=False, tool_name="")]
|
|
|
|
|
|
def test_translate_tool_result_with_error_status():
|
|
event = StreamEvent(
|
|
type="messages-tuple",
|
|
data={"type": "tool", "content": "boom", "name": "bash", "tool_call_id": "t1", "status": "error"},
|
|
)
|
|
actions = translate(event)
|
|
assert actions == [ToolResult(tool_call_id="t1", content="boom", is_error=True, tool_name="bash")]
|
|
|
|
|
|
def test_translate_end_event_carries_usage():
|
|
usage = {"input_tokens": 3, "output_tokens": 7, "total_tokens": 10}
|
|
actions = translate(StreamEvent(type="end", data={"usage": usage}))
|
|
assert actions == [RunEnded(usage=usage)]
|
|
|
|
|
|
def test_translate_values_surfaces_title_only():
|
|
assert translate(StreamEvent(type="values", data={"title": "My Thread", "messages": []})) == [ThreadTitle(title="My Thread")]
|
|
assert translate(StreamEvent(type="values", data={"title": None, "messages": []})) == []
|
|
assert translate(StreamEvent(type="custom", data={"anything": 1})) == []
|
|
|
|
|
|
class _FakeClient:
|
|
def __init__(self, events):
|
|
self._events = events
|
|
self.calls = []
|
|
|
|
def stream(self, message, *, thread_id=None, **kwargs):
|
|
self.calls.append((message, thread_id))
|
|
yield from self._events
|
|
|
|
|
|
def test_stream_actions_brackets_with_run_started_and_ended():
|
|
client = _FakeClient(
|
|
[
|
|
StreamEvent(type="messages-tuple", data={"type": "ai", "content": "Hi", "id": "m1"}),
|
|
StreamEvent(type="end", data={"usage": {"total_tokens": 5}}),
|
|
]
|
|
)
|
|
actions = list(stream_actions(client, "hello", thread_id="th-1"))
|
|
assert isinstance(actions[0], RunStarted)
|
|
assert isinstance(actions[-1], RunEnded)
|
|
assert client.calls == [("hello", "th-1")]
|
|
|
|
|
|
def test_stream_actions_reduces_to_expected_transcript():
|
|
client = _FakeClient(
|
|
[
|
|
StreamEvent(type="messages-tuple", data={"type": "ai", "content": "Let me look. ", "id": "m1"}),
|
|
StreamEvent(
|
|
type="messages-tuple",
|
|
data={"type": "ai", "content": "", "id": "m1", "tool_calls": [{"name": "read_file", "args": {"path": "a.py"}, "id": "t1"}]},
|
|
),
|
|
StreamEvent(type="messages-tuple", data={"type": "tool", "content": "file body", "name": "read_file", "tool_call_id": "t1"}),
|
|
StreamEvent(type="messages-tuple", data={"type": "ai", "content": "Done.", "id": "m2"}),
|
|
StreamEvent(type="end", data={"usage": {"total_tokens": 9}}),
|
|
]
|
|
)
|
|
state = initial_state()
|
|
for action in stream_actions(client, "go"):
|
|
state = reduce(state, action)
|
|
|
|
kinds = [r.kind for r in state.rows]
|
|
assert kinds == ["assistant", "tool", "assistant"]
|
|
assert state.rows[0].text == "Let me look. "
|
|
assert state.rows[1].status == "ok"
|
|
assert state.rows[2].text == "Done."
|
|
assert state.streaming is False
|
|
assert state.usage == {"total_tokens": 9}
|
|
|
|
|
|
class _BoomClient:
|
|
def stream(self, message, *, thread_id=None, **kwargs):
|
|
yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "partial", "id": "m1"})
|
|
raise RuntimeError("model down")
|
|
|
|
|
|
def test_stream_actions_surfaces_exception_as_error_then_ends():
|
|
actions = list(stream_actions(_BoomClient(), "go"))
|
|
assert any(isinstance(a, AssistantError) and "model down" in a.text for a in actions)
|
|
assert isinstance(actions[-1], RunEnded)
|