From 369d94ccc6468533ca3a333bfd2795f1fb400eeb Mon Sep 17 00:00:00 2001 From: Cossackx <121278003+Cossackx@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:13:15 -0400 Subject: [PATCH] fix(acp): normalize CRLF/CR to LF in decoded text resources Editors on Windows attach CRLF files over ACP. _decode_text_bytes passed the raw \r bytes through, embedding stray carriage returns into prompt context and downstream diffs. Normalize decoded text to LF at the decode boundary; binary detection and truncation behavior are unchanged. Co-Authored-By: Claude Opus 5 --- acp_adapter/server.py | 14 +++++++++++--- tests/acp/test_server.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 44d1df5ecc98..ee1a1ebf1e65 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -303,15 +303,23 @@ def _path_from_file_uri(uri: str) -> Path | None: def _decode_text_bytes(data: bytes, mime_type: str | None) -> str | None: - """Decode resource bytes if they are probably text; return None for binary.""" + """Decode resource bytes if they are probably text; return None for binary. + + Decoded text is newline-normalized (CRLF and bare CR become LF): editors + on Windows send CRLF attachments, and leaving the CR in place embeds + stray carriage returns into prompt context and diffs downstream. + """ if b"\x00" in data and not _is_text_resource(mime_type): return None for encoding in ("utf-8-sig", "utf-8", "latin-1"): try: - return data.decode(encoding) + text = data.decode(encoding) + break except UnicodeDecodeError: continue - return data.decode("utf-8", errors="replace") + else: + text = data.decode("utf-8", errors="replace") + return text.replace("\r\n", "\n").replace("\r", "\n") def _format_resource_text( diff --git a/tests/acp/test_server.py b/tests/acp/test_server.py index 55f9b42ef014..ef01d3af4eff 100644 --- a/tests/acp/test_server.py +++ b/tests/acp/test_server.py @@ -40,6 +40,7 @@ ACP_MAX_MODELS_PER_PROVIDER, HermesACPAgent, HERMES_VERSION, + _decode_text_bytes, ) from acp_adapter.session import SessionManager from hermes_state import SessionDB @@ -726,3 +727,34 @@ async def test_register_failure_logs_warning(self, agent, mock_manager): with patch("tools.mcp_tool.register_mcp_servers", side_effect=RuntimeError("boom")): # Should not raise await agent._register_session_mcp_servers(state, [server]) + + +# --------------------------------------------------------------------------- +# _decode_text_bytes +# --------------------------------------------------------------------------- + + +class TestDecodeTextBytesNewlines: + """Decoded ACP text resources are newline-normalized (CRLF/CR to LF). + + Editors on Windows attach CRLF files; without normalization the raw CR + bytes flow into prompt context and diffs downstream. + """ + + def test_crlf_and_bare_cr_normalized(self): + assert _decode_text_bytes(b"a\r\nb\rc\n", "text/plain") == "a\nb\nc\n" + + def test_utf8_sig_content_normalized(self): + data = "line1\r\nline2\r\n".encode("utf-8-sig") + assert _decode_text_bytes(data, "text/plain") == "line1\nline2\n" + + def test_binary_still_rejected(self): + assert _decode_text_bytes(b"\x00\x01\x02", None) is None + + def test_lf_only_content_unchanged(self): + assert _decode_text_bytes(b"a\nb\n", "text/plain") == "a\nb\n" + + def test_nul_byte_kept_when_mime_forces_text(self): + """A text mime type overrides the NUL-byte binary heuristic, and the + content is still normalized on the way out.""" + assert _decode_text_bytes(b"\x00a\r\nb", "text/plain") == "\x00a\nb"