Files
coder/agent/agentfiles/files_indent_internal_test.go
Mathias Fredriksson 6b0bb02e5d fix: server-side diffs and stricter fuzzy splicing for edit_files (#24454)
Fixes three classes of edit_files bugs and adds structured per-file
diff output for tool callers:

- New IncludeDiff flag on FileEditRequest; when set, the agent
  returns FileEditResponse.Files[]{Path, Diff} with unified diffs
  computed via go-udiff v0.4.1 Lines + ToUnified (not Unified,
  which calls log.Fatalf on internal error).
- Fuzzy match comparators split each line into leading whitespace,
  body, trailing whitespace, and ending. The splice substitutes at
  each position: on agreement between search and replace the file's
  bytes win; on disagreement the replacement's bytes are spliced
  verbatim. Carve-outs for empty-body lines, multi-line EOF splices,
  and level-aware indent translation for inserted lines.
- Indent-unit detection (GCD for spaces, tab-priority) lets a 4sp
  LLM search insert correctly into tab or 2sp files. Falls back to
  the previous cLead-inheritance path when units can't be detected
  cleanly.
- Empty search is rejected with "search string must not be empty".
- Duplicate file paths in one request are rejected; symlink aliases
  resolved via api.resolvePath before the dedup check.
- Frontend EditFilesRenderer consumes the structured files array by
  explicit path (no label munging) with per-file synthetic fallback
  for older agents or mismatched paths. On error, no diff is
  rendered so the synthetic fallback doesn't misrepresent a
  rejected edit as applied.

Breaking change: AgentConn.EditFiles changes from (ctx, req) error
to (ctx, req) (FileEditResponse, error) in codersdk/workspacesdk.
Source-breaking for external Go consumers; no compat shim per plan
owner.

Out of scope (tracked in CODAGT-214): level-aware indent for
middle-substituted splice lines. Locked in
TestEditFiles_FuzzyIndent_InsertionLevelAware's Lock_* cases plus
TestEditFiles_ReplaceAll_FuzzyIndentGap.
2026-04-18 16:39:34 +03:00

299 lines
6.2 KiB
Go

package agentfiles
import (
"testing"
"github.com/stretchr/testify/require"
)
// Direct unit tests for the indent-splice helpers. These test the
// functions in isolation so a helper bug surfaces here with a
// descriptive failure instead of as a rendered-file mismatch deep
// in an integration test.
func TestDetectIndentUnit(t *testing.T) {
t.Parallel()
tests := []struct {
name string
lines []string
wantUnit string
wantOK bool
}{
{
name: "Empty",
lines: nil,
wantUnit: "",
wantOK: false,
},
{
name: "NoIndent",
lines: []string{"foo\n", "bar\n"},
wantUnit: "",
wantOK: false,
},
{
name: "TabOnly",
lines: []string{"\tfoo\n", "\t\tbar\n"},
wantUnit: "\t",
wantOK: true,
},
{
name: "FourSpaceUniform",
lines: []string{" foo\n", " bar\n"},
wantUnit: " ",
wantOK: true,
},
{
name: "TwoSpaceUniform",
lines: []string{" foo\n", " bar\n"},
wantUnit: " ",
wantOK: true,
},
{
name: "GCDReducesFourAndSixToTwo",
lines: []string{" foo\n", " bar\n"},
wantUnit: " ",
wantOK: true,
},
{
name: "MixedAcrossLinesTabAndSpace",
lines: []string{"\tfoo\n", " bar\n"},
wantUnit: "",
wantOK: false,
},
{
name: "MixedWithinLeadTabThenSpace",
lines: []string{"\t foo\n"},
wantUnit: "",
wantOK: false,
},
{
name: "MixedWithinLeadSpaceThenTab",
lines: []string{" \tfoo\n"},
wantUnit: "",
wantOK: false,
},
{
// DEREM-33 regression: a 2sp whitespace-only line in
// a 4sp-indented region must not pull the GCD down.
name: "WhitespaceOnlyLineSkipped",
lines: []string{" foo\n", " \n", " bar\n"},
wantUnit: " ",
wantOK: true,
},
{
name: "OnlyWhitespaceOnlyLines",
lines: []string{" \n", " \n"},
wantUnit: "",
wantOK: false,
},
{
name: "BlankLineIgnored",
lines: []string{"\n", " foo\n"},
wantUnit: " ",
wantOK: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
gotUnit, gotOK := detectIndentUnit(tc.lines)
require.Equal(t, tc.wantUnit, gotUnit)
require.Equal(t, tc.wantOK, gotOK)
})
}
}
func TestIndentGCD(t *testing.T) {
t.Parallel()
tests := []struct {
name string
a, b int
want int
}{
{"BothZero", 0, 0, 0},
{"AZero", 0, 4, 4},
{"BZero", 4, 0, 4},
{"Equal", 4, 4, 4},
{"Coprime", 3, 5, 1},
{"CommonFactorTwo", 4, 6, 2},
{"CommonFactorFour", 8, 12, 4},
{"TwoSpaceAndFourSpace", 2, 4, 2},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.want, indentGCD(tc.a, tc.b))
})
}
}
func TestIndentLevel(t *testing.T) {
t.Parallel()
tests := []struct {
name string
lead string
unit string
wantLevel int
wantOK bool
}{
{
name: "EmptyLead",
lead: "",
unit: " ",
wantLevel: 0,
wantOK: true,
},
{
name: "CleanMultipleOne",
lead: " ",
unit: " ",
wantLevel: 1,
wantOK: true,
},
{
name: "CleanMultipleThreeTwoSp",
lead: " ",
unit: " ",
wantLevel: 3,
wantOK: true,
},
{
name: "CleanMultipleTwoTab",
lead: "\t\t",
unit: "\t",
wantLevel: 2,
wantOK: true,
},
{
name: "NonMultipleLength",
lead: " ",
unit: " ",
wantLevel: 0,
wantOK: false,
},
{
// Even when the length divides evenly, the lead must
// be composed of repetitions of the unit.
name: "LengthDividesButCompositionMismatches",
lead: "\t ",
unit: " ",
wantLevel: 0,
wantOK: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
gotLevel, gotOK := indentLevel(tc.lead, tc.unit)
require.Equal(t, tc.wantLevel, gotLevel)
require.Equal(t, tc.wantOK, gotOK)
})
}
}
func TestTranslateIndentLevel(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rLead string
sLead string
cLead string
searchUnit string
fileUnit string
want string
wantOK bool
}{
{
// Caller sends a 4sp search; inserted line is 8sp
// (one level deeper). File uses tabs, matched at
// 1-tab depth. Expected: 2 tabs.
name: "PositiveDeltaWrap",
rLead: " ",
sLead: " ",
cLead: "\t",
searchUnit: " ",
fileUnit: "\t",
want: "\t\t",
wantOK: true,
},
{
// Inserted line at the same level as its reference.
name: "ZeroDeltaSameLevel",
rLead: " ",
sLead: " ",
cLead: "\t",
searchUnit: " ",
fileUnit: "\t",
want: "\t",
wantOK: true,
},
{
// Inserted line shallower than the reference's
// level by more than the file_base: target goes
// negative, helper bails.
name: "NegativeDeltaBelowFileBase",
rLead: "",
sLead: " ",
cLead: "\t",
searchUnit: " ",
fileUnit: "\t",
want: "",
wantOK: false,
},
{
// Malformed rLead (3 spaces under a 4sp unit).
name: "MalformedRLead",
rLead: " ",
sLead: " ",
cLead: "\t",
searchUnit: " ",
fileUnit: "\t",
want: "",
wantOK: false,
},
{
// 4sp LLM into a 2sp file at matched-4sp baseline.
// rep_level=2, search_base=1, file_base=2,
// target=3, emit " " (6sp).
name: "CrossStyle4spTo2sp",
rLead: " ",
sLead: " ",
cLead: " ",
searchUnit: " ",
fileUnit: " ",
want: " ",
wantOK: true,
},
{
// 2sp LLM into a tab file.
// rep_level=2, search_base=1, file_base=1,
// target=2, emit "\t\t".
name: "CrossStyle2spToTab",
rLead: " ",
sLead: " ",
cLead: "\t",
searchUnit: " ",
fileUnit: "\t",
want: "\t\t",
wantOK: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, gotOK := translateIndentLevel(tc.rLead, tc.sLead, tc.cLead, tc.searchUnit, tc.fileUnit)
require.Equal(t, tc.want, got)
require.Equal(t, tc.wantOK, gotOK)
})
}
}