wip: try repairing edit tool requests

Kujtim Hoxha created

Change summary

internal/agent/tools/edit.go            | 305 ++++++++++++++
internal/agent/tools/edit_fuzzy_test.go | 542 +++++++++++++++++++++++++++
internal/agent/tools/multiedit.go       |  21 
3 files changed, 837 insertions(+), 31 deletions(-)

Detailed changes

internal/agent/tools/edit.go 🔗

@@ -7,6 +7,7 @@ import (
 	"log/slog"
 	"os"
 	"path/filepath"
+	"regexp"
 	"strings"
 	"time"
 
@@ -211,25 +212,28 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
 	var deletionCount int
 
 	if replaceAll {
-		newContent = strings.ReplaceAll(oldContent, oldString, "")
-		deletionCount = strings.Count(oldContent, oldString)
-		if deletionCount == 0 {
+		// For replaceAll, try fuzzy match if exact match fails.
+		replaced, found := replaceAllWithBestMatch(oldContent, oldString, "")
+		if !found {
 			return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
 		}
+		newContent = replaced
+		deletionCount = 1
 	} else {
-		index := strings.Index(oldContent, oldString)
-		if index == -1 {
+		// Try exact match first, then fuzzy match.
+		matchedString, found, isMultiple := findBestMatch(oldContent, oldString)
+		if !found {
 			return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
 		}
-
-		lastIndex := strings.LastIndex(oldContent, oldString)
-		if index != lastIndex {
+		if isMultiple {
 			return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
 		}
 
-		newContent = oldContent[:index] + oldContent[index+len(oldString):]
+		index := strings.Index(oldContent, matchedString)
+		newContent = oldContent[:index] + oldContent[index+len(matchedString):]
 		deletionCount = 1
 	}
+	_ = deletionCount
 
 	sessionID := GetSessionFromContext(edit.ctx)
 
@@ -341,27 +345,26 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
 	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
 
 	var newContent string
-	var replacementCount int
 
 	if replaceAll {
-		newContent = strings.ReplaceAll(oldContent, oldString, newString)
-		replacementCount = strings.Count(oldContent, oldString)
-		if replacementCount == 0 {
+		// For replaceAll, try fuzzy match if exact match fails.
+		replaced, found := replaceAllWithBestMatch(oldContent, oldString, newString)
+		if !found {
 			return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
 		}
+		newContent = replaced
 	} else {
-		index := strings.Index(oldContent, oldString)
-		if index == -1 {
+		// Try exact match first, then fuzzy match.
+		matchedString, found, isMultiple := findBestMatch(oldContent, oldString)
+		if !found {
 			return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
 		}
-
-		lastIndex := strings.LastIndex(oldContent, oldString)
-		if index != lastIndex {
+		if isMultiple {
 			return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
 		}
 
-		newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
-		replacementCount = 1
+		index := strings.Index(oldContent, matchedString)
+		newContent = oldContent[:index] + newString + oldContent[index+len(matchedString):]
 	}
 
 	if oldContent == newContent {
@@ -440,3 +443,265 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
 			Removals:   removals,
 		}), nil
 }
+
+// findBestMatch attempts to find a match for oldString in content. If an exact
+// match is found, it returns the oldString unchanged. Otherwise, it tries
+// several normalization strategies to find a fuzzy match.
+//
+// Returns: (matchedString, found, isMultiple)
+//   - matchedString: the actual string found in content that should be used
+//   - found: whether any match was found
+//   - isMultiple: whether multiple matches were found (ambiguous)
+func findBestMatch(content, oldString string) (string, bool, bool) {
+	oldString = normalizeOldStringForMatching(oldString)
+
+	// Strategy 1: Exact match.
+	index := strings.Index(content, oldString)
+	if index != -1 {
+		lastIndex := strings.LastIndex(content, oldString)
+		return oldString, true, index != lastIndex
+	}
+
+	// Strategy 2: Try trimming surrounding blank lines.
+	trimmedSurrounding := trimSurroundingBlankLines(oldString)
+	if trimmedSurrounding != "" && trimmedSurrounding != oldString {
+		index := strings.Index(content, trimmedSurrounding)
+		if index != -1 {
+			lastIndex := strings.LastIndex(content, trimmedSurrounding)
+			return trimmedSurrounding, true, index != lastIndex
+		}
+	}
+
+	// Strategy 3: Try trimming trailing whitespace from each line of oldString.
+	trimmedLines := trimTrailingWhitespacePerLine(oldString)
+	if trimmedLines != oldString {
+		index := strings.Index(content, trimmedLines)
+		if index != -1 {
+			lastIndex := strings.LastIndex(content, trimmedLines)
+			return trimmedLines, true, index != lastIndex
+		}
+	}
+
+	// Strategy 4: Try with/without trailing newline.
+	if strings.HasSuffix(oldString, "\n") {
+		withoutTrailing := strings.TrimSuffix(oldString, "\n")
+		index := strings.Index(content, withoutTrailing)
+		if index != -1 {
+			lastIndex := strings.LastIndex(content, withoutTrailing)
+			return withoutTrailing, true, index != lastIndex
+		}
+	} else {
+		withTrailing := oldString + "\n"
+		index := strings.Index(content, withTrailing)
+		if index != -1 {
+			lastIndex := strings.LastIndex(content, withTrailing)
+			return withTrailing, true, index != lastIndex
+		}
+	}
+
+	// Strategy 5: Try matching with flexible blank lines (collapse multiple
+	// blank lines to single).
+	collapsedOld := collapseBlankLines(oldString)
+	if collapsedOld != oldString {
+		index := strings.Index(content, collapsedOld)
+		if index != -1 {
+			lastIndex := strings.LastIndex(content, collapsedOld)
+			return collapsedOld, true, index != lastIndex
+		}
+	}
+
+	// Strategy 6: Try normalizing indentation (find content with same structure
+	// but different leading whitespace).
+	matched, found, isMultiple := tryNormalizeIndentation(content, oldString)
+	if found {
+		return matched, true, isMultiple
+	}
+
+	if collapsedOld != oldString {
+		matched, found, isMultiple := tryNormalizeIndentation(content, collapsedOld)
+		if found {
+			return matched, true, isMultiple
+		}
+	}
+
+	return "", false, false
+}
+
+var viewLinePrefixRE = regexp.MustCompile(`^\s*\d+\|\s?`)
+
+func normalizeOldStringForMatching(oldString string) string {
+	oldString, _ = fsext.ToUnixLineEndings(oldString)
+	oldString = stripZeroWidthCharacters(oldString)
+	oldString = stripMarkdownCodeFences(oldString)
+	oldString = stripViewLineNumbers(oldString)
+	return oldString
+}
+
+func stripZeroWidthCharacters(s string) string {
+	s = strings.ReplaceAll(s, "\ufeff", "")
+	s = strings.ReplaceAll(s, "\u200b", "")
+	s = strings.ReplaceAll(s, "\u200c", "")
+	s = strings.ReplaceAll(s, "\u200d", "")
+	s = strings.ReplaceAll(s, "\u2060", "")
+	return s
+}
+
+func stripMarkdownCodeFences(s string) string {
+	re := regexp.MustCompile("(?s)^\\s*```[^\\n]*\\n(.*)\\n```\\s*$")
+	m := re.FindStringSubmatch(s)
+	if len(m) != 2 {
+		return s
+	}
+	return m[1]
+}
+
+func stripViewLineNumbers(s string) string {
+	lines := strings.Split(s, "\n")
+	if len(lines) < 2 {
+		return s
+	}
+
+	var withPrefix int
+	for _, line := range lines {
+		if viewLinePrefixRE.MatchString(line) {
+			withPrefix++
+		}
+	}
+
+	if withPrefix < (len(lines)+1)/2 {
+		return s
+	}
+
+	for i, line := range lines {
+		lines[i] = viewLinePrefixRE.ReplaceAllString(line, "")
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+func trimSurroundingBlankLines(s string) string {
+	lines := strings.Split(s, "\n")
+	start := 0
+	for start < len(lines) && strings.TrimSpace(lines[start]) == "" {
+		start++
+	}
+
+	end := len(lines)
+	for end > start && strings.TrimSpace(lines[end-1]) == "" {
+		end--
+	}
+
+	return strings.Join(lines[start:end], "\n")
+}
+
+func replaceAllWithBestMatch(content, oldString, newString string) (string, bool) {
+	oldString = normalizeOldStringForMatching(oldString)
+	if oldString == "" {
+		return "", false
+	}
+
+	if strings.Contains(content, oldString) {
+		return strings.ReplaceAll(content, oldString, newString), true
+	}
+
+	newContent, ok := tryReplaceAllWithFlexibleMultilineRegexp(content, oldString, newString)
+	if ok {
+		return newContent, true
+	}
+
+	collapsedOld := collapseBlankLines(oldString)
+	if collapsedOld != oldString {
+		newContent, ok := tryReplaceAllWithFlexibleMultilineRegexp(content, collapsedOld, newString)
+		if ok {
+			return newContent, true
+		}
+	}
+
+	matchedString, found, _ := findBestMatch(content, oldString)
+	if !found || matchedString == "" {
+		return "", false
+	}
+	return strings.ReplaceAll(content, matchedString, newString), true
+}
+
+func tryReplaceAllWithFlexibleMultilineRegexp(content, oldString, newString string) (string, bool) {
+	re := buildFlexibleMultilineRegexp(oldString)
+	if re == nil {
+		return "", false
+	}
+
+	if !re.MatchString(content) {
+		return "", false
+	}
+
+	newContent := re.ReplaceAllStringFunc(content, func(string) string {
+		return newString
+	})
+	return newContent, true
+}
+
+func buildFlexibleMultilineRegexp(oldString string) *regexp.Regexp {
+	oldString = normalizeOldStringForMatching(oldString)
+	lines := strings.Split(oldString, "\n")
+	if len(lines) > 0 && lines[len(lines)-1] == "" {
+		lines = lines[:len(lines)-1]
+	}
+	if len(lines) < 2 {
+		return nil
+	}
+
+	patternParts := make([]string, 0, len(lines))
+	for _, line := range lines {
+		trimmedLeft := strings.TrimLeft(line, " \t")
+		trimmed := strings.TrimRight(trimmedLeft, " \t")
+		if trimmed == "" {
+			patternParts = append(patternParts, `^[ \t]*$`)
+			continue
+		}
+		escaped := regexp.QuoteMeta(trimmed)
+		patternParts = append(patternParts, `^[ \t]*`+escaped+`[ \t]*$`)
+	}
+
+	pattern := "(?m)" + strings.Join(patternParts, "\n")
+	re, err := regexp.Compile(pattern)
+	if err != nil {
+		return nil
+	}
+	return re
+}
+
+// trimTrailingWhitespacePerLine removes trailing spaces/tabs from each line.
+func trimTrailingWhitespacePerLine(s string) string {
+	lines := strings.Split(s, "\n")
+	for i, line := range lines {
+		lines[i] = strings.TrimRight(line, " \t")
+	}
+	return strings.Join(lines, "\n")
+}
+
+// collapseBlankLines replaces multiple consecutive blank lines with a single
+// blank line.
+func collapseBlankLines(s string) string {
+	re := regexp.MustCompile(`\n{3,}`)
+	return re.ReplaceAllString(s, "\n\n")
+}
+
+// tryNormalizeIndentation attempts to find a match by adjusting indentation.
+// It extracts the "shape" of the code (non-whitespace content per line) and
+// looks for that pattern in the content with potentially different
+// indentation.
+func tryNormalizeIndentation(content, oldString string) (string, bool, bool) {
+	re := buildFlexibleMultilineRegexp(oldString)
+	if re == nil {
+		return "", false, false
+	}
+
+	matches := re.FindAllStringIndex(content, 2)
+	if len(matches) == 0 {
+		return "", false, false
+	}
+	if len(matches) > 1 {
+		return content[matches[0][0]:matches[0][1]], true, true
+	}
+	return content[matches[0][0]:matches[0][1]], true, false
+}

internal/agent/tools/edit_fuzzy_test.go 🔗

@@ -0,0 +1,542 @@
+package tools
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/filetracker"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/stretchr/testify/require"
+)
+
+func TestFindBestMatch_ExactMatch(t *testing.T) {
+	t.Parallel()
+
+	content := "func foo() {\n\treturn 1\n}\n"
+	oldString := "func foo() {\n\treturn 1\n}"
+
+	matched, found, isMultiple := findBestMatch(content, oldString)
+	require.True(t, found)
+	require.False(t, isMultiple)
+	require.Equal(t, oldString, matched)
+}
+
+func TestFindBestMatch_TrailingWhitespacePerLine(t *testing.T) {
+	t.Parallel()
+
+	// Content has no trailing spaces, but oldString has trailing spaces.
+	content := "func foo() {\n\treturn 1\n}\n"
+	oldString := "func foo() {  \n\treturn 1  \n}"
+
+	matched, found, isMultiple := findBestMatch(content, oldString)
+	require.True(t, found)
+	require.False(t, isMultiple)
+	require.Equal(t, "func foo() {\n\treturn 1\n}", matched)
+}
+
+func TestFindBestMatch_TrailingNewline(t *testing.T) {
+	t.Parallel()
+
+	// Content has trailing newline, oldString doesn't.
+	content := "line1\nline2\n"
+	oldString := "line1\nline2"
+
+	matched, found, isMultiple := findBestMatch(content, oldString)
+	require.True(t, found)
+	require.False(t, isMultiple)
+	require.Equal(t, "line1\nline2", matched)
+}
+
+func TestFindBestMatch_MissingTrailingNewline(t *testing.T) {
+	t.Parallel()
+
+	// Content doesn't have trailing newline after match, but oldString does.
+	content := "line1\nline2"
+	oldString := "line1\nline2\n"
+
+	matched, found, isMultiple := findBestMatch(content, oldString)
+	require.True(t, found)
+	require.False(t, isMultiple)
+	require.Equal(t, "line1\nline2", matched)
+}
+
+func TestFindBestMatch_IndentationDifference(t *testing.T) {
+	t.Parallel()
+
+	// Content uses tabs, oldString uses spaces.
+	content := "func foo() {\n\treturn 1\n}\n"
+	oldString := "func foo() {\n    return 1\n}"
+
+	matched, found, isMultiple := findBestMatch(content, oldString)
+	require.True(t, found)
+	require.False(t, isMultiple)
+	require.Equal(t, "func foo() {\n\treturn 1\n}", matched)
+}
+
+func TestFindBestMatch_DifferentIndentLevel(t *testing.T) {
+	t.Parallel()
+
+	// Content has 4-space indent, oldString has 2-space indent.
+	content := "func foo() {\n    return 1\n}\n"
+	oldString := "func foo() {\n  return 1\n}"
+
+	matched, found, isMultiple := findBestMatch(content, oldString)
+	require.True(t, found)
+	require.False(t, isMultiple)
+	require.Equal(t, "func foo() {\n    return 1\n}", matched)
+}
+
+func TestFindBestMatch_CollapseBlankLines(t *testing.T) {
+	t.Parallel()
+
+	// Content has single blank line, oldString has multiple.
+	content := "line1\n\nline2\n"
+	oldString := "line1\n\n\n\nline2"
+
+	matched, found, isMultiple := findBestMatch(content, oldString)
+	require.True(t, found)
+	require.False(t, isMultiple)
+	require.Equal(t, "line1\n\nline2", matched)
+}
+
+func TestFindBestMatch_MultipleMatches(t *testing.T) {
+	t.Parallel()
+
+	content := "foo\nbar\nfoo\n"
+	oldString := "foo"
+
+	matched, found, isMultiple := findBestMatch(content, oldString)
+	require.True(t, found)
+	require.True(t, isMultiple)
+	require.Equal(t, "foo", matched)
+}
+
+func TestFindBestMatch_NoMatch(t *testing.T) {
+	t.Parallel()
+
+	content := "func foo() {\n\treturn 1\n}\n"
+	oldString := "func bar() {\n\treturn 2\n}"
+
+	_, found, _ := findBestMatch(content, oldString)
+	require.False(t, found)
+}
+
+func TestFindBestMatch_StripsViewLineNumbers(t *testing.T) {
+	t.Parallel()
+
+	content := "line1\nline2\nline3\n"
+	oldString := "  1|line1\n  2|line2\n  3|line3"
+
+	matched, found, isMultiple := findBestMatch(content, oldString)
+	require.True(t, found)
+	require.False(t, isMultiple)
+	require.Equal(t, "line1\nline2\nline3", matched)
+}
+
+func TestFindBestMatch_StripsMarkdownCodeFences(t *testing.T) {
+	t.Parallel()
+
+	content := "line1\nline2\n"
+	oldString := "```go\nline1\nline2\n```"
+
+	matched, found, isMultiple := findBestMatch(content, oldString)
+	require.True(t, found)
+	require.False(t, isMultiple)
+	require.Equal(t, "line1\nline2", matched)
+}
+
+func TestFindBestMatch_TrimsSurroundingBlankLines(t *testing.T) {
+	t.Parallel()
+
+	content := "line1\nline2\n"
+	oldString := "\n\nline1\nline2\n\n"
+
+	matched, found, isMultiple := findBestMatch(content, oldString)
+	require.True(t, found)
+	require.False(t, isMultiple)
+	require.Equal(t, "line1\nline2", matched)
+}
+
+func TestFindBestMatch_StripsZeroWidthCharacters(t *testing.T) {
+	t.Parallel()
+
+	content := "line1\nline2\n"
+	oldString := "line\u200b1\nline2"
+
+	matched, found, isMultiple := findBestMatch(content, oldString)
+	require.True(t, found)
+	require.False(t, isMultiple)
+	require.Equal(t, "line1\nline2", matched)
+}
+
+func TestFindBestMatch_ComplexIndentation(t *testing.T) {
+	t.Parallel()
+
+	content := `func example() {
+	if true {
+		doSomething()
+	}
+}
+`
+	// Model provided with wrong indentation (2 spaces instead of tabs).
+	oldString := `func example() {
+  if true {
+    doSomething()
+  }
+}`
+
+	matched, found, isMultiple := findBestMatch(content, oldString)
+	require.True(t, found)
+	require.False(t, isMultiple)
+	require.Contains(t, matched, "\t")
+}
+
+func TestApplyEditToContent_FuzzyMatch(t *testing.T) {
+	t.Parallel()
+
+	// Content uses tabs, edit uses spaces - should still match.
+	content := "func foo() {\n\treturn 1\n}\n"
+
+	newContent, err := applyEditToContent(content, MultiEditOperation{
+		OldString: "func foo() {\n    return 1\n}",
+		NewString: "func foo() {\n\treturn 2\n}",
+	})
+	require.NoError(t, err)
+	require.Contains(t, newContent, "return 2")
+}
+
+func TestApplyEditToContent_FuzzyMatchTrailingSpaces(t *testing.T) {
+	t.Parallel()
+
+	content := "line 1\nline 2\nline 3\n"
+
+	// Edit has trailing spaces that don't exist in content.
+	newContent, err := applyEditToContent(content, MultiEditOperation{
+		OldString: "line 1  \nline 2  ",
+		NewString: "LINE 1\nLINE 2",
+	})
+	require.NoError(t, err)
+	require.Contains(t, newContent, "LINE 1")
+	require.Contains(t, newContent, "LINE 2")
+}
+
+func TestApplyEditToContent_FuzzyMatchReplaceAll(t *testing.T) {
+	t.Parallel()
+
+	content := "foo bar\nfoo baz\n"
+
+	// With replaceAll and fuzzy match (trailing space).
+	newContent, err := applyEditToContent(content, MultiEditOperation{
+		OldString:  "foo ",
+		NewString:  "FOO ",
+		ReplaceAll: true,
+	})
+	require.NoError(t, err)
+	require.Contains(t, newContent, "FOO bar")
+	require.Contains(t, newContent, "FOO baz")
+}
+
+func TestTrimTrailingWhitespacePerLine(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		input    string
+		expected string
+	}{
+		{
+			name:     "trailing spaces",
+			input:    "line1  \nline2   \nline3",
+			expected: "line1\nline2\nline3",
+		},
+		{
+			name:     "trailing tabs",
+			input:    "line1\t\nline2\t\t\nline3",
+			expected: "line1\nline2\nline3",
+		},
+		{
+			name:     "mixed trailing whitespace",
+			input:    "line1 \t \nline2\t \nline3  ",
+			expected: "line1\nline2\nline3",
+		},
+		{
+			name:     "no trailing whitespace",
+			input:    "line1\nline2\nline3",
+			expected: "line1\nline2\nline3",
+		},
+		{
+			name:     "preserves leading whitespace",
+			input:    "  line1  \n\tline2\t\n    line3  ",
+			expected: "  line1\n\tline2\n    line3",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			result := trimTrailingWhitespacePerLine(tt.input)
+			require.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func TestCollapseBlankLines(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		input    string
+		expected string
+	}{
+		{
+			name:     "multiple blank lines",
+			input:    "line1\n\n\n\nline2",
+			expected: "line1\n\nline2",
+		},
+		{
+			name:     "single blank line unchanged",
+			input:    "line1\n\nline2",
+			expected: "line1\n\nline2",
+		},
+		{
+			name:     "no blank lines",
+			input:    "line1\nline2",
+			expected: "line1\nline2",
+		},
+		{
+			name:     "many blank lines",
+			input:    "line1\n\n\n\n\n\n\nline2",
+			expected: "line1\n\nline2",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			result := collapseBlankLines(tt.input)
+			require.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+// Integration tests that test the actual replaceContent and deleteContent
+// functions with real files.
+
+func newTestEditContext(t *testing.T) (editContext, string) {
+	t.Helper()
+	tmpDir := t.TempDir()
+	permissions := &mockPermissionService{Broker: pubsub.NewBroker[permission.PermissionRequest]()}
+	files := &mockHistoryService{Broker: pubsub.NewBroker[history.File]()}
+	ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
+	return editContext{ctx, permissions, files, tmpDir}, tmpDir
+}
+
+func TestEditTool_ReplaceContent_FuzzyIndentation(t *testing.T) {
+	t.Parallel()
+
+	edit, tmpDir := newTestEditContext(t)
+	testFile := filepath.Join(tmpDir, "test.go")
+
+	// File uses tabs for indentation.
+	content := "func foo() {\n\treturn 1\n}\n"
+	err := os.WriteFile(testFile, []byte(content), 0o644)
+	require.NoError(t, err)
+
+	// Simulate reading the file first.
+	filetracker.RecordRead(testFile)
+
+	// Model provides spaces instead of tabs.
+	oldString := "func foo() {\n    return 1\n}"
+	newString := "func foo() {\n\treturn 2\n}"
+
+	resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"})
+	require.NoError(t, err)
+	require.False(t, resp.IsError, "expected no error, got: %s", resp.Content)
+
+	// Verify the file was updated.
+	result, err := os.ReadFile(testFile)
+	require.NoError(t, err)
+	require.Contains(t, string(result), "return 2")
+}
+
+func TestEditTool_ReplaceContent_FuzzyTrailingWhitespace(t *testing.T) {
+	t.Parallel()
+
+	edit, tmpDir := newTestEditContext(t)
+	testFile := filepath.Join(tmpDir, "test.txt")
+
+	// File has no trailing whitespace.
+	content := "line 1\nline 2\nline 3\n"
+	err := os.WriteFile(testFile, []byte(content), 0o644)
+	require.NoError(t, err)
+
+	filetracker.RecordRead(testFile)
+
+	// Model provides trailing spaces.
+	oldString := "line 1  \nline 2  "
+	newString := "LINE 1\nLINE 2"
+
+	resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"})
+	require.NoError(t, err)
+	require.False(t, resp.IsError, "expected no error, got: %s", resp.Content)
+
+	result, err := os.ReadFile(testFile)
+	require.NoError(t, err)
+	require.Contains(t, string(result), "LINE 1")
+	require.Contains(t, string(result), "LINE 2")
+}
+
+func TestEditTool_ReplaceContent_FuzzyTrailingNewline(t *testing.T) {
+	t.Parallel()
+
+	edit, tmpDir := newTestEditContext(t)
+	testFile := filepath.Join(tmpDir, "test.txt")
+
+	// File content.
+	content := "hello\nworld\n"
+	err := os.WriteFile(testFile, []byte(content), 0o644)
+	require.NoError(t, err)
+
+	filetracker.RecordRead(testFile)
+
+	// Model omits trailing newline.
+	oldString := "hello\nworld"
+	newString := "HELLO\nWORLD"
+
+	resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"})
+	require.NoError(t, err)
+	require.False(t, resp.IsError, "expected no error, got: %s", resp.Content)
+
+	result, err := os.ReadFile(testFile)
+	require.NoError(t, err)
+	require.Contains(t, string(result), "HELLO")
+	require.Contains(t, string(result), "WORLD")
+}
+
+func TestEditTool_ReplaceContent_ExactMatchStillWorks(t *testing.T) {
+	t.Parallel()
+
+	edit, tmpDir := newTestEditContext(t)
+	testFile := filepath.Join(tmpDir, "test.txt")
+
+	content := "func foo() {\n\treturn 1\n}\n"
+	err := os.WriteFile(testFile, []byte(content), 0o644)
+	require.NoError(t, err)
+
+	filetracker.RecordRead(testFile)
+
+	// Exact match should still work.
+	oldString := "func foo() {\n\treturn 1\n}"
+	newString := "func foo() {\n\treturn 2\n}"
+
+	resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"})
+	require.NoError(t, err)
+	require.False(t, resp.IsError, "expected no error, got: %s", resp.Content)
+
+	result, err := os.ReadFile(testFile)
+	require.NoError(t, err)
+	require.Contains(t, string(result), "return 2")
+}
+
+func TestEditTool_ReplaceContent_NoMatchStillFails(t *testing.T) {
+	t.Parallel()
+
+	edit, tmpDir := newTestEditContext(t)
+	testFile := filepath.Join(tmpDir, "test.txt")
+
+	content := "func foo() {\n\treturn 1\n}\n"
+	err := os.WriteFile(testFile, []byte(content), 0o644)
+	require.NoError(t, err)
+
+	filetracker.RecordRead(testFile)
+
+	// Completely wrong content should still fail.
+	oldString := "func bar() {\n\treturn 999\n}"
+	newString := "func baz() {\n\treturn 0\n}"
+
+	resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"})
+	require.NoError(t, err)
+	require.True(t, resp.IsError, "expected error for no match")
+	require.Contains(t, resp.Content, "not found")
+}
+
+func TestEditTool_DeleteContent_FuzzyIndentation(t *testing.T) {
+	t.Parallel()
+
+	edit, tmpDir := newTestEditContext(t)
+	testFile := filepath.Join(tmpDir, "test.go")
+
+	// File uses tabs.
+	content := "func foo() {\n\treturn 1\n}\n\nfunc bar() {\n\treturn 2\n}\n"
+	err := os.WriteFile(testFile, []byte(content), 0o644)
+	require.NoError(t, err)
+
+	filetracker.RecordRead(testFile)
+
+	// Model provides spaces instead of tabs.
+	oldString := "func foo() {\n    return 1\n}\n\n"
+
+	resp, err := deleteContent(edit, testFile, oldString, false, fantasy.ToolCall{ID: "test"})
+	require.NoError(t, err)
+	require.False(t, resp.IsError, "expected no error, got: %s", resp.Content)
+
+	result, err := os.ReadFile(testFile)
+	require.NoError(t, err)
+	require.NotContains(t, string(result), "return 1")
+	require.Contains(t, string(result), "return 2")
+}
+
+func TestEditTool_ReplaceContent_ReplaceAllFuzzy(t *testing.T) {
+	t.Parallel()
+
+	edit, tmpDir := newTestEditContext(t)
+	testFile := filepath.Join(tmpDir, "test.txt")
+
+	content := "foo bar\nfoo baz\nfoo qux\n"
+	err := os.WriteFile(testFile, []byte(content), 0o644)
+	require.NoError(t, err)
+
+	filetracker.RecordRead(testFile)
+
+	// ReplaceAll with exact match.
+	oldString := "foo"
+	newString := "FOO"
+
+	resp, err := replaceContent(edit, testFile, oldString, newString, true, fantasy.ToolCall{ID: "test"})
+	require.NoError(t, err)
+	require.False(t, resp.IsError, "expected no error, got: %s", resp.Content)
+
+	result, err := os.ReadFile(testFile)
+	require.NoError(t, err)
+	require.Contains(t, string(result), "FOO bar")
+	require.Contains(t, string(result), "FOO baz")
+	require.Contains(t, string(result), "FOO qux")
+	require.NotContains(t, string(result), "foo")
+}
+
+func TestEditTool_ReplaceContent_MultipleMatchesFails(t *testing.T) {
+	t.Parallel()
+
+	edit, tmpDir := newTestEditContext(t)
+	testFile := filepath.Join(tmpDir, "test.txt")
+
+	content := "foo\nbar\nfoo\n"
+	err := os.WriteFile(testFile, []byte(content), 0o644)
+	require.NoError(t, err)
+
+	filetracker.RecordRead(testFile)
+
+	// Should fail because "foo" appears multiple times.
+	oldString := "foo"
+	newString := "FOO"
+
+	resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"})
+	require.NoError(t, err)
+	require.True(t, resp.IsError, "expected error for multiple matches")
+	require.Contains(t, resp.Content, "multiple times")
+}

internal/agent/tools/multiedit.go 🔗

@@ -396,27 +396,26 @@ func applyEditToContent(content string, edit MultiEditOperation) (string, error)
 	}
 
 	var newContent string
-	var replacementCount int
 
 	if edit.ReplaceAll {
-		newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
-		replacementCount = strings.Count(content, edit.OldString)
-		if replacementCount == 0 {
+		// For replaceAll, try fuzzy match if exact match fails.
+		replaced, found := replaceAllWithBestMatch(content, edit.OldString, edit.NewString)
+		if !found {
 			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
 		}
+		newContent = replaced
 	} else {
-		index := strings.Index(content, edit.OldString)
-		if index == -1 {
+		// Try exact match first, then fuzzy match.
+		matchedString, found, isMultiple := findBestMatch(content, edit.OldString)
+		if !found {
 			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
 		}
-
-		lastIndex := strings.LastIndex(content, edit.OldString)
-		if index != lastIndex {
+		if isMultiple {
 			return "", fmt.Errorf("old_string appears multiple times in the content. Please provide more context to ensure a unique match, or set replace_all to true")
 		}
 
-		newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
-		replacementCount = 1
+		index := strings.Index(content, matchedString)
+		newContent = content[:index] + edit.NewString + content[index+len(matchedString):]
 	}
 
 	return newContent, nil