fix(tui): keep every word of an unquoted --print/--json message (#6636)

* fix(tui): keep every word of an unquoted --print/--json message

--print and --json took a single optional 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 ignored.

Both flags now take every following word and fall back to words given
before them, as --cli does; a typed message wins over piped stdin. A
message split around the flag is refused with exit 2, because argparse
loses the word order between the two parts.

* docs(changelog): reference #6636

* fix(tui): refuse --print and --json together

Only one headless mode runs, so the other flag's words were dropped:
'--print hello --json world' ran with 'hello', and '--print --json hello'
asked for a message. Put both flags in a mutually exclusive group so argparse
exits 2 for every combination.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-10-11 13:52:11 +09:00 committed by GitHub
parent cb4393879d
commit 5116434712
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 117 additions and 9 deletions

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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(

View File

@ -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"