Skip to content

fix(tools): fail StrReplaceFile when a multi-edit hunk is unmatched#2452

Open
Osamaali313 wants to merge 2 commits into
MoonshotAI:mainfrom
Osamaali313:fix/str-replace-atomic-multi-edit
Open

fix(tools): fail StrReplaceFile when a multi-edit hunk is unmatched#2452
Osamaali313 wants to merge 2 commits into
MoonshotAI:mainfrom
Osamaali313:fix/str-replace-atomic-multi-edit

Conversation

@Osamaali313

@Osamaali313 Osamaali313 commented Jun 14, 2026

Copy link
Copy Markdown

Problem

StrReplaceFile applies edits with str.replace and then only errors when the entire result is unchanged:

for edit in edits:
    content = self._apply_edit(content, edit)

if content == original_content:
    return ToolError(message="No replacements were made. ...")

So in a multi-edit call where some edits match and others don't, the non-matching edits are silently skipped and the tool still reports success:

# content = "Hello world! Goodbye world!"
edit = [Edit(old="Hello", new="Hi"), Edit(old="does-not-exist", new="x")]
# -> result.is_error == False
# -> message: "Applied 2 edit(s) with 1 total replacement(s)."
# -> file becomes "Hi world! Goodbye world!"  (edit #2 silently dropped)

The model is told all hunks landed when one never did — a silent loss of requested changes. This is easy to hit when the file has drifted from what the model last read (the same situation behind the frequent old_string not found confusion), because a partially-stale batch looks like it succeeded.

A second, smaller bug: the reported replacement count was computed against the original content for every edit, so it's wrong for sequential dependent edits (e.g. foobar then barbaz reported 1 instead of 2, since "bar" isn't in the original "foo").

Fix

Apply edits sequentially and validate each edit's old string is present at its turn (a later edit may legitimately target text an earlier edit introduced). If any edit doesn't match, fail the whole call without writing, so a multi-edit is atomic and the failure is surfaced with the offending edit index. The replacement count is now tallied as edits actually apply, fixing the sequential-edit count too.

Behavior preserved:

  • Single edit that matches / doesn't match → unchanged (still "No replacements were made").
  • Independent multi-edits that all match → unchanged.
  • The no-op case (old == new) is still guarded.

Tests

Added test_replace_multiple_edits_one_not_found_is_atomic (asserts the batch fails and the file is left untouched) and test_replace_sequential_dependent_edits_count (dependent edits apply and the count is correct). Full tests/tools/test_str_replace_file.py passes (15), plus the plan-mode/additional-dirs suites (19). ruff check / ruff format clean.


Open in Devin Review

StrReplaceFile applied every edit with str.replace and then only errored
when the *entire* result was unchanged (`content == original_content`).
So in a multi-edit call where some edits matched and others did not, the
non-matching edits were silently skipped and the tool still reported
success ("Applied N edit(s)"). The model believes all hunks landed when
some never did — a silent loss of requested changes that is easy to hit
when the file has drifted from what the model last read.

Apply edits sequentially and validate that each edit's old string is
present at its turn (a later edit may legitimately target text an earlier
edit introduced). If any edit does not match, fail the whole call without
writing, so the edit batch is atomic and the failure is surfaced with the
offending edit index.

This also corrects the reported replacement count, which was computed
against the original content for every edit and therefore wrong for
sequential dependent edits (e.g. foo->bar then bar->baz reported 1
instead of 2).

Adds tests for the atomic-failure and sequential-dependent-edit cases.
Copilot AI review requested due to automatic review settings June 14, 2026 16:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR updates the string-replace file tool to enforce atomic multi-edit behavior (fail the whole call if any edit doesn’t match) and to treat edits as sequential transformations, with tests covering these behaviors.

Changes:

  • Enforce atomic failure when any edit in a batch can’t find its old string at the time it applies.
  • Apply edits sequentially and compute a sequential “total replacements” count.
  • Add tests for atomic multi-edit failure and sequential dependent edits/counts.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
tests/tools/test_str_replace_file.py Adds tests for atomic multi-edit failure and sequential dependent edits with count messaging.
src/kimi_cli/tools/file/replace.py Implements sequential edit application with per-edit validation and updated replacement counting.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/kimi_cli/tools/file/replace.py Outdated
Comment on lines 166 to 170
# Defensive: an edit whose old string equals its new string matches
# but changes nothing, so guard the no-op case explicitly.
if content == original_content:
return ToolError(
message="No replacements were made. The old string was not found in the file.",
Comment on lines +143 to +164
total_replacements = 0
for index, edit in enumerate(edits):
occurrences = content.count(edit.old)
if occurrences == 0:
detail = (
"The old string was not found in the file."
if len(edits) == 1
else (
f"Edit #{index + 1}'s old string was not found "
"(an earlier edit in this call may have changed that text)."
)
)
return ToolError(
message=(
f"No replacements were made. {detail} "
"The file contents may be out of date; "
"use the Read tool to reload it."
),
brief="No replacements made",
)
content = self._apply_edit(content, edit)
total_replacements += occurrences if edit.replace_all else 1
Comment thread src/kimi_cli/tools/file/replace.py Outdated
Comment on lines 168 to 170
if content == original_content:
return ToolError(
message="No replacements were made. The old string was not found in the file.",

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Open in Devin Review

Comment thread src/kimi_cli/tools/file/replace.py Outdated
Comment on lines 168 to 170
if content == original_content:
return ToolError(
message="No replacements were made. The old string was not found in the file.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Defensive no-op check produces factually incorrect error message

After the new per-edit validation loop confirms that each edit.old IS present in the content (line 145-146), the defensive check at line 168-172 can still trigger when edits produce content identical to the original (e.g., edit.old == edit.new, or edits that cancel each other out). In these cases, the error message "The old string was not found in the file." is factually wrong — the old strings were all found and matched. This misleading message will cause the AI model to re-read the file looking for a string that is actually present, potentially looping without understanding the real issue (that the edit is a no-op).

(Refers to lines 168-172)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +145 to +146
occurrences = content.count(edit.old)
if occurrences == 0:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Empty edit.old string passes validation (pre-existing behavior)

If edit.old is an empty string, content.count("") returns len(content) + 1 (Python's behavior for counting empty substrings), so the occurrences == 0 check will never trigger. Then content.replace("", new_text) inserts new_text between every character. This is pre-existing behavior (the old code also applied edits without validating against empty old), but is worth noting as a potential edge case the model could trigger. Whether to guard against this is a design decision.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Address review feedback on the multi-edit validation:

- Empty old_string: `str.count('')` returns `len(content) + 1` (never 0),
  so the occurrence check could not catch it and `str.replace('', new)`
  would splice `new` between every character. Reject an empty old string
  per edit with a clear message.
- No-op edits: when every edit matches but the result equals the original
  (old_string == new_string, or edits that cancel out), the final guard
  previously reported "old string was not found" — factually wrong and
  liable to send the model re-reading the file for a string that is
  present. Report that the edit(s) left the file unchanged instead.

Adds tests for both cases.
@Osamaali313

Copy link
Copy Markdown
Author

Thanks @devin-ai-integration — both are valid. Addressed in 884c671:

  1. No-op message: when every edit matches but the result equals the original (old_string == new_string, or edits that cancel out), the final guard no longer claims "old string was not found". It now reports that the edit(s) matched but left the file unchanged, so the model isn't sent re-reading the file for a string that is actually present.

  2. Empty old_string: added an explicit per-edit guard. As noted, str.count('') is len(content) + 1 (never 0) so the occurrence check can't catch it, and str.replace('', new) would splice new between every character. Empty old is now rejected with a clear message.

Added tests for both (test_replace_noop_reports_unchanged_not_missing, test_replace_rejects_empty_old_string); full tool suite passes (17), ruff check/format clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants