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
14 changes: 11 additions & 3 deletions acp_adapter/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions tests/acp/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Loading