diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e500e09c2..52a56ecc5a 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 +- Tools: Fix the StrReplaceFile success message reporting the wrong replacement total when multiple edits interact; the count now follows the running content instead of the original file ## 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..ac8f5e725d 100644 --- a/src/kimi_cli/tools/file/replace.py +++ b/src/kimi_cli/tools/file/replace.py @@ -78,12 +78,15 @@ async def _validate_path(self, path: KaosPath) -> ToolError | None: ) return None - def _apply_edit(self, content: str, edit: Edit) -> str: - """Apply a single edit to the content.""" + def _apply_edit(self, content: str, edit: Edit) -> tuple[str, int]: + """Apply a single edit, returning the new content and how many + replacements it made against the content passed in.""" if edit.replace_all: - return content.replace(edit.old, edit.new) + count = content.count(edit.old) + return content.replace(edit.old, edit.new), count else: - return content.replace(edit.old, edit.new, 1) + count = 1 if edit.old in content else 0 + return content.replace(edit.old, edit.new, 1), count @override async def __call__(self, params: Params) -> ToolReturnValue: @@ -134,9 +137,14 @@ 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 as they actually happen. + # Edits apply sequentially, so a later edit sees the output of the + # earlier ones; counting against the running content keeps the + # reported total accurate when edits interact. + total_replacements = 0 for edit in edits: - content = self._apply_edit(content, edit) + content, n_replacements = self._apply_edit(content, edit) + total_replacements += n_replacements # Check if any changes were made if content == original_content: @@ -169,14 +177,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..d26ccd32a2 100644 --- a/tests/tools/test_str_replace_file.py +++ b/tests/tools/test_str_replace_file.py @@ -231,6 +231,49 @@ async def test_replace_mixed_multiple_edits( assert await file_path.read_text() == "fruit apple tasty apple cherry" +async def test_replace_reports_replacement_count( + str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath +): + """A single replace_all edit reports the number of occurrences replaced.""" + file_path = temp_work_dir / "test.txt" + await file_path.write_text("apple banana apple cherry apple") + + result = await str_replace_file_tool( + Params( + path=str(file_path), + edit=Edit(old="apple", new="fruit", replace_all=True), + ) + ) + + assert not result.is_error + assert "3 total replacement(s)" in result.message + + +async def test_replace_count_accounts_for_interacting_edits( + str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath +): + """When an earlier edit changes how many times a later edit matches, the + reported count must follow the running content, not the original file.""" + file_path = temp_work_dir / "test.txt" + await file_path.write_text("foo bar foo") + + result = await str_replace_file_tool( + Params( + path=str(file_path), + edit=[ + Edit(old="foo", new="foo bar", replace_all=True), + Edit(old="bar", new="baz", replace_all=True), + ], + ) + ) + + assert not result.is_error + # foo->"foo bar" replaces 2, producing "foo bar bar foo bar"; bar->baz then + # replaces 3. Counting against the original file would wrongly report 3. + assert await file_path.read_text() == "foo baz baz foo baz" + assert "5 total replacement(s)" in result.message + + async def test_replace_empty_strings( str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath ):