mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* 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>
1082 lines
38 KiB
Python
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
|