deer-flow/backend/tests/test_tui_cli_main.py
xiaodu55 f67b1f574e
fix(tui): report headless --print/--json failures instead of leaking tracebacks (#5963)
* fix(tui): report headless --print/--json failures instead of leaking tracebacks

The headless --print and --json runners dispatched straight into the
embedded client with no error boundary, so session-creation, thread-
resolution, chat, and stream failures escaped main() and surfaced as
unhandled Python tracebacks from the console script - for example the
documented CheckpointModeMismatchError case, which the checkpoint
operations/troubleshooting docs recorded as a raw traceback.

Both runners now catch Exception (KeyboardInterrupt/SystemExit still
propagate), mirroring the TUI's stream_actions() boundary:

- --print writes one concise 'Error: ...' line to stderr and returns 1
- --json appends a terminal {"type": "error"} record to the NDJSON
  stream (diagnostics stay on stderr) and returns 1; every stdout line
  remains independently valid JSON, including failures mid-stream

The checkpoint operations/troubleshooting docs (en + zh) no longer
describe the headless runners as printing an uncaught traceback.

Fixes #5962

* fix(tui): close the broken-pipe edge and document the --json error contract

Review follow-ups on #5963 (both non-blocking):

- print(answer) sat outside the headless boundary, so a consumer closing
  the pipe early (deerflow --print ... | head) still got a
  BrokenPipeError traceback plus the interpreter's shutdown-flush noise.
  The final --print write and the --json terminal error-record write now
  catch BrokenPipeError, point the stdout descriptor at devnull (the
  documented SIGPIPE-adjacent recipe) and exit 1 without a traceback.
- The terminal {"type": "error"} record is a real contract for --json
  scripters but was only documented in the checkpoint pages; the
  backend/docs/TUI.md command table and the README cheat line now state
  the error record and the exit-1 semantics where CLI consumers look.
2026-09-28 16:16:40 +08:00

207 lines
7.5 KiB
Python

"""Integration tests for ``main`` dispatch (headless paths), with a fake session."""
import json
from types import SimpleNamespace
import pytest
from deerflow.client import StreamEvent
from deerflow.tui import cli
class _FakeClient:
def __init__(self):
self.chat_kwargs = None
self.stream_kwargs = None
def chat(self, message, *, thread_id=None, **kwargs):
self.chat_kwargs = kwargs
return f"answer:{message}"
def stream(self, message, *, thread_id=None, **kwargs):
self.stream_kwargs = kwargs
yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "hi", "id": "m1"})
yield StreamEvent(type="end", data={"usage": {"total_tokens": 1}})
class _FakeSession:
latest = None
def __init__(self):
self.client = _FakeClient()
type(self).latest = self
def resolve_thread(self, plan):
return None
def test_main_print_outputs_chat_answer(monkeypatch, capsys):
monkeypatch.setattr(cli, "_make_session", _FakeSession)
rc = cli.main(["--print", "hello"])
assert rc == 0
assert "answer:hello" in capsys.readouterr().out
def test_main_print_passes_explicit_recursion_limit(monkeypatch, capsys):
monkeypatch.setattr(cli, "_make_session", _FakeSession)
rc = cli.main(["--recursion-limit", "250", "--print", "hello"])
assert rc == 0
assert _FakeSession.latest.client.chat_kwargs == {"recursion_limit": 250}
def test_main_print_omits_default_recursion_limit(monkeypatch, capsys):
monkeypatch.setattr(cli, "_make_session", _FakeSession)
rc = cli.main(["--print", "hello"])
assert rc == 0
assert _FakeSession.latest.client.chat_kwargs == {}
def test_main_json_emits_ndjson_stream_events(monkeypatch, capsys):
monkeypatch.setattr(cli, "_make_session", _FakeSession)
rc = cli.main(["--json", "hello"])
assert rc == 0
lines = [ln for ln in capsys.readouterr().out.splitlines() if ln.strip()]
payloads = [json.loads(ln) for ln in lines]
assert payloads[0]["type"] == "messages-tuple"
assert payloads[-1]["type"] == "end"
def test_main_json_passes_explicit_recursion_limit(monkeypatch, capsys):
monkeypatch.setattr(cli, "_make_session", _FakeSession)
rc = cli.main(["--recursion-limit", "250", "--json", "hello"])
assert rc == 0
assert _FakeSession.latest.client.stream_kwargs == {"recursion_limit": 250}
def test_invalid_recursion_limit_fails_before_session_creation(monkeypatch):
def fail_if_called():
raise AssertionError("session should not be created for invalid CLI input")
monkeypatch.setattr(cli, "_make_session", fail_if_called)
with pytest.raises(SystemExit):
cli.main(["--recursion-limit", "0", "--print", "hello"])
def test_main_headless_help_returns_2_and_prints_usage(monkeypatch, capsys):
# On a TTY with no message and no piped stdin, --cli has nothing to run.
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True)
rc = cli.main(["--cli"])
assert rc == 2
assert "deerflow" in capsys.readouterr().err
# --------------------------------------------------------------------------- #
# Headless error boundary: client/setup failures must not escape main() as
# tracebacks. --print reports on stderr; --json appends one terminal error
# record while keeping every stdout line valid NDJSON. KeyboardInterrupt and
# SystemExit are not part of the boundary.
# --------------------------------------------------------------------------- #
def _raising_session(exc, *, stream_events_before_failure=0):
def fail(*args, **kwargs):
raise exc
def stream(message, *, thread_id=None, **kwargs):
for _ in range(stream_events_before_failure):
yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "hi", "id": "m1"})
raise exc
return SimpleNamespace(resolve_thread=lambda plan: "thread-1", client=SimpleNamespace(chat=fail, stream=stream))
def test_main_print_client_failure_reports_error_without_traceback(monkeypatch, capsys):
session = _raising_session(RuntimeError("Thread requires delta mode"))
monkeypatch.setattr(cli, "_make_session", lambda: session)
rc = cli.main(["--print", "hello"])
captured = capsys.readouterr()
assert rc == 1
assert "Thread requires delta mode" in captured.err
assert captured.out == ""
assert "Traceback" not in captured.err
def test_main_json_failure_before_first_event_emits_single_error_record(monkeypatch, capsys):
session = _raising_session(RuntimeError("Thread requires delta mode"))
monkeypatch.setattr(cli, "_make_session", lambda: session)
rc = cli.main(["--json", "hello"])
captured = capsys.readouterr()
assert rc == 1
payloads = [json.loads(ln) for ln in captured.out.splitlines() if ln.strip()]
assert payloads == [{"type": "error", "data": {"message": "Thread requires delta mode"}}]
assert "Thread requires delta mode" in captured.err
def test_main_json_failure_after_event_keeps_every_line_valid_json(monkeypatch, capsys):
session = _raising_session(RuntimeError("checkpoint mode mismatch"), stream_events_before_failure=1)
monkeypatch.setattr(cli, "_make_session", lambda: session)
rc = cli.main(["--json", "hello"])
captured = capsys.readouterr()
assert rc == 1
payloads = [json.loads(ln) for ln in captured.out.splitlines() if ln.strip()]
assert payloads[0]["type"] == "messages-tuple"
assert payloads[-1] == {"type": "error", "data": {"message": "checkpoint mode mismatch"}}
def test_main_session_creation_failure_reports_error_without_traceback(monkeypatch, capsys):
def fail():
raise RuntimeError("no checkpointer configured")
monkeypatch.setattr(cli, "_make_session", fail)
rc = cli.main(["--print", "hello"])
captured = capsys.readouterr()
assert rc == 1
assert "no checkpointer configured" in captured.err
assert "Traceback" not in captured.err
def test_main_thread_resolution_failure_emits_json_error_record(monkeypatch, capsys):
def fail():
raise RuntimeError("thread lookup failed")
session = SimpleNamespace(resolve_thread=lambda plan: fail(), client=SimpleNamespace())
monkeypatch.setattr(cli, "_make_session", lambda: session)
rc = cli.main(["--json", "hello"])
captured = capsys.readouterr()
assert rc == 1
payloads = [json.loads(ln) for ln in captured.out.splitlines() if ln.strip()]
assert payloads[-1]["type"] == "error"
def test_main_headless_boundary_preserves_keyboard_interrupt(monkeypatch):
session = _raising_session(KeyboardInterrupt())
monkeypatch.setattr(cli, "_make_session", lambda: session)
with pytest.raises(KeyboardInterrupt):
cli.main(["--print", "hello"])
class _BrokenStdout:
"""A stdout whose consumer closed the pipe early (e.g. ``| head``)."""
def isatty(self):
return False
def write(self, *_args, **_kwargs):
raise BrokenPipeError
def flush(self):
pass
def fileno(self):
raise OSError # forces the devnull dup2 to take its best-effort path
def test_main_print_broken_pipe_exits_without_traceback(monkeypatch):
monkeypatch.setattr(cli, "_make_session", _FakeSession)
monkeypatch.setattr(cli.sys, "stdout", _BrokenStdout())
rc = cli.main(["--print", "hello"])
assert rc == 1
def test_main_json_broken_pipe_error_record_fails_silently(monkeypatch):
session = _raising_session(RuntimeError("boom"))
monkeypatch.setattr(cli, "_make_session", lambda: session)
monkeypatch.setattr(cli.sys, "stdout", _BrokenStdout())
rc = cli.main(["--json", "hello"])
assert rc == 1