diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index f13fe64029d5c..8aa6c9662d4ce 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -277,6 +277,15 @@ def run_oneshot( _write_usage_file(usage_file, result) + # Model text can contain lone UTF-16 surrogates (invalid in UTF-8). Writing + # those to a real stdout TextIO raises UnicodeEncodeError and aborts with + # exit 1 after the turn already completed — scrub to U+FFFD first. + # See #80366. + if response: + from agent.message_sanitization import _sanitize_surrogates + + response = _sanitize_surrogates(response) + if response: real_stdout.write(response) if not response.endswith("\n"): diff --git a/tests/hermes_cli/test_oneshot_surrogate.py b/tests/hermes_cli/test_oneshot_surrogate.py new file mode 100644 index 0000000000000..9039690a84063 --- /dev/null +++ b/tests/hermes_cli/test_oneshot_surrogate.py @@ -0,0 +1,39 @@ +"""Oneshot stdout must survive lone UTF-16 surrogates in model text (#80366).""" + +from __future__ import annotations + +import subprocess +import sys +import textwrap +from pathlib import Path + + +def test_oneshot_replaces_lone_surrogate_and_exits_zero(): + """hermes -z must print U+FFFD and exit 0 when the model returns U+D800.""" + program = textwrap.dedent( + """ + import hermes_cli.oneshot as oneshot + + dirty = "answer \\ud800 here" + oneshot._run_agent = lambda *args, **kwargs: ( + dirty, + {"final_response": dirty, "failed": False, "partial": False, "completed": True}, + ) + raise SystemExit(oneshot.run_oneshot("hello")) + """ + ) + + result = subprocess.run( + [sys.executable, "-c", program], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0, result.stderr.decode("utf-8", errors="replace") + # U+FFFD as UTF-8; no raw surrogate bytes + assert "\ufffd".encode("utf-8") in result.stdout + assert b"answer " in result.stdout + assert b" here\n" in result.stdout + assert b"Traceback" not in result.stderr