From 45cefbdf4cedffb98d4bea88be85c23a0ee6dfcd Mon Sep 17 00:00:00 2001 From: Sreekant Baheti <60787859+Sreekant13@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:00:21 -0700 Subject: [PATCH] fix(tools): count StrReplaceFile replacements against the running content StrReplaceFile applies its edits sequentially but computed the reported replacement count against the original file content. A chained edit whose `old` string is produced by an earlier edit is not present in the original, so it was counted as zero: editing "hello" -> "goodbye" then "goodbye" -> "farewell" reported "1 total replacement(s)" instead of 2. Count each edit against the content it is actually applied to, and add a regression test. --- CHANGELOG.md | 1 + src/kimi_cli/tools/file/replace.py | 19 ++++++++++--------- tests/tools/test_str_replace_file.py | 25 +++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e500e09c2..905abbc836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Only write entries that are worth mentioning to users. ## Unreleased - Kosong: Stop sending an empty `anthropic-beta` header when no beta features are declared — adaptive thinking removes the interleaved-thinking beta, which previously left an empty header value that some backends reject +- Tool: Fix StrReplaceFile reporting the wrong replacement count when edits are chained. The total was counted against the original file content, so a later edit whose target text was produced by an earlier edit was counted as zero; the count now tracks the running content ## 1.49.0 (2026-07-16) diff --git a/src/kimi_cli/tools/file/replace.py b/src/kimi_cli/tools/file/replace.py index 4f551de4f4..bf197a5312 100644 --- a/src/kimi_cli/tools/file/replace.py +++ b/src/kimi_cli/tools/file/replace.py @@ -134,9 +134,18 @@ async def __call__(self, params: Params) -> ToolReturnValue: original_content = content edits = [params.edit] if isinstance(params.edit, Edit) else params.edit - # Apply all edits + # Apply all edits, counting replacements against the running content. + # Edits apply sequentially, so counting against the original content + # miscounts chained edits (e.g. one edit whose `old` is produced by an + # earlier edit is not found in the original and would be counted 0). + total_replacements = 0 for edit in edits: + before = content content = self._apply_edit(content, edit) + if edit.replace_all: + total_replacements += before.count(edit.old) + elif edit.old in before: + total_replacements += 1 # Check if any changes were made if content == original_content: @@ -169,14 +178,6 @@ async def __call__(self, params: Params) -> ToolReturnValue: # Write the modified content back to the file await p.write_text(content, 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) - else: - total_replacements += 1 if edit.old in original_content else 0 - return ToolReturnValue( is_error=False, output="", diff --git a/tests/tools/test_str_replace_file.py b/tests/tools/test_str_replace_file.py index a16dad303b..85ce55d2fd 100644 --- a/tests/tools/test_str_replace_file.py +++ b/tests/tools/test_str_replace_file.py @@ -75,6 +75,31 @@ async def test_replace_multiple_edits( assert await file_path.read_text() == "Hi world! See you world!" +async def test_replace_chained_edits_report_correct_count( + str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath +): + """Chained edits (a later edit's `old` is produced by an earlier one) must + report the true number of replacements, not the count against the original.""" + file_path = temp_work_dir / "test.txt" + await file_path.write_text("hello world") + + result = await str_replace_file_tool( + Params( + path=str(file_path), + edit=[ + Edit(old="hello", new="goodbye"), + Edit(old="goodbye", new="farewell"), + ], + ) + ) + + assert not result.is_error + assert await file_path.read_text() == "farewell world" + # Both edits replaced text, so the message must say 2 replacements (the old + # code counted "goodbye" against the original and reported 1). + assert "2 total replacement(s)" in result.message + + async def test_replace_multiline_content( str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath ):