From 8174eceea899dd23059b96195ae4a996217d488c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E2=82=82=E2=82=82H=E2=82=82=E2=82=85NO=E2=82=86?= <96930391+Sisyphbaous-DT-Project@users.noreply.github.com> Date: Mon, 25 May 2026 00:18:15 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=8E=9F=E5=A7=8B=E6=8D=A2=E8=A1=8C=E7=AC=A6=E9=A3=8E=E6=A0=BC?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E8=B7=A8=E5=B9=B3=E5=8F=B0=20CRLF/L?= =?UTF-8?q?F=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/kaos/src/kaos/local.py | 4 +- packages/kaos/src/kaos/ssh.py | 13 +++-- packages/kaos/tests/test_local_kaos.py | 35 +++++++++++++ src/kimi_cli/tools/file/replace.py | 34 +++++++++---- src/kimi_cli/tools/file/write.py | 27 +++++++--- tests/tools/test_str_replace_file.py | 68 ++++++++++++++++++++++++++ tests/tools/test_write_file.py | 50 +++++++++++++++++++ 7 files changed, 209 insertions(+), 22 deletions(-) diff --git a/packages/kaos/src/kaos/local.py b/packages/kaos/src/kaos/local.py index 7ab2f7cd0..0af736b28 100644 --- a/packages/kaos/src/kaos/local.py +++ b/packages/kaos/src/kaos/local.py @@ -121,7 +121,7 @@ async def readtext( errors: Literal["strict", "ignore", "replace"] = "strict", ) -> str: local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path) - async with aiofiles.open(local_path, encoding=encoding, errors=errors) as f: + async with aiofiles.open(local_path, encoding=encoding, errors=errors, newline="") as f: return await f.read() async def readlines( @@ -132,7 +132,7 @@ async def readlines( errors: Literal["strict", "ignore", "replace"] = "strict", ) -> AsyncGenerator[str]: local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path) - async with aiofiles.open(local_path, encoding=encoding, errors=errors) as f: + async with aiofiles.open(local_path, encoding=encoding, errors=errors, newline="") as f: async for line in f: yield line diff --git a/packages/kaos/src/kaos/ssh.py b/packages/kaos/src/kaos/ssh.py index b623742d8..6c031cd55 100644 --- a/packages/kaos/src/kaos/ssh.py +++ b/packages/kaos/src/kaos/ssh.py @@ -228,8 +228,9 @@ async def readtext( encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> str: - async with self._sftp.open(str(path), "r", encoding=encoding, errors=errors) as f: - return await f.read() + # Read raw bytes to avoid any newline translation by the remote SFTP server. + raw = await self.readbytes(path) + return raw.decode(encoding, errors=errors) async def readlines( self, @@ -240,7 +241,7 @@ async def readlines( ) -> AsyncGenerator[str]: # NOTE: readlines is not supported by SFTPClientFile text = await self.readtext(path, encoding=encoding, errors=errors) - for line in text.splitlines(): + for line in text.splitlines(keepends=True): yield line async def writebytes(self, path: StrOrKaosPath, data: bytes) -> int: @@ -256,8 +257,10 @@ async def writetext( encoding: str = "utf-8", errors: Literal["strict", "ignore", "replace"] = "strict", ) -> int: - async with self._sftp.open(str(path), mode, encoding=encoding, errors=errors) as f: - return await f.write(data) + # Write raw bytes to avoid any newline translation by the remote SFTP server. + bytes_mode = "ab" if mode == "a" else "wb" + async with self._sftp.open(str(path), bytes_mode) as f: + return await f.write(data.encode(encoding, errors=errors)) async def mkdir( self, diff --git a/packages/kaos/tests/test_local_kaos.py b/packages/kaos/tests/test_local_kaos.py index 80709f4f5..127960d3a 100644 --- a/packages/kaos/tests/test_local_kaos.py +++ b/packages/kaos/tests/test_local_kaos.py @@ -129,6 +129,41 @@ async def test_writetext_preserves_crlf_line_endings(local_kaos: LocalKaos): assert raw == b"hello\r\nworld\r\n", f"Expected CRLF preserved, got {raw!r}" +async def test_readtext_preserves_lf_line_endings(local_kaos: LocalKaos): + """readtext should not convert LF to CRLF.""" + tmp_path = local_kaos.getcwd() + file_path = tmp_path / "lf_read.txt" + + await local_kaos.writebytes(file_path, b"hello\nworld\n") + + text = await local_kaos.readtext(file_path) + assert "\r" not in text, f"Expected no \\r in read text, got {text!r}" + assert text == "hello\nworld\n", f"Expected LF line endings, got {text!r}" + + +async def test_readtext_preserves_crlf_line_endings(local_kaos: LocalKaos): + """readtext should preserve CRLF line endings.""" + tmp_path = local_kaos.getcwd() + file_path = tmp_path / "crlf_read.txt" + + await local_kaos.writebytes(file_path, b"hello\r\nworld\r\n") + + text = await local_kaos.readtext(file_path) + assert "\r\n" in text, f"Expected CRLF in read text, got {text!r}" + assert text == "hello\r\nworld\r\n", f"Expected CRLF preserved, got {text!r}" + + +async def test_readlines_preserves_crlf_line_endings(local_kaos: LocalKaos): + """readlines should preserve CRLF line endings in each yielded line.""" + tmp_path = local_kaos.getcwd() + file_path = tmp_path / "crlf_lines.txt" + + await local_kaos.writebytes(file_path, b"line1\r\nline2\r\n") + + lines = [line async for line in local_kaos.readlines(file_path)] + assert lines == ["line1\r\n", "line2\r\n"], f"Expected CRLF preserved in lines, got {lines!r}" + + async def test_mkdir_with_parents(local_kaos: LocalKaos): tmp_path = local_kaos.getcwd() nested_dir = tmp_path / "a" / "b" / "c" diff --git a/src/kimi_cli/tools/file/replace.py b/src/kimi_cli/tools/file/replace.py index 4f551de4f..d30019656 100644 --- a/src/kimi_cli/tools/file/replace.py +++ b/src/kimi_cli/tools/file/replace.py @@ -128,10 +128,22 @@ async def __call__(self, params: Params) -> ToolReturnValue: brief="Invalid path", ) - # Read the file content - content = await p.read_text(errors="replace") - - original_content = content + # Read raw bytes to preserve original line endings + raw_bytes = await p.read_bytes() + original_content = raw_bytes.decode("utf-8", errors="replace") + + # Detect dominant line ending style + if b"\r\n" in raw_bytes: + eol_style = "\r\n" + elif b"\r" in raw_bytes: + eol_style = "\r" + else: + eol_style = "\n" + + # Normalize to \n for model matching (model-generated old/new use \n) + normalized_content = original_content.replace("\r\n", "\n").replace("\r", "\n") + + content = normalized_content edits = [params.edit] if isinstance(params.edit, Edit) else params.edit # Apply all edits @@ -139,12 +151,16 @@ async def __call__(self, params: Params) -> ToolReturnValue: content = self._apply_edit(content, edit) # Check if any changes were made - if content == original_content: + if content == normalized_content: return ToolError( message="No replacements were made. The old string was not found in the file.", brief="No replacements made", ) + # Restore original line ending style before writing + if eol_style != "\n": + content = content.replace("\n", eol_style) + diff_blocks: list[DisplayBlock] = await build_diff_blocks( str(p), original_content, content ) @@ -166,16 +182,16 @@ async def __call__(self, params: Params) -> ToolReturnValue: if not result: return result.rejection_error() - # Write the modified content back to the file - await p.write_text(content, errors="replace") + # Write the modified content back to the file preserving original line endings + await p.write_bytes(content.encode("utf-8", errors="replace")) # Count changes for success message total_replacements = 0 for edit in edits: if edit.replace_all: - total_replacements += original_content.count(edit.old) + total_replacements += normalized_content.count(edit.old) else: - total_replacements += 1 if edit.old in original_content else 0 + total_replacements += 1 if edit.old in normalized_content else 0 return ToolReturnValue( is_error=False, diff --git a/src/kimi_cli/tools/file/write.py b/src/kimi_cli/tools/file/write.py index 3e6e3a069..6059fcd21 100644 --- a/src/kimi_cli/tools/file/write.py +++ b/src/kimi_cli/tools/file/write.py @@ -122,12 +122,23 @@ async def __call__(self, params: Params) -> ToolReturnValue: file_existed = await p.exists() old_text = None + original_bytes = b"" if file_existed: - old_text = await p.read_text(errors="replace") + original_bytes = await p.read_bytes() + old_text = original_bytes.decode("utf-8", errors="replace") + + # Detect dominant line ending style from existing file + eol_style = "\n" + if file_existed: + if b"\r\n" in original_bytes: + eol_style = "\r\n" + elif b"\r" in original_bytes: + eol_style = "\r" + + new_text = params.content + if params.mode == "append": + new_text = (old_text or "") + params.content - new_text = ( - params.content if params.mode == "overwrite" else (old_text or "") + params.content - ) diff_blocks: list[DisplayBlock] = await build_diff_blocks( str(p), old_text or "", @@ -152,10 +163,14 @@ async def __call__(self, params: Params) -> ToolReturnValue: if not result: return result.rejection_error() - # Write content to file + # Write content to file preserving original line endings when overwriting match params.mode: case "overwrite": - await p.write_text(params.content) + if eol_style != "\n": + content_to_write = params.content.replace("\n", eol_style) + else: + content_to_write = params.content + await p.write_bytes(content_to_write.encode("utf-8", errors="replace")) case "append": await p.append_text(params.content) diff --git a/tests/tools/test_str_replace_file.py b/tests/tools/test_str_replace_file.py index a16dad303..5f11a668c 100644 --- a/tests/tools/test_str_replace_file.py +++ b/tests/tools/test_str_replace_file.py @@ -246,3 +246,71 @@ async def test_replace_empty_strings( assert not result.is_error assert "successfully edited" in result.message assert await file_path.read_text() == "Hello !" + + +async def test_replace_preserves_crlf_line_endings( + str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath +): + """StrReplaceFile should preserve CRLF line endings in the file.""" + file_path = temp_work_dir / "crlf.txt" + original_bytes = b"line1\r\nline2\r\nline3\r\n" + await file_path.write_bytes(original_bytes) + + result = await str_replace_file_tool( + Params( + path=str(file_path), + edit=Edit(old="line2", new="modified_line2"), + ) + ) + + assert not result.is_error + assert "successfully edited" in result.message + + # Verify bytes on disk are still CRLF + raw = await file_path.read_bytes() + expected = b"line1\r\nmodified_line2\r\nline3\r\n" + assert raw == expected, f"Expected CRLF preserved, got {raw!r}" + + +async def test_replace_preserves_lf_line_endings( + str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath +): + """StrReplaceFile should preserve LF line endings in the file.""" + file_path = temp_work_dir / "lf.txt" + original_bytes = b"line1\nline2\nline3\n" + await file_path.write_bytes(original_bytes) + + result = await str_replace_file_tool( + Params( + path=str(file_path), + edit=Edit(old="line2", new="modified_line2"), + ) + ) + + assert not result.is_error + assert "successfully edited" in result.message + + raw = await file_path.read_bytes() + expected = b"line1\nmodified_line2\nline3\n" + assert raw == expected, f"Expected LF preserved, got {raw!r}" + + +async def test_replace_multiline_preserves_crlf( + str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath +): + """StrReplaceFile should preserve CRLF when replacing multi-line blocks.""" + file_path = temp_work_dir / "crlf_multi.txt" + original_bytes = b"header\r\nline1\r\nline2\r\nfooter\r\n" + await file_path.write_bytes(original_bytes) + + result = await str_replace_file_tool( + Params( + path=str(file_path), + edit=Edit(old="line1\nline2", new="new1\nnew2"), + ) + ) + + assert not result.is_error + raw = await file_path.read_bytes() + expected = b"header\r\nnew1\r\nnew2\r\nfooter\r\n" + assert raw == expected, f"Expected CRLF preserved, got {raw!r}" diff --git a/tests/tools/test_write_file.py b/tests/tools/test_write_file.py index a117dd13f..74cd889c6 100644 --- a/tests/tools/test_write_file.py +++ b/tests/tools/test_write_file.py @@ -171,3 +171,53 @@ async def test_write_large_content(write_file_tool: WriteFile, temp_work_dir: Ka assert not result.is_error assert await file_path.exists() assert await file_path.read_text() == content + + +async def test_overwrite_preserves_crlf_line_endings( + write_file_tool: WriteFile, temp_work_dir: KaosPath +): + """WriteFile overwrite should preserve CRLF line endings from existing file.""" + file_path = temp_work_dir / "crlf_overwrite.txt" + original_bytes = b"old_line1\r\nold_line2\r\n" + await file_path.write_bytes(original_bytes) + + new_content = "new_line1\nnew_line2\n" + result = await write_file_tool(Params(path=str(file_path), content=new_content)) + + assert not result.is_error + assert "successfully overwritten" in result.message + + raw = await file_path.read_bytes() + expected = b"new_line1\r\nnew_line2\r\n" + assert raw == expected, f"Expected CRLF preserved, got {raw!r}" + + +async def test_overwrite_preserves_lf_line_endings( + write_file_tool: WriteFile, temp_work_dir: KaosPath +): + """WriteFile overwrite should preserve LF line endings from existing file.""" + file_path = temp_work_dir / "lf_overwrite.txt" + original_bytes = b"old_line1\nold_line2\n" + await file_path.write_bytes(original_bytes) + + new_content = "new_line1\nnew_line2\n" + result = await write_file_tool(Params(path=str(file_path), content=new_content)) + + assert not result.is_error + raw = await file_path.read_bytes() + expected = b"new_line1\nnew_line2\n" + assert raw == expected, f"Expected LF preserved, got {raw!r}" + + +async def test_new_file_write_uses_lf( + write_file_tool: WriteFile, temp_work_dir: KaosPath +): + """Writing a new file should use the content's line endings as-is (LF by default).""" + file_path = temp_work_dir / "new_lf.txt" + content = "line1\nline2\n" + + result = await write_file_tool(Params(path=str(file_path), content=content)) + + assert not result.is_error + raw = await file_path.read_bytes() + assert raw == b"line1\nline2\n", f"Expected LF for new file, got {raw!r}" From 5a075014e3e974e4f1193926fc67bcbc1a943c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E2=82=82=E2=82=82H=E2=82=82=E2=82=85NO=E2=82=86?= <96930391+Sisyphbaous-DT-Project@users.noreply.github.com> Date: Mon, 25 May 2026 00:47:12 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dreview=20bot?= =?UTF-8?q?=E6=8C=87=E5=87=BA=E7=9A=843=E4=B8=AA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/kaos/src/kaos/ssh.py | 4 +++- packages/kaos/tests/test_ssh_kaos.py | 15 +++++++++++++++ src/kimi_cli/tools/file/replace.py | 3 ++- src/kimi_cli/tools/file/write.py | 3 ++- tests/tools/test_str_replace_file.py | 22 ++++++++++++++++++++++ tests/tools/test_write_file.py | 18 ++++++++++++++++++ 6 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/kaos/src/kaos/ssh.py b/packages/kaos/src/kaos/ssh.py index 6c031cd55..c9adbd0db 100644 --- a/packages/kaos/src/kaos/ssh.py +++ b/packages/kaos/src/kaos/ssh.py @@ -259,8 +259,10 @@ async def writetext( ) -> int: # Write raw bytes to avoid any newline translation by the remote SFTP server. bytes_mode = "ab" if mode == "a" else "wb" + encoded = data.encode(encoding, errors=errors) async with self._sftp.open(str(path), bytes_mode) as f: - return await f.write(data.encode(encoding, errors=errors)) + await f.write(encoded) + return len(data) async def mkdir( self, diff --git a/packages/kaos/tests/test_ssh_kaos.py b/packages/kaos/tests/test_ssh_kaos.py index 0e5f6e01d..1bec43692 100644 --- a/packages/kaos/tests/test_ssh_kaos.py +++ b/packages/kaos/tests/test_ssh_kaos.py @@ -190,6 +190,21 @@ async def test_kaospath_roundtrip(bind_current_kaos: SSHKaos, remote_base: str): assert str(KaosPath.cwd()) == remote_base +async def test_writetext_returns_char_count_not_byte_count( + ssh_kaos: SSHKaos, remote_base: str +): + """writetext() must return character count, not byte count, for CJK text.""" + file_path = os.path.join(remote_base, "cjk.txt") + cjk_text = "你好世界" + written = await ssh_kaos.writetext(file_path, cjk_text) + assert written == len(cjk_text), ( + f"Expected {len(cjk_text)} characters, got {written}" + ) + + read_back = await ssh_kaos.readtext(file_path) + assert read_back == cjk_text + + async def test_iterdir_lists_child_entries(ssh_kaos: SSHKaos, remote_base: str): await ssh_kaos.writetext(os.path.join(remote_base, "file1.txt"), "1") await ssh_kaos.writetext(os.path.join(remote_base, "file2.log"), "2") diff --git a/src/kimi_cli/tools/file/replace.py b/src/kimi_cli/tools/file/replace.py index d30019656..a4aac0ea5 100644 --- a/src/kimi_cli/tools/file/replace.py +++ b/src/kimi_cli/tools/file/replace.py @@ -159,7 +159,8 @@ async def __call__(self, params: Params) -> ToolReturnValue: # Restore original line ending style before writing if eol_style != "\n": - content = content.replace("\n", eol_style) + # Normalize to LF first to avoid double-converting existing CRLF in edit.new + content = content.replace("\r\n", "\n").replace("\r", "\n").replace("\n", eol_style) diff_blocks: list[DisplayBlock] = await build_diff_blocks( str(p), original_content, content diff --git a/src/kimi_cli/tools/file/write.py b/src/kimi_cli/tools/file/write.py index 6059fcd21..0fb3023c6 100644 --- a/src/kimi_cli/tools/file/write.py +++ b/src/kimi_cli/tools/file/write.py @@ -167,7 +167,8 @@ async def __call__(self, params: Params) -> ToolReturnValue: match params.mode: case "overwrite": if eol_style != "\n": - content_to_write = params.content.replace("\n", eol_style) + # Normalize to LF first to avoid double-converting existing CRLF + content_to_write = params.content.replace("\r\n", "\n").replace("\r", "\n").replace("\n", eol_style) else: content_to_write = params.content await p.write_bytes(content_to_write.encode("utf-8", errors="replace")) diff --git a/tests/tools/test_str_replace_file.py b/tests/tools/test_str_replace_file.py index 5f11a668c..1bbaec821 100644 --- a/tests/tools/test_str_replace_file.py +++ b/tests/tools/test_str_replace_file.py @@ -314,3 +314,25 @@ async def test_replace_multiline_preserves_crlf( raw = await file_path.read_bytes() expected = b"header\r\nnew1\r\nnew2\r\nfooter\r\n" assert raw == expected, f"Expected CRLF preserved, got {raw!r}" + + +async def test_replace_with_crlf_in_new_no_double_conversion( + str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath +): + """StrReplaceFile should not double-convert CRLF that already exists in edit.new.""" + file_path = temp_work_dir / "crlf_in_new.txt" + original_bytes = b"header\r\nline1\r\nfooter\r\n" + await file_path.write_bytes(original_bytes) + + # edit.new contains CRLF (model may pass CRLF content) + result = await str_replace_file_tool( + Params( + path=str(file_path), + edit=Edit(old="line1", new="new1\r\nnew2"), + ) + ) + + assert not result.is_error + raw = await file_path.read_bytes() + expected = b"header\r\nnew1\r\nnew2\r\nfooter\r\n" + assert raw == expected, f"Expected CRLF in new preserved without double conversion, got {raw!r}" diff --git a/tests/tools/test_write_file.py b/tests/tools/test_write_file.py index 74cd889c6..3b4dcfee7 100644 --- a/tests/tools/test_write_file.py +++ b/tests/tools/test_write_file.py @@ -221,3 +221,21 @@ async def test_new_file_write_uses_lf( assert not result.is_error raw = await file_path.read_bytes() assert raw == b"line1\nline2\n", f"Expected LF for new file, got {raw!r}" + + +async def test_overwrite_crlf_input_no_double_conversion( + write_file_tool: WriteFile, temp_work_dir: KaosPath +): + """WriteFile overwrite should not double-convert existing CRLF in params.content.""" + file_path = temp_work_dir / "crlf_overwrite_input.txt" + original_bytes = b"old1\r\nold2\r\n" + await file_path.write_bytes(original_bytes) + + # params.content already contains CRLF (e.g., from model) + new_content = "new1\r\nnew2\r\n" + result = await write_file_tool(Params(path=str(file_path), content=new_content)) + + assert not result.is_error + raw = await file_path.read_bytes() + expected = b"new1\r\nnew2\r\n" + assert raw == expected, f"Expected preserved CRLF without double conversion, got {raw!r}" From d2064bb8d3eaeb13ad312a0c887081b2ffacf077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E2=82=82=E2=82=82H=E2=82=82=E2=82=85NO=E2=82=86?= <96930391+Sisyphbaous-DT-Project@users.noreply.github.com> Date: Mon, 25 May 2026 00:59:36 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4overwrite/replace?= =?UTF-8?q?=E4=B8=AD=E7=9A=84errors=3D"replace"=EF=BC=8C=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E9=BB=98=E8=AE=A4strict=E7=BC=96=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/kimi_cli/tools/file/replace.py | 2 +- src/kimi_cli/tools/file/write.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/kimi_cli/tools/file/replace.py b/src/kimi_cli/tools/file/replace.py index a4aac0ea5..ab35955e3 100644 --- a/src/kimi_cli/tools/file/replace.py +++ b/src/kimi_cli/tools/file/replace.py @@ -184,7 +184,7 @@ async def __call__(self, params: Params) -> ToolReturnValue: return result.rejection_error() # Write the modified content back to the file preserving original line endings - await p.write_bytes(content.encode("utf-8", errors="replace")) + await p.write_bytes(content.encode("utf-8")) # Count changes for success message total_replacements = 0 diff --git a/src/kimi_cli/tools/file/write.py b/src/kimi_cli/tools/file/write.py index 0fb3023c6..18634e76e 100644 --- a/src/kimi_cli/tools/file/write.py +++ b/src/kimi_cli/tools/file/write.py @@ -171,7 +171,7 @@ async def __call__(self, params: Params) -> ToolReturnValue: content_to_write = params.content.replace("\r\n", "\n").replace("\r", "\n").replace("\n", eol_style) else: content_to_write = params.content - await p.write_bytes(content_to_write.encode("utf-8", errors="replace")) + await p.write_bytes(content_to_write.encode("utf-8")) case "append": await p.append_text(params.content)