deer-flow/backend/tests/test_tui_app.py
Daoyuan Li 13afef6278
fix(tui): interrupt an active run before /quit exits (#4235)
_handle_builtin's "quit" branch called self.exit() unconditionally,
unlike action_interrupt (Ctrl+C), which checks self._streaming and
interrupts before any teardown. During an active run, /quit tore the
app down while the worker thread was still live; the next
call_from_thread call from that orphaned thread then failed silently
(the app's loop is gone), quietly abandoning the in-flight turn and any
post-run persistence such as the thread title.

Mirror action_interrupt's check: if self._streaming, run the same
interrupt/cleanup path before calling self.exit(). /quit still always
exits -- it now just does so safely.

Adds a regression test using a fake client that blocks mid-stream (a
real worker thread genuinely stuck, not a hand-flipped flag) to catch
/quit during a run, plus a Ctrl+C contrast test under the same setup.
2026-07-17 15:48:45 +08:00

342 lines
12 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 pytest
from deerflow.client import StreamEvent
from deerflow.tui.app import DeerFlowTUI
from deerflow.tui.cli import LaunchPlan
class _FakeClient:
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):
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()
@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
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)
# /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_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_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.
"""
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)
for ch in "/quit":
await pilot.press(ch)
await pilot.press("enter")
await pilot.pause()
# The run must be interrupted (state cleaned up, message surfaced)
# before the app is allowed to exit — /quit still quits, but safely.
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 True
# Unblock the worker thread so it doesn't leak past the test.
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_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_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)