mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* 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.
207 lines
7.5 KiB
Python
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
|