mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(tui): keep a refused message in the composer (#6666)
* 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.
This commit is contained in:
parent
a68e3064e1
commit
2ca0b79f73
11
CHANGELOG.md
11
CHANGELOG.md
@ -4156,6 +4156,16 @@ This release closes that milestone with **439 merged pull requests**.
|
||||
missing; `verify-versions.yml` installs the uv version the backend image pins.
|
||||
([#6588])
|
||||
|
||||
- **tui:** A message the TUI refuses to send now stays in the composer. Submitting
|
||||
cleared the composer before the send was checked, so a prompt typed during an
|
||||
active run, while an interrupted run was still stopping, or when the run could
|
||||
not start was replaced by a notice and survived only in input history; a
|
||||
command-line launch message whose run could not start was lost entirely. The
|
||||
composer now gets the text back as input history recorded it, with the cursor
|
||||
at its end and the command palette closed, so `Enter` resends it; accepted
|
||||
messages and local slash commands clear it as before.
|
||||
([#6666])
|
||||
|
||||
- **tui:** `deerflow --print` and `--json` no longer drop words of an unquoted
|
||||
message. Each flag took a single value and never read the positional words,
|
||||
so `deerflow --print summarize this repo` sent only `summarize`,
|
||||
@ -9577,3 +9587,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
||||
[#6613]: https://github.com/bytedance/deer-flow/pull/6613
|
||||
[#6635]: https://github.com/bytedance/deer-flow/pull/6635
|
||||
[#6636]: https://github.com/bytedance/deer-flow/pull/6636
|
||||
[#6666]: https://github.com/bytedance/deer-flow/pull/6666
|
||||
|
||||
@ -3193,6 +3193,13 @@
|
||||
规范化交给 uv,`2.1.0-rc0` 仍与 `2.1.0rc0` 匹配),缺少 `uv` 时直接失败;
|
||||
`verify-versions.yml` 会安装与 backend 镜像相同的固定 uv 版本。([#6588])
|
||||
|
||||
- **TUI:** TUI 拒绝发送的消息现在会保留在输入框中。此前提交时会先清空输入框再检查能否发送,
|
||||
因此在运行进行中、被中断的运行尚未停止,或运行无法启动时输入的提示会被一条提示信息取代,
|
||||
只能在输入历史中找回;命令行传入的启动消息在运行无法启动时则会完全丢失。
|
||||
现在输入框会按输入历史记录的文本恢复消息,把光标放在末尾并保持命令面板关闭,
|
||||
按 `Enter` 即可重新发送;
|
||||
被接受的消息和本地斜杠命令仍会像以前一样清空输入框。([#6666])
|
||||
|
||||
- **TUI:** `deerflow --print` 和 `--json` 不再丢弃未加引号消息中的词。此前这两个
|
||||
选项只接收一个值,也从不读取位置参数,因此 `deerflow --print summarize this repo`
|
||||
只会发送 `summarize`,`deerflow summarize this repo --print` 在终端中会以退出码 2
|
||||
@ -7700,3 +7707,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
||||
[#6613]: https://github.com/bytedance/deer-flow/pull/6613
|
||||
[#6635]: https://github.com/bytedance/deer-flow/pull/6635
|
||||
[#6636]: https://github.com/bytedance/deer-flow/pull/6636
|
||||
[#6666]: https://github.com/bytedance/deer-flow/pull/6666
|
||||
|
||||
@ -2915,6 +2915,7 @@ A tool call already in progress still finishes, so until it does a new prompt in
|
||||
that conversation is held back with a notice instead of racing it; `/new` and
|
||||
`/resume` stay available.
|
||||
If a run cannot start, the TUI reports an error and returns to idle so you can retry.
|
||||
A message that is held back or cannot start stays in the composer, so `Enter` resends it.
|
||||
|
||||
At the last composer row, `Down` leaves an unsent draft untouched unless you are
|
||||
browsing input history; after recalling history, it moves forward to restore your
|
||||
|
||||
@ -22,6 +22,10 @@ Reserve the run identity and busy state when accepting input, before its worker
|
||||
starts. Each run owns its cancellation flag; a later send must not reset it.
|
||||
If worker creation fails, cancel and clear that reserved run, restore idle state,
|
||||
and show a retryable error. Do not cancel a previously completed run's title write.
|
||||
`_send_to_agent` returns False whenever it does not start a run (busy, a stopping
|
||||
interrupted run, worker start failure); the submit handler and the launch message
|
||||
then restore the text as history recorded it via `_restore_draft`, which keeps the
|
||||
palette closed until that draft is edited. Local slash commands still clear it.
|
||||
Worker callbacks check both run identity and thread id on UI delivery, rejecting
|
||||
cancelled, completed, or superseded runs before updating the reducer. Keep the
|
||||
completed run's state available for its worker's title persistence check. An
|
||||
|
||||
@ -216,6 +216,7 @@ class DeerFlowTUI(App):
|
||||
self._palette_open = False
|
||||
self._palette_items: list = []
|
||||
self._palette_index = 0
|
||||
self._restored_draft: str | None = None
|
||||
self._history = InputHistory()
|
||||
self._transcript_dirty = False
|
||||
self._transcript_scroll_pending = False
|
||||
@ -241,8 +242,8 @@ class DeerFlowTUI(App):
|
||||
self.set_interval(0.1, self._tick_spinner)
|
||||
self.set_interval(0.06, self._flush_transcript) # coalesce streaming re-renders
|
||||
self.query_one("#composer", ComposerInput).focus()
|
||||
if self.plan and getattr(self.plan, "message", None):
|
||||
self._send_to_agent(self.plan.message)
|
||||
if self.plan and getattr(self.plan, "message", None) and not self._send_to_agent(self.plan.message):
|
||||
self._restore_draft(self.plan.message)
|
||||
|
||||
# ----- session info -------------------------------------------------- #
|
||||
|
||||
@ -276,13 +277,28 @@ class DeerFlowTUI(App):
|
||||
if not stripped:
|
||||
return
|
||||
self._history.add(text)
|
||||
self._handle_submit(text)
|
||||
if not self._handle_submit(text):
|
||||
self._restore_draft(text)
|
||||
|
||||
def _restore_draft(self, text: str) -> None:
|
||||
# A message that did not start is still the user's draft: put it back so
|
||||
# Enter resends it, instead of leaving it only in input history. A bare
|
||||
# "/skill" would reopen the palette, where Enter fills instead of sends,
|
||||
# so the palette stays closed until the draft is edited.
|
||||
self._restored_draft = text
|
||||
composer = self.query_one("#composer", ComposerInput)
|
||||
composer.value = text
|
||||
composer.cursor_position = len(text)
|
||||
|
||||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||
if not isinstance(event.text_area, ComposerInput):
|
||||
return
|
||||
value = event.text_area.value
|
||||
event.text_area.set_class("\n" in value, "multiline")
|
||||
if value == self._restored_draft:
|
||||
self._close_palette()
|
||||
return
|
||||
self._restored_draft = None
|
||||
if value.startswith("/") and " " not in value and "\n" not in value:
|
||||
from .command_registry import build_registry, filter_commands
|
||||
|
||||
@ -423,19 +439,20 @@ class DeerFlowTUI(App):
|
||||
composer.cursor_position = len(composer.value)
|
||||
self._close_palette()
|
||||
|
||||
def _handle_submit(self, text: str) -> None:
|
||||
def _handle_submit(self, text: str) -> bool:
|
||||
"""Route a submission; return False when a message for the agent did not start."""
|
||||
from .command_registry import resolve
|
||||
|
||||
res = resolve(text, skills=self._skill_names)
|
||||
if res.kind == "builtin":
|
||||
self._handle_builtin(res.name, res.args)
|
||||
return
|
||||
return True
|
||||
if res.kind == "unknown":
|
||||
self._dispatch(SystemMessage(f"Unknown command /{res.name}. Try /help.", tone="error"))
|
||||
return
|
||||
return True
|
||||
# plain message or skill activation (/skill task) both go to the agent,
|
||||
# which applies skill-activation semantics on the raw text.
|
||||
self._send_to_agent(text)
|
||||
return self._send_to_agent(text)
|
||||
|
||||
def _dispatch_still_working(self) -> None:
|
||||
self._dispatch(SystemMessage("Still working — wait for the current run to finish.", tone="info"))
|
||||
@ -658,10 +675,11 @@ class DeerFlowTUI(App):
|
||||
|
||||
# ----- agent run ----------------------------------------------------- #
|
||||
|
||||
def _send_to_agent(self, text: str) -> None:
|
||||
def _send_to_agent(self, text: str) -> bool:
|
||||
"""Start a run for ``text``; return False when it was refused or failed to start."""
|
||||
if self._streaming:
|
||||
self._dispatch_still_working()
|
||||
return
|
||||
return False
|
||||
# An interrupted worker cannot be killed: it stops at its next stream
|
||||
# event, so a long tool call keeps running and then checkpoints. A new
|
||||
# run on the same thread would race it, and whichever checkpoint lands
|
||||
@ -669,7 +687,7 @@ class DeerFlowTUI(App):
|
||||
self._interrupted_runs = [run for run in self._interrupted_runs if run.worker_running()]
|
||||
if any(run.thread_id == self._conv_thread_id for run in self._interrupted_runs):
|
||||
self._dispatch(SystemMessage("The interrupted run is still stopping on this thread. Send again once it finishes, or use /new or /resume to continue elsewhere.", tone="info"))
|
||||
return
|
||||
return False
|
||||
if self._conv_thread_id is None:
|
||||
self._conv_thread_id = str(uuid.uuid4())
|
||||
run = _Run(self._conv_thread_id)
|
||||
@ -688,6 +706,8 @@ class DeerFlowTUI(App):
|
||||
self._run = None
|
||||
self._streaming = False
|
||||
self._dispatch(SystemMessage("Could not start the run. Please try again.", tone="error"))
|
||||
return False
|
||||
return True
|
||||
|
||||
def _stream_worker(self, text: str, run: _Run) -> None:
|
||||
# Mark the start before checking cancellation: an interrupt either
|
||||
|
||||
@ -637,6 +637,94 @@ async def test_whitespace_only_input_is_not_submitted():
|
||||
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"))
|
||||
|
||||
@ -123,10 +123,11 @@ async def test_send_waits_for_the_interrupted_run_before_reusing_its_thread():
|
||||
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 _submit(app, pilot, "new question")
|
||||
await pilot.press("enter")
|
||||
await _settle(pilot, lambda: "new question" in client.finished and not app._streaming)
|
||||
finally:
|
||||
client.release_old_tool.set()
|
||||
|
||||
@ -212,6 +212,7 @@ async def test_send_reserves_busy_state_before_worker_starts(monkeypatch):
|
||||
app.query_one("#composer").value = "new question"
|
||||
await pilot.press("enter")
|
||||
assert any(row.kind == "system" and "Still working" in row.text for row in app.state.rows)
|
||||
assert app.query_one("#composer").value == "new question"
|
||||
await pilot.press("ctrl+c")
|
||||
release.set()
|
||||
client.release_old.set()
|
||||
@ -249,6 +250,7 @@ async def test_worker_start_failure_restores_idle_state_and_allows_retry(monkeyp
|
||||
assert any(row.kind == "system" and row.tone == "error" for row in app.state.rows)
|
||||
assert "private worker startup detail" not in str(app.state.rows)
|
||||
assert client.calls == []
|
||||
assert app.query_one("#composer").value == "old question"
|
||||
|
||||
idle_state = app.state
|
||||
for action in [RunStarted(), AssistantDelta(id="failed-answer", text="stale"), ThreadTitle("Failed title"), RunEnded(usage={"total_tokens": 999})]:
|
||||
@ -267,6 +269,28 @@ async def test_worker_start_failure_restores_idle_state_and_allows_retry(monkeyp
|
||||
assert not any(row.kind == "system" and "Still working" in row.text for row in app.state.rows)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_launch_message_that_fails_to_start_stays_in_the_composer(monkeypatch):
|
||||
client = _Client()
|
||||
app = DeerFlowTUI(Session(client=client), LaunchPlan(mode="tui", thread_id="thread-a", message="launch question"))
|
||||
original_start = app.run_worker
|
||||
|
||||
def fail_agent_worker(*args, **kwargs):
|
||||
if kwargs.get("group") == "agent":
|
||||
raise RuntimeError("Cannot start the launch worker")
|
||||
return original_start(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(app, "run_worker", fail_agent_worker)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert not app._streaming
|
||||
assert any(row.kind == "system" and row.tone == "error" for row in app.state.rows)
|
||||
composer = app.query_one("#composer")
|
||||
assert composer.value == "launch question"
|
||||
assert composer.cursor_position == len("launch question")
|
||||
assert client.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_run_rejects_a_duplicate_terminal_callback(monkeypatch):
|
||||
client, writer = _Client(), _Writer()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user