diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ffddf2f9e..2afa327a1f 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index a4b6b910b8..027ccdd227 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -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 diff --git a/README.md b/README.md index 93ecf90c1a..e5981e872a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/packages/harness/deerflow/tui/AGENTS.md b/backend/packages/harness/deerflow/tui/AGENTS.md index e2f07e1da9..566acee4ee 100644 --- a/backend/packages/harness/deerflow/tui/AGENTS.md +++ b/backend/packages/harness/deerflow/tui/AGENTS.md @@ -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 diff --git a/backend/packages/harness/deerflow/tui/app.py b/backend/packages/harness/deerflow/tui/app.py index b974269068..f1ac19bc55 100644 --- a/backend/packages/harness/deerflow/tui/app.py +++ b/backend/packages/harness/deerflow/tui/app.py @@ -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 diff --git a/backend/tests/test_tui_app.py b/backend/tests/test_tui_app.py index 58e83e173f..a8bd167b04 100644 --- a/backend/tests/test_tui_app.py +++ b/backend/tests/test_tui_app.py @@ -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")) diff --git a/backend/tests/test_tui_interrupted_run_drain.py b/backend/tests/test_tui_interrupted_run_drain.py index 61cbcec2b6..a2827f342e 100644 --- a/backend/tests/test_tui_interrupted_run_drain.py +++ b/backend/tests/test_tui_interrupted_run_drain.py @@ -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() diff --git a/backend/tests/test_tui_run_isolation.py b/backend/tests/test_tui_run_isolation.py index 62acc3411e..77d1452d9c 100644 --- a/backend/tests/test_tui_run_isolation.py +++ b/backend/tests/test_tui_run_isolation.py @@ -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()