feat(cli): add --output-format stream-json for headless clients (#309)

* feat(cli): add --output-format stream-json for headless clients

Emit EvoScientist's native event stream as line-delimited JSON on stdout
in single-shot (-p) mode, with all human output redirected to stderr so
stdout stays pure JSONL. Intended as the integration surface for
programmatic clients (e.g. an agent runtime) that drive EvoSci headlessly.

- stream/json_sink.py: write_events_as_json + stream_json sink, plus
  redirect_console_to_stderr helper for stdout purity
- cli/interactive.py: cmd_run gains output_format; stream-json branch runs
  the sink instead of the Rich renderer
- cli/commands.py: --output-format option + validation (stream-json
  requires -p; value must be text|stream-json)
- docs/stream-json.md: event-schema contract + example transcript
- tests: json sink serialization, CLI dispatch, console redirect, validation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): honor explicit --no-auto-mode over config in stream-json

Address CodeRabbit review (discussion_r3514041123): the auto-mode override
block only wrote to cli_overrides when the resolved value was True, so an
explicit --no-auto-mode silently fell back to a config that enables
auto-mode -- breaking "explicit flags always win" and leaving stream-json
running unattended despite the warning. Write auto_mode=False when the flag
is explicitly False. Add regression tests that capture the overrides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kalisgd0h
2026-07-05 06:53:52 +02:00
committed by GitHub
parent 2b244888ec
commit bd54eaa0a4
6 changed files with 539 additions and 18 deletions
+144
View File
@@ -0,0 +1,144 @@
"""Tests for the --output-format stream-json CLI wiring.
Covers:
1. The console singleton is redirected to stderr so stdout stays pure JSONL.
2. --output-format is validated (stream-json requires -p; bad values rejected).
The dispatch itself runs the sink through ``GraphGateway.stream_events`` from the
single-shot path; that sourcing is exercised by the json_sink unit tests.
"""
import sys
import pytest
from EvoScientist.cli.commands import _resolve_stream_json_auto_mode
@pytest.mark.parametrize(
("auto_mode", "output_format", "expected"),
[
(None, "stream-json", True), # default on for headless stream-json
(None, "text", False), # text keeps historical off-by-default
(False, "stream-json", False), # explicit --no-auto-mode wins
(True, "text", True), # explicit --auto-mode wins
],
)
def test_resolve_stream_json_auto_mode(auto_mode, output_format, expected):
"""auto-mode defaults on for stream-json when unset; explicit flags always win."""
assert _resolve_stream_json_auto_mode(auto_mode, output_format) is expected
def _overrides_for(monkeypatch, argv):
"""Invoke the CLI and capture the cli_overrides handed to get_effective_config,
aborting before the run actually starts."""
from typer.testing import CliRunner
import EvoScientist.config as cfg_mod
from EvoScientist.cli._app import app
from EvoScientist.stream.console import console
captured: dict[str, object] = {}
class _Stop(Exception):
"""Sentinel to short-circuit the callback once overrides are captured."""
def _grab(overrides):
"""Record the overrides dict, then abort before the heavy run path."""
captured["overrides"] = dict(overrides)
raise _Stop
monkeypatch.setattr(cfg_mod, "get_effective_config", _grab)
original_console_file = console.file
try:
CliRunner().invoke(app, argv, catch_exceptions=True)
finally:
console.file = original_console_file
return captured.get("overrides", {})
def test_stream_json_defaults_auto_mode_on_in_overrides(monkeypatch):
"""stream-json without the flag defaults auto-mode (and auto-approve) on."""
overrides = _overrides_for(
monkeypatch, ["-p", "hi", "--output-format", "stream-json"]
)
assert overrides.get("auto_mode") is True
assert overrides.get("auto_approve") is True
def test_no_auto_mode_writes_explicit_false_override(monkeypatch):
"""Explicit --no-auto-mode must write auto_mode=False so it wins over a config
that enables auto-mode (not silently fall back to the config default)."""
overrides = _overrides_for(
monkeypatch,
["-p", "hi", "--output-format", "stream-json", "--no-auto-mode"],
)
assert overrides.get("auto_mode") is False
def test_redirect_console_to_stderr():
"""redirect_console_to_stderr moves the shared console's output to stderr."""
from EvoScientist.stream.console import console
from EvoScientist.stream.json_sink import redirect_console_to_stderr
original = console.file
try:
redirect_console_to_stderr()
assert console.file is sys.stderr
finally:
console.file = original
# ── CLI validation (via typer CliRunner) ──
def _invoke(monkeypatch, argv):
"""Run the CLI app with argv under stubbed config/dispatch, returning
(dispatch-calls, CliRunner result)."""
from typer.testing import CliRunner
import EvoScientist.cli.commands as cmds
import EvoScientist.cli.interactive as interactive_mod
import EvoScientist.config as cfg_mod
from EvoScientist.cli._app import app
from EvoScientist.config.settings import EvoScientistConfig
calls: dict[str, object] = {}
def _fake_config(overrides):
"""Return a minimal config honoring only the ui_backend override."""
cfg = EvoScientistConfig()
cfg.ui_backend = overrides.get("ui_backend") or "cli"
return cfg
monkeypatch.setattr(cfg_mod, "get_effective_config", _fake_config)
monkeypatch.setattr(cfg_mod, "apply_config_to_env", lambda cfg: None)
monkeypatch.setattr(cmds, "ensure_dirs", lambda: None)
monkeypatch.setattr(cmds, "_ensure_async_subagent_server", lambda *a, **k: None)
monkeypatch.setattr(
interactive_mod,
"cmd_interactive",
lambda **kw: calls.__setitem__("dispatch", "interactive"),
)
monkeypatch.setattr(
interactive_mod,
"cmd_run",
lambda *a, **kw: calls.__setitem__("dispatch", "run"),
)
result = CliRunner().invoke(app, argv, catch_exceptions=False)
return calls, result
def test_stream_json_without_prompt_is_rejected(monkeypatch):
"""stream-json requires -p (single-shot); bare --output-format is rejected."""
calls, result = _invoke(monkeypatch, ["--output-format", "stream-json"])
assert result.exit_code != 0
assert "dispatch" not in calls
def test_invalid_output_format_is_rejected(monkeypatch):
"""An unknown --output-format value is rejected before any dispatch."""
calls, result = _invoke(monkeypatch, ["-p", "hi", "--output-format", "bogus"])
assert result.exit_code != 0
assert "dispatch" not in calls