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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
28 changes: 14 additions & 14 deletions src/kimi_cli/tools/file/replace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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="",
Expand Down
43 changes: 43 additions & 0 deletions tests/tools/test_str_replace_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down