Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions sgpt/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ def main(
if "__sgpt__eof__" in line:
break
stdin += line
# Sanitize surrogate characters that can cause UnicodeEncodeError when
# piping binary content (e.g. `git diff` on Windows).
stdin = stdin.encode("utf-8", errors="replace").decode("utf-8")
prompt = f"{stdin}\n\n{prompt}" if prompt else stdin
try:
# Switch to stdin for interactive input.
Expand Down
38 changes: 38 additions & 0 deletions tests/test_default.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import io
from pathlib import Path
from unittest.mock import patch

Expand Down Expand Up @@ -38,6 +39,43 @@ def test_default_stdin(completion):
assert "Prague" in result.output


@patch("sgpt.handlers.handler.completion")
def test_stdin_with_surrogate_characters(completion):
"""Stdin with lone surrogate characters must not raise UnicodeEncodeError.

On Windows, piping binary content (e.g. ``git diff`` on a repo with
binary files) causes Python's text-mode stdin to produce lone surrogates
via the surrogateescape error handler. Without sanitisation the surrogates
propagate into the JSON payload and httpx raises::

UnicodeEncodeError: 'utf-8' codec can't encode characters: surrogates not allowed

The fix encodes the stdin string with ``errors='replace'`` before using it
as the prompt. This test verifies the fix by injecting a mock stdin that
yields a line containing a lone surrogate and asserting the invocation
succeeds.
"""
completion.return_value = mock_comp("ok")

stdin_with_surrogate = "diff --git a/file b/file\n\udcffbinary content\n"
mock_stdin = io.StringIO(stdin_with_surrogate)
mock_stdin.isatty = lambda: False # type: ignore[attr-defined]

with patch("sgpt.app.sys") as mock_sys:
mock_sys.stdin = mock_stdin
mock_sys.name = "posix"

result = runner.invoke(app, cmd_args())

assert result.exit_code == 0, result.output
assert "ok" in result.output

# The prompt passed to the LLM must contain no lone surrogates.
call_messages = completion.call_args[1]["messages"]
user_content = next(m["content"] for m in call_messages if m["role"] == "user")
user_content.encode("utf-8") # must not raise


@patch("rich.console.Console.print")
@patch("sgpt.handlers.handler.completion")
def test_show_chat_use_markdown(completion, console_print):
Expand Down