Files
hermes-agent/tests/hermes_cli/test_oneshot_exit_contract.py
teknium1 391a7007fb fix(cli): hermes -z exits non-zero for partial, failed and interrupted runs even when text was printed
`hermes -z` judged the run by whether stdout was non-empty: a turn that hit the
iteration budget, ended `partial`, failed on the provider, or was interrupted
still exited 0 as long as it printed an explanation, so scripts treated a
half-done job (or a 401 summary) as success. The `-q`/`-Q` paths already got
one result-derived contract in 23863ccbafe0d; this is the same class on the
scripted one-shot entry point (#111770).

- `hermes_cli/oneshot.py::_oneshot_exit_code`: 0 only when the turn completed;
  130 interrupted; 2 failed / partial / completed:false; 1 a completed turn
  with no text (unchanged message).
- `--usage-file` now also reports `partial`, `interrupted` and
  `turn_exit_reason`, so a pipeline can tell a budget stop from a Ctrl-C.
- Docs: `hermes -z` exit-code table + usage-file fields in cli-commands.md.

Live probe (run_oneshot with a stubbed _run_agent): partial-with-text 0 -> 2,
budget-with-text 0 -> 2, interrupted-with-text 0 -> 130, failed-with-text 0 -> 2;
completed 0 -> 0 (control), partial-no-text 2 -> 2 (unchanged).
2026-09-16 18:12:34 -07:00

42 lines
1.8 KiB
Python

"""``hermes -z`` exit-code contract: the outcome is judged from the turn result, not from
whether any text was printed (#111770 — an incomplete or failed run that left an explanation on
stdout used to exit 0, so scripts treated a half-done job as success)."""
from unittest import mock
import hermes_cli.oneshot as oneshot
def _run(monkeypatch, tmp_path, response, result):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
usage = tmp_path / "usage.json"
with mock.patch.object(oneshot, "_run_agent", return_value=(response, dict(result))):
code = oneshot.run_oneshot("q", usage_file=str(usage))
return code, usage
def test_incomplete_run_with_text_exits_nonzero_and_reports_why(monkeypatch, tmp_path):
import json
partial = {"final_response": "Got as far as step 2.", "completed": False, "partial": True,
"turn_exit_reason": "iteration_limit"}
code, usage = _run(monkeypatch, tmp_path, "Got as far as step 2.", partial)
assert code == 2
report = json.loads(usage.read_text(encoding="utf-8"))
assert report["completed"] is False and report["partial"] is True
assert report["turn_exit_reason"] == "iteration_limit"
interrupted = {"final_response": "Stopping.", "completed": False, "interrupted": True}
code, usage = _run(monkeypatch, tmp_path, "Stopping.", interrupted)
assert code == 130
assert json.loads(usage.read_text(encoding="utf-8"))["interrupted"] is True
failed = {"final_response": "Provider returned 401.", "completed": False, "failed": True}
code, _ = _run(monkeypatch, tmp_path, "Provider returned 401.", failed)
assert code == 2
def test_completed_run_still_exits_zero(monkeypatch, tmp_path):
code, _ = _run(monkeypatch, tmp_path, "Paris.", {"final_response": "Paris.", "completed": True, "failed": False})
assert code == 0