diff --git a/CHANGELOG.md b/CHANGELOG.md index 51291d3c03..0ffddf2f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4156,6 +4156,17 @@ This release closes that milestone with **439 merged pull requests**. missing; `verify-versions.yml` installs the uv version the backend image pins. ([#6588]) +- **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`, + `deerflow summarize this repo --print` exited 2 asking for a message on a + terminal, and with piped stdin the typed words were silently ignored. Both flags now take every word + that follows them and fall back to words given before them, as `--cli` does; + a typed message wins over piped stdin. A message split around the flag + (`deerflow hello --print there`) exits 2 with a hint to quote it, since its + word order cannot be recovered. Passing both `--print` and `--json` now exits + 2 instead of running `--print` and dropping the `--json` message. ([#6636]) + ### Security - **authz:** Enforce permission checks on routes that only had authentication, and @@ -9565,3 +9576,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#6611]: https://github.com/bytedance/deer-flow/pull/6611 [#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 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 97a66fc5ef..a4b6b910b8 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -3193,6 +3193,15 @@ 规范化交给 uv,`2.1.0-rc0` 仍与 `2.1.0rc0` 匹配),缺少 `uv` 时直接失败; `verify-versions.yml` 会安装与 backend 镜像相同的固定 uv 版本。([#6588]) +- **TUI:** `deerflow --print` 和 `--json` 不再丢弃未加引号消息中的词。此前这两个 + 选项只接收一个值,也从不读取位置参数,因此 `deerflow --print summarize this repo` + 只会发送 `summarize`,`deerflow summarize this repo --print` 在终端中会以退出码 2 + 要求提供消息,而有管道 stdin 时命令行上输入的词会被悄悄忽略。现在两个选项会接收 + 其后的所有词,并像 `--cli` 一样在没有值时使用选项之前的词;命令行上输入的消息 + 优先于管道 stdin。消息被选项分隔在两侧时(`deerflow hello --print there`), + 由于词序无法还原,会以退出码 2 退出并提示加引号。同时传入 `--print` 和 `--json` + 现在会以退出码 2 退出,而不是运行 `--print` 并丢弃 `--json` 的消息。([#6636]) + ### 安全 - **鉴权:** 仅有认证而无权限校验的路由现在会强制执行权限检查,且 @@ -7690,3 +7699,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#6611]: https://github.com/bytedance/deer-flow/pull/6611 [#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 diff --git a/backend/docs/TUI.md b/backend/docs/TUI.md index 453d219df6..86bbbdf686 100644 --- a/backend/docs/TUI.md +++ b/backend/docs/TUI.md @@ -33,6 +33,12 @@ Launch modes: | `DEER_FLOW_TUI=1 deerflow` | Force the TUI via environment | | `DEER_FLOW_TUI_TRANSPARENT=1 deerflow` | Persist terminal-background rendering via environment | +`--print` and `--json` keep every word of an unquoted message, and words before +a bare flag (`deerflow summarize this repo --print`) are the message, as with +`--cli`. A message given on the command line wins over piped stdin. Words on +both sides of the flag are rejected with exit 2, since their order cannot be +recovered. `--print` and `--json` cannot be combined; pick one output mode. + If no TTY is available and no headless flag is given, `deerflow` prints guidance instead of hanging. diff --git a/backend/packages/harness/deerflow/tui/cli.py b/backend/packages/harness/deerflow/tui/cli.py index 208500c445..0eccbbbb69 100644 --- a/backend/packages/harness/deerflow/tui/cli.py +++ b/backend/packages/harness/deerflow/tui/cli.py @@ -55,21 +55,21 @@ def build_parser() -> argparse.ArgumentParser: epilog="Extension management: deerflow extensions --help", add_help=True, ) - parser.add_argument("message", nargs="*", help="initial prompt for the TUI, or message in --cli mode") - parser.add_argument( + parser.add_argument("message", nargs="*", help="initial prompt for the TUI, or the message for --print, --json or --cli") + # One headless mode runs per invocation; refuse both so neither message is dropped. + headless = parser.add_mutually_exclusive_group() + headless.add_argument( "--print", dest="print", - nargs="?", - const=None, + nargs="*", default=_UNSET, metavar="MESSAGE", help="headless one-shot: print the final answer and exit (reads stdin if no MESSAGE)", ) - parser.add_argument( + headless.add_argument( "--json", dest="json", - nargs="?", - const=None, + nargs="*", default=_UNSET, metavar="MESSAGE", help="headless streaming: emit newline-delimited JSON StreamEvents and exit", @@ -100,6 +100,18 @@ def _strip_chat(argv: Sequence[str]) -> list[str]: return argv +def _headless_message(parser: argparse.ArgumentParser, flag: str, values: list[str], positional: str | None) -> str | None: + """Return the words after a ``--print``/``--json`` flag, or else the positional words. + + argparse loses the order between the flag's words and positional words, so a + message split around the flag is refused instead of reassembled by guess. + """ + message = " ".join(values).strip() or None + if message and positional: + parser.error(f'{flag}: pass the message in one place, e.g. deerflow {flag} "your question"') + return message or positional + + def _truthy(value: object) -> bool: return isinstance(value, str) and value.strip().lower() in {"1", "true", "yes", "on"} @@ -122,7 +134,7 @@ def plan_launch( parser.error("--recursion-limit requires --print, --json, or --cli") if args.print is not _UNSET: - message = args.print if isinstance(args.print, str) else None + message = _headless_message(parser, "--print", args.print, positional) if message is None and stdin_isatty: return LaunchPlan(mode="headless-help", reason="--print needs a MESSAGE argument or piped stdin.") return LaunchPlan( @@ -135,7 +147,7 @@ def plan_launch( ) if args.json is not _UNSET: - message = args.json if isinstance(args.json, str) else None + message = _headless_message(parser, "--json", args.json, positional) if message is None and stdin_isatty: return LaunchPlan(mode="headless-help", reason="--json needs a MESSAGE argument or piped stdin.") return LaunchPlan( diff --git a/backend/tests/test_tui_cli.py b/backend/tests/test_tui_cli.py index 00a153238e..7929dd99fa 100644 --- a/backend/tests/test_tui_cli.py +++ b/backend/tests/test_tui_cli.py @@ -67,6 +67,74 @@ def test_json_mode(): assert p.message == "hello" +@pytest.mark.parametrize("mode", ["--print", "--json"]) +def test_headless_unquoted_message_keeps_every_word(mode): + """An unquoted message must not be truncated to its first word.""" + p = plan([mode, "summarize", "this", "repo"]) + assert p.mode == mode.removeprefix("--") + assert p.message == "summarize this repo" + assert p.read_stdin is False + + +@pytest.mark.parametrize("mode", ["--print", "--json"]) +def test_headless_message_before_the_flag_is_used(mode): + """Words before a bare --print/--json are the message, as with --cli.""" + p = plan(["summarize", "this", "repo", mode]) + assert p.mode == mode.removeprefix("--") + assert p.message == "summarize this repo" + assert p.read_stdin is False + + +@pytest.mark.parametrize("mode", ["--print", "--json"]) +def test_headless_message_wins_over_piped_stdin(mode): + p = plan(["hello", mode], stdin_tty=False) + assert p.message == "hello" + assert p.read_stdin is False + + +@pytest.mark.parametrize( + "argv", + [ + ["hello", "--print", "there"], + ["--print", "hello", "--continue", "there"], + ["hello", "--json", "there"], + ], +) +def test_headless_message_split_around_the_flag_is_rejected(argv, capsys): + """Word order across the flag is lost by argparse, so refuse rather than guess.""" + with pytest.raises(SystemExit) as exc: + plan(argv) + assert exc.value.code == 2 + assert "pass the message in one place" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "argv", + [ + ["--print", "hello", "--json", "world"], + ["--print", "--json", "hello"], + ["--json", "hello", "--print"], + ["hello", "--print", "--json"], + ], +) +def test_print_and_json_together_are_rejected(argv, capsys): + """Only one headless mode runs, so the other flag's message must not vanish.""" + with pytest.raises(SystemExit) as exc: + plan(argv, stdin_tty=False) + assert exc.value.code == 2 + assert "not allowed with argument" in capsys.readouterr().err + + +@pytest.mark.parametrize("mode", ["--print", "--json"]) +def test_headless_blank_message_is_treated_as_missing(mode): + p = plan([mode, " "]) + assert p.mode == "headless-help" + + +def test_headless_help_documents_multi_word_messages(): + assert "--print [MESSAGE ...]" in build_parser().format_help() + + def test_force_tui_even_without_tty(): p = plan(["--tui"], stdin_tty=False, stdout_tty=False) assert p.mode == "tui"