From fd9fc38f414c81c8ab1fdb5c88a384ce9939f403 Mon Sep 17 00:00:00 2001 From: yu-xin-c <2182712990@qq.com> Date: Tue, 9 Jun 2026 22:50:08 +0800 Subject: [PATCH] fix(tools): keep apply_patch additions line-separated --- nanobot/agent/tools/apply_patch.py | 16 +++++-- tests/tools/test_apply_patch_tool.py | 63 ++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/nanobot/agent/tools/apply_patch.py b/nanobot/agent/tools/apply_patch.py index a1acd4c90..dcde6db62 100644 --- a/nanobot/agent/tools/apply_patch.py +++ b/nanobot/agent/tools/apply_patch.py @@ -75,6 +75,18 @@ def _line_diff_stats(before: str, after: str) -> tuple[int, int]: return added, deleted +def _append_text(content: str, addition: str) -> str: + """Append text without merging it into an unterminated final line.""" + base = content.replace("\r\n", "\n") + extra = addition.replace("\r\n", "\n") + if base and extra and not base.endswith("\n") and not extra.startswith("\n"): + base += "\n" + combined = base + extra + if combined and not combined.endswith("\n"): + combined += "\n" + return combined + + def _format_summary(summary: _PatchSummary) -> str: stats = "" if summary.added or summary.deleted: @@ -177,9 +189,7 @@ class ApplyPatchTool(_FsTool): if exists: uses_crlf = "\r\n" in content - new_norm = content.replace("\r\n", "\n") + new_text.replace("\r\n", "\n") - if new_norm and not new_norm.endswith("\n"): - new_norm += "\n" + new_norm = _append_text(content, new_text) if uses_crlf: new_norm = new_norm.replace("\n", "\r\n") writes[source] = new_norm diff --git a/tests/tools/test_apply_patch_tool.py b/tests/tools/test_apply_patch_tool.py index 9ddc35a85..d0de43d2d 100644 --- a/tests/tools/test_apply_patch_tool.py +++ b/tests/tools/test_apply_patch_tool.py @@ -89,6 +89,69 @@ def test_apply_patch_edits_add_to_existing_file(tmp_path): ) +def test_apply_patch_edits_add_to_existing_file_without_final_newline(tmp_path): + target = tmp_path / "notes.txt" + target.write_text("alpha", encoding="utf-8") + tool = ApplyPatchTool(workspace=tmp_path) + + result = asyncio.run( + tool.execute( + edits=[ + { + "path": "notes.txt", + "action": "add", + "new_text": "beta", + } + ] + ) + ) + + assert "update notes.txt" in result + assert target.read_text(encoding="utf-8") == "alpha\nbeta\n" + + +def test_apply_patch_edits_add_to_existing_crlf_file_without_final_newline(tmp_path): + target = tmp_path / "notes.txt" + target.write_bytes(b"alpha\r\nbravo") + tool = ApplyPatchTool(workspace=tmp_path) + + result = asyncio.run( + tool.execute( + edits=[ + { + "path": "notes.txt", + "action": "add", + "new_text": "charlie", + } + ] + ) + ) + + assert "update notes.txt" in result + assert target.read_bytes() == b"alpha\r\nbravo\r\ncharlie\r\n" + + +def test_apply_patch_edits_add_to_existing_file_respects_leading_newline(tmp_path): + target = tmp_path / "notes.txt" + target.write_text("alpha", encoding="utf-8") + tool = ApplyPatchTool(workspace=tmp_path) + + result = asyncio.run( + tool.execute( + edits=[ + { + "path": "notes.txt", + "action": "add", + "new_text": "\nbeta", + } + ] + ) + ) + + assert "update notes.txt" in result + assert target.read_text(encoding="utf-8") == "alpha\nbeta\n" + + def test_apply_patch_rejects_delete_action(tmp_path): target = tmp_path / "utils.py" target.write_text("def unused():\n pass\ndef used():\n return 1\n")