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.
This commit is contained in:
Mathias Fredriksson
2026-04-18 16:39:34 +03:00
committed by GitHub
parent 23f9e26796
commit 6b0bb02e5d
15 changed files with 3032 additions and 144 deletions
+36 -8
View File
@@ -86,7 +86,7 @@ type AgentConn interface {
ReadFile(ctx context.Context, path string, offset, limit int64) (io.ReadCloser, string, error)
ReadFileLines(ctx context.Context, path string, offset, limit int64, limits ReadFileLinesLimits) (ReadFileLinesResponse, error)
WriteFile(ctx context.Context, path string, reader io.Reader) error
EditFiles(ctx context.Context, edits FileEditRequest) error
EditFiles(ctx context.Context, edits FileEditRequest) (FileEditResponse, error)
SSH(ctx context.Context) (*gonet.TCPConn, error)
SSHClient(ctx context.Context) (*ssh.Client, error)
SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error)
@@ -1043,6 +1043,32 @@ type FileEdits struct {
type FileEditRequest struct {
Files []FileEdits `json:"files"`
// IncludeDiff asks the agent to compute a unified diff per file
// and return it in FileEditResponse.Files[i].Diff. When false
// (default) the agent skips diff computation and Files is nil.
IncludeDiff bool `json:"include_diff,omitempty"`
}
// FileEditResponse is the success response for the edit-files endpoint.
// When the request's IncludeDiff flag is set, Files contains one entry
// per edited file in request order. Each entry's Path matches the
// caller-supplied path (pre-symlink resolution).
//
// The slice is named Files (rather than Diffs) so future work can
// hang per-file errors or status off each element without a second
// wire break.
type FileEditResponse struct {
Files []FileEditResult `json:"files,omitempty"`
}
// FileEditResult carries the outcome of editing one file. Path is
// the original caller-supplied path, not any symlink-resolved
// target. Diff is the unified-diff string produced when the
// caller set FileEditRequest.IncludeDiff; it is empty for no-op
// edits or when diffs were not requested.
type FileEditResult struct {
Path string `json:"path"`
Diff string `json:"diff"`
}
// ListMCPToolsResponse is the response from the agent's
@@ -1219,24 +1245,26 @@ func (c *agentConn) SignalProcess(ctx context.Context, id string, signal string)
}
// EditFiles performs search and replace edits on one or more files.
func (c *agentConn) EditFiles(ctx context.Context, edits FileEditRequest) error {
// When edits.IncludeDiff is true, the returned FileEditResponse
// carries a unified diff per edited file.
func (c *agentConn) EditFiles(ctx context.Context, edits FileEditRequest) (FileEditResponse, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/edit-files", edits)
if err != nil {
return xerrors.Errorf("do request: %w", err)
return FileEditResponse{}, xerrors.Errorf("do request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return codersdk.ReadBodyAsError(res)
return FileEditResponse{}, codersdk.ReadBodyAsError(res)
}
var m codersdk.Response
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
return xerrors.Errorf("decode response body: %w", err)
var resp FileEditResponse
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return FileEditResponse{}, xerrors.Errorf("decode response body: %w", err)
}
return nil
return resp, nil
}
func agentAPIPath(path string, query neturl.Values) string {
@@ -204,11 +204,12 @@ func (mr *MockAgentConnMockRecorder) DialContext(ctx, network, addr any) *gomock
}
// EditFiles mocks base method.
func (m *MockAgentConn) EditFiles(ctx context.Context, edits workspacesdk.FileEditRequest) error {
func (m *MockAgentConn) EditFiles(ctx context.Context, edits workspacesdk.FileEditRequest) (workspacesdk.FileEditResponse, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "EditFiles", ctx, edits)
ret0, _ := ret[0].(error)
return ret0
ret0, _ := ret[0].(workspacesdk.FileEditResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// EditFiles indicates an expected call of EditFiles.