diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go index 8d17902f097f6e0b4ebee7d0d684618c91bb0e04..4550e42d510b5bc77be666c4ed1619cc7359e86d 100644 --- a/internal/agent/tools/edit.go +++ b/internal/agent/tools/edit.go @@ -274,6 +274,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } + normalizedNewContent := newContent if isCrlf { newContent, _ = fsext.ToWindowsLineEndings(newContent) } @@ -300,7 +301,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool } } // Store the new version - _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent) + _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, normalizedNewContent) if err != nil { slog.Error("Error creating file history version", "error", err) } @@ -311,7 +312,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool fantasy.NewTextResponse("Content deleted from file: "+filePath), EditResponseMetadata{ OldContent: oldContent, - NewContent: newContent, + NewContent: normalizedNewContent, Additions: additions, Removals: removals, }, @@ -405,6 +406,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } + normalizedNewContent := newContent if isCrlf { newContent, _ = fsext.ToWindowsLineEndings(newContent) } @@ -431,7 +433,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep } } // Store the new version - _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent) + _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, normalizedNewContent) if err != nil { slog.Error("Error creating file history version", "error", err) } @@ -442,7 +444,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep fantasy.NewTextResponse("Content replaced in file: "+filePath), EditResponseMetadata{ OldContent: oldContent, - NewContent: newContent, + NewContent: normalizedNewContent, Additions: additions, Removals: removals, }), nil diff --git a/internal/agent/tools/multiedit.go b/internal/agent/tools/multiedit.go index 28af9206a6485900dc05356c68bcdc091c01fe02..53088142e600ade51aa868a29aed4770614a9fd1 100644 --- a/internal/agent/tools/multiedit.go +++ b/internal/agent/tools/multiedit.go @@ -340,6 +340,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } + normalizedNewContent := currentContent if isCrlf { currentContent, _ = fsext.ToWindowsLineEndings(currentContent) } @@ -367,7 +368,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call } // Store the new version - _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent) + _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, normalizedNewContent) if err != nil { slog.Error("Error creating file history version", "error", err) } @@ -385,7 +386,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.NewTextResponse(message), MultiEditResponseMetadata{ OldContent: oldContent, - NewContent: currentContent, + NewContent: normalizedNewContent, Additions: additions, Removals: removals, EditsApplied: editsApplied, diff --git a/internal/agent/tools/write.go b/internal/agent/tools/write.go index fbc2b8f11e9a84a9848af8eba5d2c2d1aa8ca258..a17513f1b0ed31fcc97160721a7461fa7c47a897 100644 --- a/internal/agent/tools/write.go +++ b/internal/agent/tools/write.go @@ -99,7 +99,7 @@ func NewWriteTool( if fileInfo != nil && !fileInfo.IsDir() { oldBytes, readErr := os.ReadFile(filePath) if readErr == nil { - oldContent = string(oldBytes) + oldContent, _ = fsext.ToUnixLineEndings(string(oldBytes)) } } diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 9e00c87177d1438c5967797a3b4e6f3e7a467dbe..00d0fd7819646337fefdff9575c3b2b531871b24 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -6,8 +6,11 @@ import ( "github.com/aymanbagabas/go-udiff" ) -// GenerateDiff creates a unified diff from two file contents +// GenerateDiff creates a unified diff from two file contents. func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) { + beforeContent = strings.ReplaceAll(beforeContent, "\r\n", "\n") + afterContent = strings.ReplaceAll(afterContent, "\r\n", "\n") + fileName = strings.TrimPrefix(fileName, "/") var ( diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go new file mode 100644 index 0000000000000000000000000000000000000000..cb54c84929591512cdcb7b1b9ddd89cc11ec7190 --- /dev/null +++ b/internal/diff/diff_test.go @@ -0,0 +1,131 @@ +package diff + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerateDiff(t *testing.T) { + t.Parallel() + + before := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello, World!\")\n\tfmt.Println(\"Line 2\")\n}\n" + after := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello, Go!\")\n\tfmt.Println(\"Line 2\")\n}\n" + + t.Run("LF_before_LF_after", func(t *testing.T) { + t.Parallel() + + diff, additions, removals := GenerateDiff(before, after, "main.go") + + require.Equal(t, 1, additions) + require.Equal(t, 1, removals) + require.Contains(t, diff, "-\tfmt.Println(\"Hello, World!\")") + require.Contains(t, diff, "+\tfmt.Println(\"Hello, Go!\")") + }) + + t.Run("CRLF_before_LF_after", func(t *testing.T) { + t.Parallel() + + crlfBefore := "package main\r\n\r\nimport \"fmt\"\r\n\r\nfunc main() {\r\n\tfmt.Println(\"Hello, World!\")\r\n\tfmt.Println(\"Line 2\")\r\n}\r\n" + diff, additions, removals := GenerateDiff(crlfBefore, after, "main.go") + + require.Equal(t, 1, additions, "CRLF before should not inflate counts") + require.Equal(t, 1, removals, "CRLF before should not inflate counts") + require.Contains(t, diff, "-\tfmt.Println(\"Hello, World!\")") + require.Contains(t, diff, "+\tfmt.Println(\"Hello, Go!\")") + }) + + t.Run("LF_before_CRLF_after", func(t *testing.T) { + t.Parallel() + + crlfAfter := "package main\r\n\r\nimport \"fmt\"\r\n\r\nfunc main() {\r\n\tfmt.Println(\"Hello, Go!\")\r\n\tfmt.Println(\"Line 2\")\r\n}\r\n" + diff, additions, removals := GenerateDiff(before, crlfAfter, "main.go") + + require.Equal(t, 1, additions, "CRLF after should not inflate counts") + require.Equal(t, 1, removals, "CRLF after should not inflate counts") + require.Contains(t, diff, "-\tfmt.Println(\"Hello, World!\")") + require.Contains(t, diff, "+\tfmt.Println(\"Hello, Go!\")") + }) + + t.Run("CRLF_before_CRLF_after", func(t *testing.T) { + t.Parallel() + + crlfBefore := "package main\r\n\r\nimport \"fmt\"\r\n\r\nfunc main() {\r\n\tfmt.Println(\"Hello, World!\")\r\n\tfmt.Println(\"Line 2\")\r\n}\r\n" + crlfAfter := "package main\r\n\r\nimport \"fmt\"\r\n\r\nfunc main() {\r\n\tfmt.Println(\"Hello, Go!\")\r\n\tfmt.Println(\"Line 2\")\r\n}\r\n" + diff, additions, removals := GenerateDiff(crlfBefore, crlfAfter, "main.go") + + require.Equal(t, 1, additions) + require.Equal(t, 1, removals) + require.Contains(t, diff, "-\tfmt.Println(\"Hello, World!\")") + require.Contains(t, diff, "+\tfmt.Println(\"Hello, Go!\")") + }) + + t.Run("mixed_line_endings", func(t *testing.T) { + t.Parallel() + + mixedBefore := "line1\r\nline2\nline3\r\nline4\n" + mixedAfter := "line1\nline2\nchanged\nline4\n" + diff, additions, removals := GenerateDiff(mixedBefore, mixedAfter, "test.txt") + + require.Equal(t, 1, additions) + require.Equal(t, 1, removals) + require.Contains(t, diff, "-line3") + require.Contains(t, diff, "+changed") + }) + + t.Run("identical_content_different_endings", func(t *testing.T) { + t.Parallel() + + lfContent := "line1\nline2\nline3\n" + crlfContent := "line1\r\nline2\r\nline3\r\n" + diff, additions, removals := GenerateDiff(lfContent, crlfContent, "test.txt") + + require.Equal(t, 0, additions, "identical content with different line endings should produce no diff") + require.Equal(t, 0, removals, "identical content with different line endings should produce no diff") + require.Empty(t, diff) + }) + + t.Run("tabs_are_not_normalized", func(t *testing.T) { + t.Parallel() + + tabContent := "\tfoo\n" + spaceContent := " foo\n" + diff, additions, removals := GenerateDiff(tabContent, spaceContent, "test.txt") + + require.Equal(t, 1, additions, "tab vs space should be a real diff") + require.Equal(t, 1, removals, "tab vs space should be a real diff") + require.NotEmpty(t, diff) + }) + + t.Run("empty_before", func(t *testing.T) { + t.Parallel() + + diff, additions, removals := GenerateDiff("", "line1\nline2\n", "new.txt") + + require.Equal(t, 2, additions) + require.Equal(t, 0, removals) + require.Contains(t, diff, "+line1") + require.Contains(t, diff, "+line2") + }) + + t.Run("empty_after", func(t *testing.T) { + t.Parallel() + + diff, additions, removals := GenerateDiff("line1\nline2\n", "", "deleted.txt") + + require.Equal(t, 0, additions) + require.Equal(t, 2, removals) + require.Contains(t, diff, "-line1") + require.Contains(t, diff, "-line2") + }) + + t.Run("leading_slash_trimmed", func(t *testing.T) { + t.Parallel() + + diff, _, _ := GenerateDiff("a\n", "b\n", "/src/main.go") + + require.Contains(t, diff, "a/src/main.go") + require.Contains(t, diff, "b/src/main.go") + require.NotContains(t, diff, "a//src/main.go") + }) +}