From 450788d3d5c0d585a37b00d4b0a7a00d33066fb5 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Sun, 27 Sep 2026 09:42:21 -0700 Subject: [PATCH] fix(codex): preserve subprocess errors for non-UTF8 stderr --- .../extensions/experimental/codex/exec.py | 2 +- .../codex/test_codex_exec_thread.py | 43 +++++++++++++++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/agents/extensions/experimental/codex/exec.py b/src/agents/extensions/experimental/codex/exec.py index 6ebf4ab34f..e54da248d6 100644 --- a/src/agents/extensions/experimental/codex/exec.py +++ b/src/agents/extensions/experimental/codex/exec.py @@ -204,7 +204,7 @@ async def _read_stdout_line() -> bytes: if process.returncode not in (0, None): await stderr_task - stderr_text = b"".join(stderr_chunks).decode("utf-8") + stderr_text = b"".join(stderr_chunks).decode("utf-8", errors="replace") raise RuntimeError( f"Codex exec exited with code {process.returncode}: {stderr_text}" ) diff --git a/tests/extensions/experiemental/codex/test_codex_exec_thread.py b/tests/extensions/experiemental/codex/test_codex_exec_thread.py index c50e3af1ec..c6b61cf57e 100644 --- a/tests/extensions/experiemental/codex/test_codex_exec_thread.py +++ b/tests/extensions/experiemental/codex/test_codex_exec_thread.py @@ -588,10 +588,13 @@ async def fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> FakeProcess: @pytest.mark.asyncio +@pytest.mark.parametrize("stderr_text", ["bad", "找不到命令"]) async def test_codex_exec_run_raises_on_non_zero_exit( - monkeypatch: pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, stderr_text: str ) -> None: - process = FakeProcess(stdout_lines=[], stderr_chunks=[b"bad"], returncode=2) + process = FakeProcess( + stdout_lines=[], stderr_chunks=[stderr_text.encode("utf-8")], returncode=2 + ) async def fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> FakeProcess: return process @@ -601,10 +604,44 @@ async def fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> FakeProcess: exec_client = exec_module.CodexExec(executable_path="/bin/codex") args = exec_module.CodexExecArgs(input="hello") - with pytest.raises(RuntimeError, match="exited with code 2"): + with pytest.raises(RuntimeError) as exc_info: async for _ in exec_client.run(args): pass + assert str(exc_info.value) == f"Codex exec exited with code 2: {stderr_text}" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_codex_thread_preserves_exit_error_with_non_utf8_stderr( + monkeypatch: pytest.MonkeyPatch, streamed: bool +) -> None: + # Captured Windows CP950 diagnostic from the npm Codex shim in issue #5185. + stderr = ( + b"'\"node\"' \xa4\xa3\xacO\xa4\xba\xb3\xa1\xa9\xce\xa5~\xb3\xa1\xa9R\xa5O" + b"\xa1B\xa5i\xb0\xf5\xa6\xe6\xaa\xba\xb5{\xa6\xa1\xa9\xce\xa7\xe5\xa6\xb8\xc0\xc9\xa1C\r\n" + ) + process = FakeProcess(stdout_lines=[], stderr_chunks=[stderr], returncode=1) + + async def fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> FakeProcess: + return process + + monkeypatch.setattr(exec_module.asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + thread = Codex(codex_path_override="/bin/codex").start_thread() + + with pytest.raises(RuntimeError) as exc_info: + if streamed: + result = await thread.run_streamed("hello") + async for _ in result.events: + pass + else: + await thread.run("hello") + + message = str(exc_info.value) + assert message.startswith("Codex exec exited with code 1: '\"node\"' ") + assert "\ufffd" in message + assert message.endswith("\r\n") + @pytest.mark.asyncio async def test_codex_exec_run_raises_without_stdin(monkeypatch: pytest.MonkeyPatch) -> None: