deer-flow/backend/tests/test_tui_app.py
Loveca 067c9736b6
feat(goal): add /goal from-chat for IM channels (#8200)
* feat(goal): add /goal from-chat for IM channels

First slice of #7050: add an explicit IM command that composes the active goal
from the recent conversation instead of taking the text literally.
Natural-language intent detection remains out of scope.

Gateway:
- POST /api/threads/{thread_id}/goal/from-chat synthesizes the objective
  from the thread summary and recent agreement turns (visible user/assistant
  text, clarification questions, and card answers), preserving agreed
  constraints and the stopping condition.
- Reject a busy thread before the model call; the save reuses the existing
  checkpoint-write reservation so a run that starts mid-synthesis still wins.
- Refuse to synthesize while a human-input request is unanswered, and return
  structured no_context/unclear/synthesis_failed outcomes without echoing
  model-generated reasons.

IM:
- /goal from-chat [hint] shows the saved objective, then starts exactly one
  run through the ordinary chat path; a longer read timeout covers synthesis.
- Map busy/pending/unclear/no-context/timeout outcomes to localized replies;
  a saved goal whose run cannot start is reported honestly.
- The TUI explains that from-chat is not supported yet; the web input box
  still treats it literally (documented follow-up).

Docs: README, CHANGELOG, IM channel guide, TUI guide, AGENTS, and the
acceptance plan/execution record. Tests: new route, synthesis and manager
suites, plus parser and TUI coverage.

* fix(goal): keep the from-chat confirmation visible on every channel

The saved-objective reply was published as a non-final progress update.
QQ and DingTalk card mode drop non-final outbounds before a stream exists,
and Feishu/Telegram fold the update into the run's stream, so the run
output overwrote the objective. Publish it as the turn's final message.

Redact PII on whole lines and the summary before the context budget can
split an identifier; the composed prompt is still redacted once more as
defense in depth, matching the goal evaluator's direct call.

Also correct the gateway/IM docs (from-chat never creates a checkpoint,
the busy pre-check is worker-local, and synthesis uses the deployment
default model), fix the route test clock patch, and add regression tests
for the final-outbound and truncation-boundary cases.

* fix(goal): align from-chat with the goal-route admission gates

Reject a from-chat save while a tool approval is pending, matching
PUT/DELETE /goal, and suppress subagent-batch continuations queued under
the previous goal after the save. Both invariants landed upstream while
this branch was in review; the new route now shares them.

Add route tests for both behaviors.

* docs(changelog): remove the from-chat entry to avoid conflicts

The maintainer asked to drop the changelog entry because the section conflicts with upstream changes. The feature remains documented in the goals guide and README.

* fix(goal): close three review gaps in /goal from-chat

- Enforce model-use authorization for the deployment default model before
  the synthesis call, matching the suggestions route, so a denied role
  never sends conversation context to the model.
- Save with expected_checkpoint_id pinned to the head the synthesis read;
  a write that landed during the model call is a 409 goal_changed instead
  of silently clobbering the newer goal.
- Re-check the conversation binding before starting the run: a /new during
  synthesis keeps the goal in the original thread and reports that the
  task did not start instead of running it in the switched conversation.

* fix(goal): preserve from-chat handoff and run status

Pin the saved thread through run preparation and reject a changed binding.
Read configuration and model authorization off the event loop, and report
rejected starts separately from requests whose response was lost.

Cover the handoff races and streaming/wait admission outcomes through the
real channel manager and SDK transport, plus file-backed config under the
strict blocking-I/O detector.

Co-authored-by: Loveca <44801706+Loveca@users.noreply.github.com>

* fix(goal): preserve reasoning tags in synthesis JSON

Use the existing JSON-aware reasoning stripper so literal tag text in
objective and reason values survives parsing. Cover complete and unclosed
tags while still removing model reasoning before the JSON payload.

Co-authored-by: Loveca <44801706+Loveca@users.noreply.github.com>

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Totoro <msy626836554@gmail.com>
2026-11-13 14:22:58 +08:00

1103 lines
39 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_goal_from_chat_is_explained_not_saved_as_literal():
"""The shared parser yields from_chat; the TUI must not fall through to set.
Saving the guidance as a literal objective would be the exact bug the
first-PR design calls out: the TUI has no synthesis wiring yet, so it
explains instead, and makes no gateway call.
"""
session = _GoalSession()
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("from-chat focus on the vendor total")
await pilot.pause()
assert session.client.calls == []
rows = _system_rows(app)
assert any("from-chat" in r.text and "/goal" in r.text for r in rows)
assert not any("Goal set" in r.text for r in rows)
@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