mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 05:30: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.
221 lines
8.6 KiB
Python
221 lines
8.6 KiB
Python
"""A new send must not race an interrupted run that is still writing its thread.
|
|
|
|
Textual cannot kill a thread worker, so after Ctrl+C the synchronous agent
|
|
stream keeps running until its next event. When it is inside a long tool call,
|
|
that step still completes and checkpoints. A new run on the same thread started
|
|
meanwhile forks from the older checkpoint, and whichever run checkpoints last
|
|
becomes the thread's state, so the other turn silently drops out of it.
|
|
|
|
These tests drive the real TUI through Textual's pilot with a client that
|
|
streams a real compiled LangGraph graph backed by a checkpointer.
|
|
"""
|
|
|
|
import asyncio
|
|
import operator
|
|
import threading
|
|
from typing import Annotated, TypedDict
|
|
|
|
import pytest
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from langgraph.graph import END, START, StateGraph
|
|
|
|
from deerflow.client import StreamEvent
|
|
from deerflow.tui.app import DeerFlowTUI
|
|
from deerflow.tui.cli import LaunchPlan
|
|
from deerflow.tui.session import Session
|
|
|
|
STILL_STOPPING = "The interrupted run is still stopping on this thread"
|
|
|
|
|
|
class _State(TypedDict):
|
|
messages: Annotated[list, operator.add]
|
|
|
|
|
|
class _GraphClient:
|
|
"""Streams a real checkpointed graph; "old question" blocks in a tool step."""
|
|
|
|
def __init__(self):
|
|
self.old_in_tool = threading.Event()
|
|
self.release_old_tool = threading.Event()
|
|
self.calls: list[tuple[str, str | None]] = []
|
|
self.finished: set[str] = set()
|
|
self.active: dict[str | None, int] = {}
|
|
self.max_active_per_thread = 0
|
|
self._lock = threading.Lock()
|
|
|
|
def tool_step(state):
|
|
last = state["messages"][-1]
|
|
if last == "old question":
|
|
self.old_in_tool.set()
|
|
if not self.release_old_tool.wait(10):
|
|
raise TimeoutError("old tool step was not released")
|
|
return {"messages": ["old tool result"]}
|
|
return {"messages": [f"answer to {last}"]}
|
|
|
|
graph = StateGraph(_State)
|
|
graph.add_node("tool_step", tool_step)
|
|
graph.add_edge(START, "tool_step")
|
|
graph.add_edge("tool_step", END)
|
|
self.graph = graph.compile(checkpointer=InMemorySaver())
|
|
|
|
def list_models(self):
|
|
return {"models": []}
|
|
|
|
def list_skills(self, **kwargs):
|
|
return {"skills": []}
|
|
|
|
def list_threads(self, **kwargs):
|
|
return {"thread_list": []}
|
|
|
|
def messages(self, thread_id: str) -> list:
|
|
return self.graph.get_state({"configurable": {"thread_id": thread_id}}).values.get("messages", [])
|
|
|
|
def stream(self, message, *, thread_id=None, **kwargs):
|
|
self.calls.append((message, thread_id))
|
|
with self._lock:
|
|
self.active[thread_id] = self.active.get(thread_id, 0) + 1
|
|
self.max_active_per_thread = max(self.max_active_per_thread, self.active[thread_id])
|
|
try:
|
|
for _ in self.graph.stream({"messages": [message]}, {"configurable": {"thread_id": thread_id}}, stream_mode="values"):
|
|
yield StreamEvent(type="values", data={})
|
|
yield StreamEvent(type="end", data={"usage": {}})
|
|
finally:
|
|
with self._lock:
|
|
self.active[thread_id] -= 1
|
|
self.finished.add(message)
|
|
|
|
|
|
async def _settle(pilot, predicate):
|
|
for _ in range(500):
|
|
await pilot.pause()
|
|
if predicate():
|
|
return
|
|
await asyncio.sleep(0.01)
|
|
raise AssertionError("Expected controlled UI event did not arrive")
|
|
|
|
|
|
async def _submit(app, pilot, text: str) -> None:
|
|
app.query_one("#composer").value = text
|
|
await pilot.press("enter")
|
|
|
|
|
|
def _system_texts(app) -> list[str]:
|
|
return [row.text for row in app.state.rows if row.kind == "system"]
|
|
|
|
|
|
async def _interrupt_old_run_in_its_tool_step(app, pilot, client) -> None:
|
|
await _submit(app, pilot, "old question")
|
|
await _settle(pilot, client.old_in_tool.is_set)
|
|
await pilot.press("ctrl+c")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_waits_for_the_interrupted_run_before_reusing_its_thread():
|
|
client = _GraphClient()
|
|
app = DeerFlowTUI(Session(client=client, writer=None), LaunchPlan(mode="tui", thread_id="thread-a"))
|
|
async with app.run_test() as pilot:
|
|
try:
|
|
await pilot.pause()
|
|
await _interrupt_old_run_in_its_tool_step(app, pilot, client)
|
|
|
|
await _submit(app, pilot, "new question")
|
|
await pilot.pause()
|
|
assert any(STILL_STOPPING in text for text in _system_texts(app))
|
|
assert [message for message, _ in client.calls] == ["old question"]
|
|
assert not app._streaming
|
|
assert app.query_one("#composer").value == "new question"
|
|
|
|
client.release_old_tool.set()
|
|
await _settle(pilot, lambda: "old question" in client.finished and app._run.stopped.is_set())
|
|
await pilot.press("enter")
|
|
await _settle(pilot, lambda: "new question" in client.finished and not app._streaming)
|
|
finally:
|
|
client.release_old_tool.set()
|
|
|
|
assert client.max_active_per_thread == 1
|
|
assert client.messages(app._conv_thread_id) == ["old question", "old tool result", "new question", "answer to new question"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_stopping_run_does_not_block_another_thread():
|
|
client = _GraphClient()
|
|
app = DeerFlowTUI(Session(client=client, writer=None), LaunchPlan(mode="tui", thread_id="thread-a"))
|
|
async with app.run_test() as pilot:
|
|
try:
|
|
await pilot.pause()
|
|
await _interrupt_old_run_in_its_tool_step(app, pilot, client)
|
|
old_thread = app._conv_thread_id
|
|
|
|
await _submit(app, pilot, "/new")
|
|
await _submit(app, pilot, "new question")
|
|
await _settle(pilot, lambda: "new question" in client.finished and not app._streaming)
|
|
new_thread = app._conv_thread_id
|
|
|
|
assert not any(STILL_STOPPING in text for text in _system_texts(app))
|
|
assert not client.release_old_tool.is_set()
|
|
finally:
|
|
client.release_old_tool.set()
|
|
await _settle(pilot, lambda: "old question" in client.finished)
|
|
|
|
assert new_thread != old_thread
|
|
assert client.messages(new_thread) == ["new question", "answer to new question"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_interrupt_before_the_worker_starts_does_not_block_the_thread(monkeypatch):
|
|
# Textual may cancel a queued thread worker before its function runs; that
|
|
# run never touches the backend and must not hold its thread.
|
|
client = _GraphClient()
|
|
app = DeerFlowTUI(Session(client=client, writer=None), LaunchPlan(mode="tui", thread_id="thread-a"))
|
|
async with app.run_test() as pilot:
|
|
await pilot.pause()
|
|
original_run_worker = app.run_worker
|
|
monkeypatch.setattr(app, "run_worker", lambda *args, **kwargs: None)
|
|
await _submit(app, pilot, "never started")
|
|
await pilot.press("ctrl+c")
|
|
monkeypatch.setattr(app, "run_worker", original_run_worker)
|
|
|
|
await _submit(app, pilot, "new question")
|
|
await _settle(pilot, lambda: "new question" in client.finished and not app._streaming)
|
|
|
|
assert not any(STILL_STOPPING in text for text in _system_texts(app))
|
|
assert [message for message, _ in client.calls] == ["new question"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_completed_run_still_saving_its_title_does_not_block_the_next_send():
|
|
# Only interrupted runs are waited for: after a normal completion the agent
|
|
# stream is done and the worker only writes the thread title.
|
|
class _BlockingTitleWriter:
|
|
def __init__(self):
|
|
self.writing = threading.Event()
|
|
self.release = threading.Event()
|
|
|
|
def ensure_created(self, *args, **kwargs):
|
|
pass
|
|
|
|
def set_title(self, thread_id, title):
|
|
self.writing.set()
|
|
self.release.wait(10)
|
|
|
|
class _TitledClient(_GraphClient):
|
|
def stream(self, message, *, thread_id=None, **kwargs):
|
|
yield StreamEvent(type="values", data={"title": f"title for {message}"})
|
|
yield from super().stream(message, thread_id=thread_id, **kwargs)
|
|
|
|
client, writer = _TitledClient(), _BlockingTitleWriter()
|
|
app = DeerFlowTUI(Session(client=client, writer=writer), LaunchPlan(mode="tui", thread_id="thread-a"))
|
|
async with app.run_test() as pilot:
|
|
try:
|
|
await pilot.pause()
|
|
await _submit(app, pilot, "first question")
|
|
await _settle(pilot, writer.writing.is_set)
|
|
await _settle(pilot, lambda: not app._streaming)
|
|
|
|
await _submit(app, pilot, "second question")
|
|
await _settle(pilot, lambda: "second question" in client.finished)
|
|
finally:
|
|
writer.release.set()
|
|
|
|
assert not any(STILL_STOPPING in text for text in _system_texts(app))
|