mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* fix(tui): keep a refused message in the composer on_composer_input_submitted cleared the composer before _send_to_agent decided whether it could start a run. A prompt typed during an active run, while an interrupted run on the same thread was still stopping, or when the worker failed to start was replaced by a notice and survived only in input history. _send_to_agent now reports whether it started a run, and the submit handler puts the exact submitted text back with the cursor at its end, so Enter resends it. Accepted messages and local slash commands clear the composer as before. * fix(tui): keep a launch message that fails to start in the composer on_mount sent the launch message through _send_to_agent and ignored the result, so a worker start failure lost the message: it never reached the composer or input history. Route both the launch message and composer submissions through one _restore_draft helper. * docs(changelog): reference #6666 for the TUI refused-message fix * fix(tui): keep the palette closed over a restored slash draft Restoring a refused bare skill activation such as /tdd posted TextArea.Changed, which reopened the command palette, so Enter filled the skill instead of resending it. Remember the restored draft and keep the palette closed until it is edited. Restore the history copy of the text too, so a trimmed slash command matches what Up recalls.
884 lines
31 KiB
Python
884 lines
31 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 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
|
|
async def test_multiline_paste_reaches_agent_without_dropping_lines():
|
|
session = _FakeSession()
|
|
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
|
|
pasted = '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")
|
|
await _wait_until(lambda: bool(session.client.stream_calls), pilot)
|
|
|
|
assert session.client.stream_calls[0][0] == pasted
|
|
|
|
|
|
@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.
|
|
"""
|
|
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_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
|