deer-flow/backend/tests/test_tui_app.py
Hyeonsang Cho 3d2ed725f6
fix(tui): let quit skip an interrupted run's unfinished step (#7326)
* fix(tui): let quit skip an interrupted run's unfinished step

After Ctrl+C the synchronous agent stream keeps running until its next
event. The run was a Textual thread worker, i.e. a thread on asyncio's
default executor, which App.run() joins on shutdown (up to 300 s on
3.12) and interpreter exit joins again with no timeout. Quitting while
a long step was still running therefore hung silently after the
terminal had been restored, and further Ctrl+C presses only printed a
traceback.

Run workers on a daemon thread instead. Quit now waits visibly for
running workers (an interrupted step still checkpoints, a finished run
still saves its title) and refuses sends meanwhile; a second quit
request exits at once. Report unexpected worker exceptions as an error
row and end the run, since Textual no longer catches them.

* docs(changelog): reference #7326 for the TUI quit-after-interrupt fix

---------

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

1082 lines
38 KiB
Python

"""Integration tests for the Textual app via the pilot harness.
Uses a fake in-process session so no real model is invoked. Exercises the full
loop: keypress -> submit -> worker thread -> stream_actions -> reducer -> state.
"""
import asyncio
import threading
import time
import pytest
from textual import events
from textual.containers import VerticalScroll
from deerflow.client import StreamEvent
from deerflow.tui.app import DeerFlowTUI
from deerflow.tui.cli import LaunchPlan
from deerflow.tui.view_state import SystemMessage
class _FakeClient:
def __init__(self):
self.stream_calls: list[tuple] = []
def list_models(self):
return {"models": [{"name": "fake-model", "display_name": "Fake Model"}]}
def list_skills(self, enabled_only=False):
return {"skills": [{"name": "tdd", "enabled": True}]}
def stream(self, message, *, thread_id=None, **kwargs):
self.stream_calls.append((message, thread_id, kwargs))
yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "Hello ", "id": "m1"})
yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "world", "id": "m1"})
yield StreamEvent(type="end", data={"usage": {"total_tokens": 3}})
class _FakeSession:
def __init__(self):
self.client = _FakeClient()
def resolve_thread(self, plan):
return None
async def _wait_until(predicate, pilot, *, timeout=3.0):
deadline = 0.0
while deadline < timeout:
await pilot.pause()
if predicate():
return True
await asyncio.sleep(0.02)
deadline += 0.02
return predicate()
async def _fill_scrollable_transcript(app: DeerFlowTUI, pilot) -> VerticalScroll:
for index in range(80):
app._dispatch(SystemMessage(f"row {index}: " + "content " * 12))
await pilot.pause()
scroll = app.query_one("#scroll", VerticalScroll)
assert scroll.max_scroll_y > 0
assert scroll.is_vertical_scroll_end
return scroll
@pytest.mark.asyncio
async def test_app_runs_a_turn_and_renders_streamed_assistant():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("h", "i")
await pilot.press("enter")
await _wait_until(
lambda: not app._streaming and any(r.kind == "assistant" for r in app.state.rows),
pilot,
)
kinds = [r.kind for r in app.state.rows]
assert "user" in kinds
assert "assistant" in kinds
assistant = [r for r in app.state.rows if r.kind == "assistant"][-1]
assert assistant.text == "Hello world"
assert app.state.usage == {"total_tokens": 3}
@pytest.mark.asyncio
@pytest.mark.parametrize("prefix", ["", "/tdd\n", "/tdd\t"])
async def test_multiline_paste_reaches_agent_without_dropping_lines(prefix):
session = _FakeSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
pasted = prefix + 'Traceback:\n File "worker.py", line 7\nRuntimeError: boom'
async with app.run_test() as pilot:
await pilot.pause()
app.post_message(events.Paste(pasted))
await pilot.pause()
await pilot.press("enter")
assert await _wait_until(lambda: bool(session.client.stream_calls), pilot)
assert session.client.stream_calls[0][0] == pasted
@pytest.mark.asyncio
@pytest.mark.parametrize("separator", [" ", "\t", "\n", "\u00a0", "\u2003", "\u202f", "\u3000"])
async def test_pasted_whitespace_closes_palette_and_enter_submits_skill(separator):
session = _FakeSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
pasted = f"/tdd{separator}"
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("slash", "t", "d", "d")
assert app._palette_open
app.post_message(events.Paste(separator))
await pilot.pause()
assert app.query_one("#composer").value == pasted
palette_open_after_separator = app._palette_open
await pilot.press("enter")
assert await _wait_until(lambda: bool(session.client.stream_calls), pilot)
assert not palette_open_after_separator
assert not app._palette_open
assert len(session.client.stream_calls) == 1
assert session.client.stream_calls[0][0] == "/tdd"
assert app._history.entries() == ["/tdd"]
@pytest.mark.asyncio
async def test_multiline_arrows_move_lines_before_input_history():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._history.add("previous prompt")
app.post_message(events.Paste("first line\nsecond line"))
await pilot.pause()
composer = app.query_one("#composer")
assert composer.cursor_location == (1, 11)
await pilot.press("up")
await pilot.pause()
assert composer.value == "first line\nsecond line"
assert composer.cursor_location[0] == 0
await pilot.press("down")
await pilot.pause()
assert composer.value == "first line\nsecond line"
assert composer.cursor_location[0] == 1
composer.cursor_position = 0
await pilot.press("up")
await pilot.pause()
assert composer.value == "previous prompt"
@pytest.mark.asyncio
@pytest.mark.parametrize("draft", ["unsent question", "first line\nsecond line"])
async def test_up_with_empty_history_preserves_draft_and_cursor(draft):
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app.post_message(events.Paste(draft))
await pilot.pause()
composer = app.query_one("#composer")
location = (0, 2)
composer.move_cursor(location)
await pilot.press("up", "up")
await pilot.pause()
assert composer.value == draft
assert composer.cursor_location == location
@pytest.mark.asyncio
async def test_up_with_empty_history_preserves_undo():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app.post_message(events.Paste("unsent question"))
await pilot.pause()
await pilot.press("up", "ctrl+z")
await pilot.pause()
assert app.query_one("#composer").value == ""
@pytest.mark.asyncio
async def test_app_assigns_thread_id_on_first_send():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
assert app._conv_thread_id is None
await pilot.press("y", "o")
await pilot.press("enter")
await _wait_until(lambda: app._conv_thread_id is not None, pilot)
assert app._conv_thread_id is not None
@pytest.mark.asyncio
async def test_help_command_renders_system_row_without_calling_agent():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
for ch in "/help":
await pilot.press(ch)
await pilot.press("enter")
await _wait_until(lambda: any(r.kind == "system" for r in app.state.rows), pilot)
assert any(r.kind == "system" for r in app.state.rows)
assert any(r.kind == "system" and "/clear" in r.text for r in app.state.rows)
# Commands previously missing from the hardcoded help string must now appear,
# since the help text is derived from the command registry.
help_rows = [r for r in app.state.rows if r.kind == "system"]
for command in ("/help", "/resume", "/switch", "/uploads", "/artifacts", "/details"):
assert any(command in r.text for r in help_rows), command
# /help must not produce a user turn or start streaming.
assert not any(r.kind == "user" for r in app.state.rows)
@pytest.mark.asyncio
async def test_help_text_matches_command_registry():
from deerflow.tui.command_registry import format_command_help
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
for ch in "/help":
await pilot.press(ch)
await pilot.press("enter")
await _wait_until(lambda: any(r.kind == "system" for r in app.state.rows), pilot)
expected = format_command_help()
assert any(r.kind == "system" and expected in r.text for r in app.state.rows)
@pytest.mark.asyncio
async def test_clear_command_clears_display_without_resetting_thread_or_calling_agent():
session = _FakeSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._conv_thread_id = "thread-123"
app._dispatch(SystemMessage("visible row"))
await pilot.pause()
app._handle_submit("/clear")
await pilot.pause()
assert app.state.rows == ()
assert app._conv_thread_id == "thread-123"
assert session.client.stream_calls == []
@pytest.mark.asyncio
async def test_up_arrow_recalls_previous_input_from_history():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
for ch in "remember me":
await pilot.press("space" if ch == " " else ch)
await pilot.press("enter")
await _wait_until(lambda: any(r.kind == "user" for r in app.state.rows), pilot)
# Composer is empty after submit; Up should recall the last entry.
await pilot.press("up")
await pilot.pause()
assert app.query_one("#composer").value == "remember me"
@pytest.mark.asyncio
async def test_escape_interrupts_an_active_run():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._streaming = True
app._refresh_status()
await pilot.press("escape")
await pilot.pause()
assert app._streaming is False
assert any(r.kind == "system" and "Interrupt" in r.text for r in app.state.rows)
# --------------------------------------------------------------------------- #
# /quit vs. Ctrl+C during an active stream
# --------------------------------------------------------------------------- #
class _BlockedClient(_FakeClient):
"""A client whose stream() blocks mid-run until the test releases it.
Unlike ``_FakeClient``, which finishes a turn synchronously, this lets a
test catch the app while a real worker thread is genuinely stuck inside
the streaming generator — the same shape as a live agent turn — instead
of only flipping the ``_streaming`` flag by hand.
"""
def __init__(self):
self.release = threading.Event()
def stream(self, message, *, thread_id=None, **kwargs):
self.release.wait(timeout=5)
yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "done", "id": "m1"})
yield StreamEvent(type="end", data={"usage": {"total_tokens": 1}})
class _BlockedSession(_FakeSession):
def __init__(self):
self.client = _BlockedClient()
@pytest.mark.asyncio
async def test_clear_command_is_blocked_during_active_stream():
session = _BlockedSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("h", "i")
await pilot.press("enter")
await _wait_until(lambda: app._streaming, pilot)
app._handle_submit("/clear")
await pilot.pause()
assert app._streaming is True
assert any(r.kind == "user" and r.text == "hi" for r in app.state.rows)
assert any(r.kind == "system" and "Still working" in r.text for r in app.state.rows)
session.client.release.set()
await _wait_until(lambda: not app._streaming, pilot)
@pytest.mark.asyncio
async def test_new_command_is_blocked_during_active_stream():
session = _BlockedSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("h", "i")
await pilot.press("enter")
await _wait_until(lambda: app._streaming and app._conv_thread_id is not None, pilot)
active_thread_id = app._conv_thread_id
app._handle_submit("/new")
await pilot.pause()
assert app._streaming is True
assert app._conv_thread_id == active_thread_id
assert any(r.kind == "user" and r.text == "hi" for r in app.state.rows)
assert any(r.kind == "system" and "Still working" in r.text for r in app.state.rows)
assert not any(r.kind == "system" and "Started a new thread" in r.text for r in app.state.rows)
session.client.release.set()
await _wait_until(lambda: not app._streaming, pilot)
@pytest.mark.asyncio
async def test_quit_interrupts_an_active_stream_before_exiting():
"""/quit during a run must interrupt first, mirroring Ctrl+C.
Regression test: ``_handle_builtin``'s "quit" branch used to call
``self.exit()`` unconditionally, unlike ``action_interrupt`` (Ctrl+C),
which checks ``self._streaming`` and interrupts before any teardown.
Left unfixed, the worker thread survives the app's exit; its next
``call_from_thread`` call then fails silently and the in-flight turn is
abandoned without a trace. The interrupted worker is still inside its
step, so the app says it is waiting and exits once that step returns.
"""
session = _BlockedSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
try:
await pilot.pause()
await pilot.press("h", "i")
await pilot.press("enter")
await _wait_until(lambda: app._streaming, pilot)
for ch in "/quit":
await pilot.press(ch)
await pilot.press("enter")
await pilot.pause()
assert app._streaming is False
assert any(r.kind == "system" and "Interrupt" in r.text for r in app.state.rows)
assert any(r.kind == "system" and "Press Ctrl+C again to quit now" in r.text for r in app.state.rows)
assert app._exit is False
session.client.release.set()
assert await _wait_until(lambda: app._exit, pilot)
finally:
session.client.release.set()
@pytest.mark.asyncio
async def test_ctrl_c_interrupts_an_active_stream_without_exiting():
"""Contrast/control: Ctrl+C on a real blocked worker interrupts but stays open."""
session = _BlockedSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("h", "i")
await pilot.press("enter")
await _wait_until(lambda: app._streaming, pilot)
await pilot.press("ctrl+c")
await pilot.pause()
assert app._streaming is False
assert any(r.kind == "system" and "Interrupt" in r.text for r in app.state.rows)
assert app._exit is False
session.client.release.set()
@pytest.mark.asyncio
async def test_ctrl_c_quit_waits_for_the_interrupted_run_and_refuses_new_sends():
session = _BlockedSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
try:
await pilot.pause()
await pilot.press("h", "i")
await pilot.press("enter")
await _wait_until(lambda: app._streaming, pilot)
await pilot.press("ctrl+c") # interrupt
# Another thread is free to send to, but not while quitting: the
# exit would cut that new run off as soon as the old one stops.
app._handle_submit("/new")
await pilot.press("ctrl+c") # quit
await pilot.pause()
assert app._exit is False
assert any(r.kind == "system" and "Press Ctrl+C again to quit now" in r.text for r in app.state.rows)
app.query_one("#composer").value = "one more"
await pilot.press("enter")
await pilot.pause()
assert not app._streaming
assert app.query_one("#composer").value == "one more"
assert not any(r.kind == "user" and r.text == "one more" for r in app.state.rows)
session.client.release.set()
assert await _wait_until(lambda: app._exit, pilot)
finally:
session.client.release.set()
@pytest.mark.asyncio
async def test_second_quit_request_exits_without_waiting_for_the_interrupted_run():
session = _BlockedSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
try:
await pilot.pause()
await pilot.press("h", "i")
await pilot.press("enter")
await _wait_until(lambda: app._streaming, pilot)
await pilot.press("ctrl+c") # interrupt
await pilot.press("ctrl+c") # quit: waits
await pilot.press("ctrl+c") # quit now
await pilot.pause()
assert app._exit is True
assert app._run.worker_running()
finally:
session.client.release.set()
@pytest.mark.asyncio
async def test_quit_waits_for_a_completed_run_still_saving_its_title():
class _BlockingTitleWriter:
def __init__(self):
self.writing = threading.Event()
self.release = threading.Event()
self.titles = []
def ensure_created(self, *args, **kwargs):
pass
def set_title(self, thread_id, title):
self.writing.set()
self.release.wait(5)
self.titles.append(title)
class _TitledClient(_FakeClient):
def stream(self, message, *, thread_id=None, **kwargs):
yield StreamEvent(type="values", data={"title": "Saved title"})
yield StreamEvent(type="end", data={"usage": {}})
session = _FakeSession()
session.client = _TitledClient()
session.writer = _BlockingTitleWriter()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
try:
await pilot.pause()
await pilot.press("h", "i")
await pilot.press("enter")
assert await _wait_until(lambda: session.writer.writing.is_set() and not app._streaming, pilot)
await pilot.press("ctrl+c")
await pilot.pause()
assert app._exit is False
session.writer.release.set()
assert await _wait_until(lambda: app._exit, pilot)
assert session.writer.titles == ["Saved title"]
finally:
session.writer.release.set()
@pytest.mark.asyncio
async def test_unexpected_worker_failure_ends_the_run_with_an_error_row():
class _FailingWriter:
def ensure_created(self, *args, **kwargs):
raise RuntimeError("thread store exploded")
session = _FakeSession()
session.writer = _FailingWriter()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("h", "i")
await pilot.press("enter")
assert await _wait_until(lambda: not app._streaming, pilot)
assert any(r.kind == "assistant" and r.error and "thread store exploded" in r.text for r in app.state.rows)
assert app._run.stopped.is_set()
assert session.client.stream_calls == []
_FORCE_QUIT_SCRIPT = """
import threading, time
from deerflow.client import StreamEvent
from deerflow.tui.app import DeerFlowTUI
from deerflow.tui.cli import LaunchPlan
from deerflow.tui.session import Session
in_step = threading.Event()
class Client:
def list_models(self): return {"models": []}
def list_skills(self, **kwargs): return {"skills": []}
def stream(self, message, *, thread_id=None, **kwargs):
yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "working", "id": "m1"})
in_step.set()
time.sleep(60) # a long tool step that emits no event
yield StreamEvent(type="end", data={"usage": {}})
class App(DeerFlowTUI):
def on_mount(self):
super().on_mount()
self._send_to_agent("q")
self.set_interval(0.05, self._press_ctrl_c_once_in_step)
def _press_ctrl_c_once_in_step(self):
if in_step.is_set() and not self._exit:
for _ in range(3): # interrupt, quit, quit now
self.action_interrupt()
App(Session(client=Client(), writer=None), LaunchPlan(mode="tui", thread_id="t-1")).run(headless=True)
print("exited")
"""
def test_quit_now_exits_the_process_while_the_interrupted_step_is_still_running():
"""Drive the real ``App.run()``: a Textual thread worker would be joined by
asyncio's default-executor shutdown and again at interpreter exit, holding
the process until the 60 s step ended."""
import subprocess
import sys
started = time.monotonic()
result = subprocess.run([sys.executable, "-c", _FORCE_QUIT_SCRIPT], capture_output=True, text=True, timeout=45)
assert result.returncode == 0, result.stderr
assert result.stdout.strip().endswith("exited")
assert time.monotonic() - started < 30
@pytest.mark.asyncio
async def test_tab_keeps_focus_on_composer_when_palette_closed():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
composer = app.query_one("#composer")
assert app.focused is composer
await pilot.press("tab")
await pilot.pause()
# Tab must not move focus off the composer to the scroll region.
assert app.focused is composer
@pytest.mark.asyncio
async def test_page_keys_scroll_transcript_without_moving_composer_focus():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
composer = app.query_one("#composer")
scroll = await _fill_scrollable_transcript(app, pilot)
bottom = scroll.scroll_y
await pilot.press("pageup")
await pilot.pause()
assert scroll.scroll_y < bottom
assert app.focused is composer
for _ in range(3):
if scroll.is_vertical_scroll_end:
break
await pilot.press("pagedown")
await pilot.pause()
assert scroll.is_vertical_scroll_end
assert app.focused is composer
@pytest.mark.asyncio
async def test_transcript_update_does_not_cancel_pending_page_scroll():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
scroll = await _fill_scrollable_transcript(app, pilot)
bottom = scroll.scroll_y
app.action_transcript_page_up()
app._dispatch(SystemMessage("output before the queued scroll is applied"))
await pilot.pause()
assert scroll.scroll_y < bottom
assert not scroll.is_vertical_scroll_end
@pytest.mark.asyncio
async def test_transcript_refresh_preserves_manual_scroll_until_returning_to_end():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
scroll = await _fill_scrollable_transcript(app, pilot)
await pilot.press("pageup")
await pilot.pause()
reading_position = scroll.scroll_y
assert not scroll.is_vertical_scroll_end
app._dispatch(SystemMessage("new streamed output"))
await pilot.pause()
assert scroll.scroll_y == reading_position
assert not scroll.is_vertical_scroll_end
for _ in range(3):
if scroll.is_vertical_scroll_end:
break
await pilot.press("pagedown")
await pilot.pause()
assert scroll.is_vertical_scroll_end
app._dispatch(SystemMessage("new output after returning to the end"))
await pilot.pause()
assert scroll.is_vertical_scroll_end
@pytest.mark.asyncio
async def test_transcript_refresh_preserves_non_key_scroll_position():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
scroll = await _fill_scrollable_transcript(app, pilot)
scroll.scroll_page_up(animate=False)
await pilot.pause()
reading_position = scroll.scroll_y
assert not scroll.is_vertical_scroll_end
app._dispatch(SystemMessage("new output after external scrolling"))
await pilot.pause()
assert scroll.scroll_y == reading_position
@pytest.mark.asyncio
async def test_unknown_command_shows_error_system_row():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
for ch in "/nope":
await pilot.press(ch)
await pilot.press("enter")
await _wait_until(
lambda: any(r.kind == "system" and getattr(r, "tone", "") == "error" for r in app.state.rows),
pilot,
)
assert any(r.kind == "system" and r.tone == "error" for r in app.state.rows)
# --------------------------------------------------------------------------- #
# /goal handler
# --------------------------------------------------------------------------- #
class _GoalClient(_FakeClient):
"""Records goal API calls and keeps an in-memory active goal."""
def __init__(self):
self.calls: list[tuple] = []
self.goal: dict | None = None
def get_goal(self, thread_id):
self.calls.append(("get", thread_id))
return {"goal": self.goal}
def set_goal(self, thread_id, objective):
self.calls.append(("set", thread_id, objective))
self.goal = {"objective": objective, "status": "active"}
return {"goal": self.goal}
def clear_goal(self, thread_id):
self.calls.append(("clear", thread_id))
self.goal = None
return {"goal": None}
class _GoalSession(_FakeSession):
def __init__(self):
self.client = _GoalClient()
def _system_rows(app):
return [r for r in app.state.rows if r.kind == "system"]
@pytest.mark.asyncio
async def test_goal_set_mints_thread_and_reports_objective():
session = _GoalSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
assert app._conv_thread_id is None
app._handle_goal("finish the work")
await pilot.pause()
assert app._conv_thread_id is not None
assert ("set", app._conv_thread_id, "finish the work") in session.client.calls
assert any("Goal set: finish the work" in r.text for r in _system_rows(app))
@pytest.mark.asyncio
async def test_goal_status_without_thread_reports_no_active_goal():
session = _GoalSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._handle_goal("")
await pilot.pause()
# No thread yet -> no gateway round-trip.
assert session.client.calls == []
assert any(r.text == "No active goal." for r in _system_rows(app))
@pytest.mark.asyncio
async def test_goal_status_reports_active_objective():
session = _GoalSession()
session.client.goal = {"objective": "ship it", "status": "active"}
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._conv_thread_id = "t-1"
app._handle_goal("")
await pilot.pause()
assert ("get", "t-1") in session.client.calls
assert any("Goal: ship it" in r.text for r in _system_rows(app))
@pytest.mark.asyncio
async def test_goal_clear_calls_gateway_and_confirms():
session = _GoalSession()
session.client.goal = {"objective": "ship it", "status": "active"}
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._conv_thread_id = "t-1"
app._handle_goal("clear")
await pilot.pause()
assert ("clear", "t-1") in session.client.calls
assert any(r.text == "Goal cleared." for r in _system_rows(app))
@pytest.mark.asyncio
async def test_goal_set_failure_shows_error_tone():
class _Boom(_GoalClient):
def set_goal(self, thread_id, objective):
raise RuntimeError("gateway down")
session = _GoalSession()
session.client = _Boom()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._conv_thread_id = "t-1"
app._handle_goal("do it")
await pilot.pause()
errors = [r for r in _system_rows(app) if r.tone == "error"]
assert any("Could not set goal." in r.text for r in errors)
@pytest.mark.asyncio
async def test_multiline_paste_keeps_leading_indentation_and_trailing_newline():
session = _FakeSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
pasted = " def f():\n return 1\n"
async with app.run_test() as pilot:
await pilot.pause()
app.post_message(events.Paste(pasted))
await pilot.pause()
await pilot.press("enter")
await _wait_until(lambda: bool(session.client.stream_calls), pilot)
assert session.client.stream_calls[0][0] == pasted
assert app._history.entries() == [pasted]
@pytest.mark.asyncio
async def test_whitespace_only_input_is_not_submitted():
session = _FakeSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app.post_message(events.Paste(" \n\t\n"))
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert session.client.stream_calls == []
assert app._history.entries() == []
@pytest.mark.asyncio
async def test_message_refused_during_a_run_stays_in_the_composer():
session = _FakeSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
pasted = " def f():\n return 1\n"
async with app.run_test() as pilot:
await pilot.pause()
app._streaming = True
app.post_message(events.Paste(pasted))
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
composer = app.query_one("#composer")
assert composer.value == pasted
assert composer.cursor_position == len(pasted)
assert any(r.kind == "system" and "Still working" in r.text for r in app.state.rows)
assert session.client.stream_calls == []
app._streaming = False
await pilot.press("enter")
await _wait_until(lambda: bool(session.client.stream_calls), pilot)
assert composer.value == ""
assert session.client.stream_calls[0][0] == pasted
assert app._history.entries() == [pasted]
@pytest.mark.asyncio
async def test_refused_bare_skill_activation_is_restored_without_reopening_the_palette():
"""Restoring "/tdd" must not reopen the palette, or Enter would fill it instead of resending."""
session = _FakeSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._streaming = True
await pilot.press("/", "t", "d", "d", "escape", "enter")
await pilot.pause()
composer = app.query_one("#composer")
assert composer.value == "/tdd"
assert app._palette_open is False
app._streaming = False
await pilot.press("enter")
await _wait_until(lambda: bool(session.client.stream_calls), pilot)
assert composer.value == ""
assert session.client.stream_calls[0][0] == "/tdd"
@pytest.mark.asyncio
async def test_editing_a_restored_slash_draft_reopens_the_palette():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._streaming = True
await pilot.press("/", "t", "d", "d", "escape", "enter")
await pilot.pause()
await pilot.press("backspace")
await pilot.pause()
assert app.query_one("#composer").value == "/td"
assert app._palette_open is True
# Once edited, the draft is ordinary input again, even if retyped back.
await pilot.press("d")
await pilot.pause()
assert app.query_one("#composer").value == "/tdd"
assert app._palette_open is True
@pytest.mark.asyncio
async def test_refused_skill_activation_is_restored_as_recorded_in_history():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._streaming = True
app.post_message(events.Paste(" /tdd write tests "))
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert app.query_one("#composer").value == "/tdd write tests"
assert app._history.entries() == ["/tdd write tests"]
@pytest.mark.asyncio
async def test_down_on_last_line_of_multiline_input_falls_back_to_history():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._history.add("first line\nsecond line")
await pilot.press("up")
await pilot.pause()
composer = app.query_one("#composer")
assert composer.value == "first line\nsecond line"
assert composer.cursor_location[0] == 1
# Already on the last document line: Down leaves the recalled entry for the draft.
await pilot.press("down")
await pilot.pause()
assert composer.value == ""
@pytest.mark.asyncio
@pytest.mark.parametrize("history", [[], ["previous prompt"]])
@pytest.mark.parametrize("draft", ["unsent question", "first line\nsecond line"])
async def test_down_without_history_navigation_preserves_draft_and_cursor(history, draft):
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
for entry in history:
app._history.add(entry)
app.post_message(events.Paste(draft))
await pilot.pause()
composer = app.query_one("#composer")
location = (len(draft.splitlines()) - 1, 2)
composer.move_cursor(location)
await pilot.press("down", "down")
await pilot.pause()
assert composer.value == draft
assert composer.cursor_location == location
await pilot.press("ctrl+z")
await pilot.pause()
assert composer.value == ""
@pytest.mark.asyncio
async def test_history_round_trip_restores_draft_and_idle_down_keeps_later_edits():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
draft = "first line\nsecond line"
async with app.run_test() as pilot:
await pilot.pause()
app._history.add("older prompt")
app._history.add("newer prompt")
app.post_message(events.Paste(draft))
await pilot.pause()
composer = app.query_one("#composer")
composer.cursor_position = 0
await pilot.press("up")
await pilot.pause()
assert composer.value == "newer prompt"
await pilot.press("up")
await pilot.pause()
assert composer.value == "older prompt"
await pilot.press("down")
await pilot.pause()
assert composer.value == "newer prompt"
await pilot.press("down")
await pilot.pause()
assert composer.value == draft
app.post_message(events.Paste(" edited"))
await pilot.pause()
await pilot.press("down")
await pilot.pause()
assert composer.value == draft + " edited"
@pytest.mark.asyncio
async def test_slash_command_with_surrounding_whitespace_still_runs_as_command():
session = _FakeSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app.post_message(events.Paste(" /help\n"))
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert session.client.stream_calls == []
assert app._history.entries() == ["/help"]
_LONG_PROMPT = " ".join(f"word{i}" for i in range(60)) # one logical line that soft-wraps
@pytest.mark.asyncio
@pytest.mark.parametrize("direction", ["up", "down"])
async def test_equal_history_recall_preserves_cursor_and_undo(direction):
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._history.add("same prompt")
if direction == "down":
# Down will restore the same draft that Up saved.
app._history.up("same prompt")
app.post_message(events.Paste("same prompt"))
await pilot.pause()
composer = app.query_one("#composer")
composer.move_cursor((0, 2))
await pilot.press(direction)
await pilot.pause()
assert composer.cursor_location == (0, 2)
await pilot.press("ctrl+z")
await pilot.pause()
assert composer.value == ""
@pytest.mark.asyncio
async def test_up_inside_a_soft_wrapped_line_moves_the_cursor_not_history():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test(size=(60, 24)) as pilot:
await pilot.pause()
app._history.add("previous prompt")
composer = app.query_one("#composer")
composer.value = _LONG_PROMPT
composer.cursor_position = len(_LONG_PROMPT)
await pilot.pause()
assert len(composer.wrapped_document.get_offsets(0)) >= 1 # really wrapped
await pilot.press("up")
await pilot.pause()
assert composer.value == _LONG_PROMPT
assert composer.cursor_position < len(_LONG_PROMPT)
@pytest.mark.asyncio
async def test_down_inside_a_soft_wrapped_line_moves_the_cursor_not_history():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
async with app.run_test(size=(60, 24)) as pilot:
await pilot.pause()
composer = app.query_one("#composer")
composer.value = _LONG_PROMPT
composer.cursor_position = 0
await pilot.pause()
assert len(composer.wrapped_document.get_offsets(0)) >= 1
await pilot.press("down")
await pilot.pause()
assert composer.value == _LONG_PROMPT
assert composer.cursor_position > 0