diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 009575ed..5c001257 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -65,6 +65,17 @@ def _reap_pid(pid: int) -> None: logger.debug("_reap_pid({}): {}", pid, exc) +def _decode_process_output(data: bytes) -> str: + if not data: + return "" + if _IS_WINDOWS and b"\x00" in data[:200]: + try: + return data.decode("utf-16") + except UnicodeDecodeError: + pass + return data.decode("utf-8", errors="replace") + + # Policy note appended to recoverable workspace-boundary guard errors. _WORKSPACE_BOUNDARY_NOTE = ( "\n\nNote: this is a hard policy boundary, not a transient failure. " @@ -337,10 +348,10 @@ class ExecTool(Tool): output_parts = [] if stdout: - output_parts.append(stdout.decode("utf-8", errors="replace")) + output_parts.append(_decode_process_output(stdout)) if stderr: - stderr_text = stderr.decode("utf-8", errors="replace") + stderr_text = _decode_process_output(stderr) if stderr_text.strip(): output_parts.append(f"STDERR:\n{stderr_text}") diff --git a/tests/tools/test_exec_platform.py b/tests/tools/test_exec_platform.py index 6833fe14..21f9196f 100644 --- a/tests/tools/test_exec_platform.py +++ b/tests/tools/test_exec_platform.py @@ -465,6 +465,29 @@ class TestExecuteEndToEnd: assert "hello world" in result assert "Exit code: 0" in result + @pytest.mark.asyncio + async def test_windows_decodes_utf16_output(self): + """PowerShell output may arrive as UTF-16LE on Windows.""" + mock_proc = AsyncMock() + mock_proc.communicate.return_value = ( + "ok café\r\n".encode("utf-16-le"), + "warn λ\r\n".encode("utf-16-le"), + ) + mock_proc.returncode = 0 + + with ( + patch("nanobot.agent.tools.shell._IS_WINDOWS", True), + patch.object(ExecTool, "_spawn", return_value=mock_proc), + patch.object(ExecTool, "_guard_command", return_value=None), + ): + tool = ExecTool() + result = await tool.execute(command="echo ok") + + assert "ok café" in result + assert "warn λ" in result + assert "\x00" not in result + assert "Exit code: 0" in result + @pytest.mark.asyncio async def test_unix_full_path(self): """Full execute() flow on Unix: env, spawn, output formatting."""