From 93162d2f1076df3c0d7d5a5d7f13d7780e89222c Mon Sep 17 00:00:00 2001 From: xiaweiwei67-stack <293320877+xiaweiwei67-stack@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:24:58 +0800 Subject: [PATCH] fix(edit): preserve file indentation when oldText is unindented The fuzzy fallback anchored every line with a leading [ \t]* that greedily consumed the file's indentation into the matched region, so a no-indent oldText dropped the edited line's indentation. Fold leading whitespace into the match only when oldText was itself indented on the first line, mirroring exact-substring semantics. Add a regression test. --- agent/tools/utils/diff.py | 23 +++++++++++++++-------- tests/test_edit_tool.py | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/agent/tools/utils/diff.py b/agent/tools/utils/diff.py index 1872f326..bcb5c694 100644 --- a/agent/tools/utils/diff.py +++ b/agent/tools/utils/diff.py @@ -123,17 +123,24 @@ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult: # with collapsed indentation (every untouched line got reformatted). stripped = old_text.strip('\n') if stripped.strip(): + source_lines = stripped.split('\n') line_patterns = [] - for line in stripped.split('\n'): + for i, line in enumerate(source_lines): tokens = line.split() - if tokens: - # Tolerate flexible indentation and any run of blanks between - # tokens, matching the leniency of the old normalized compare. - line_patterns.append( - r'[ \t]*' + r'[ \t]+'.join(re.escape(tok) for tok in tokens) + r'[ \t]*' - ) - else: + if not tokens: line_patterns.append(r'[ \t]*') + continue + # Tolerate any run of blanks between tokens. + core = r'[ \t]+'.join(re.escape(tok) for tok in tokens) + # First-line leading whitespace is folded into the match only when + # old_text itself was indented here; otherwise it stays OUTSIDE the + # match so a no-indent old_text preserves (does not swallow and drop) + # the file's existing indentation -- mirroring an exact substring + # match. Inner lines always tolerate indentation: it sits inside the + # matched region and is re-supplied by new_text. + if i > 0 or line[:1] in (' ', '\t'): + core = r'[ \t]*' + core + line_patterns.append(core + r'[ \t]*') pattern = '\n'.join(line_patterns) match = re.search(pattern, content) if match: diff --git a/tests/test_edit_tool.py b/tests/test_edit_tool.py index 2d006674..24b77759 100644 --- a/tests/test_edit_tool.py +++ b/tests/test_edit_tool.py @@ -80,6 +80,24 @@ class TestEditFuzzyPreservesWhitespace(unittest.TestCase): "def foo():\n x = 9\n y = 8\n return x + y\n", ) + def test_fuzzy_match_with_unindented_oldtext_preserves_file_indent(self): + # oldText has NO leading indentation (and loose spacing around '='), so + # the exact match fails and the fuzzy path runs against an indented file + # line. The file's indentation must be preserved -- kept OUTSIDE the + # replaced region -- instead of being swallowed into the match and + # dropped (which would break the file's indentation). newText is + # likewise unindented, mirroring exact-substring replacement. + result = self.tool.execute({ + "path": self.path, + "oldText": "x = 1", + "newText": "x = 100", + }) + self.assertEqual(result.status, "success", result.result) + self.assertEqual( + self._read(), + "def foo():\n x = 100\n y = 2\n return x + y\n", + ) + if __name__ == "__main__": unittest.main()