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
+30 -12
View File
@@ -1665,7 +1665,19 @@ type WorkspaceEditFileArgs struct {
Edits []workspacesdk.FileEdit `json:"edits"`
}
var WorkspaceEditFile = Tool[WorkspaceEditFileArgs, codersdk.Response]{
// WorkspaceEditFilesResponse is the response shape for the edit-file
// and edit-files tools. Message preserves the existing success text.
// Files carries the per-file results returned by the agent
// (populated when the agent-side IncludeDiff flag was set). The
// field is named Files (matching the agent's FileEditResponse.Files)
// so future per-file error or status fields can be added without a
// second wire break.
type WorkspaceEditFilesResponse struct {
Message string `json:"message"`
Files []workspacesdk.FileEditResult `json:"files,omitempty"`
}
var WorkspaceEditFile = Tool[WorkspaceEditFileArgs, WorkspaceEditFilesResponse]{
Tool: aisdk.Tool{
Name: ToolNameWorkspaceEditFile,
Description: `Edit a file in a workspace.`,
@@ -1703,27 +1715,29 @@ var WorkspaceEditFile = Tool[WorkspaceEditFileArgs, codersdk.Response]{
},
MCPAnnotations: mcpDestructiveAnnotations,
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFileArgs) (codersdk.Response, error) {
Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFileArgs) (WorkspaceEditFilesResponse, error) {
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace)
if err != nil {
return codersdk.Response{}, err
return WorkspaceEditFilesResponse{}, err
}
defer conn.Close()
err = conn.EditFiles(ctx, workspacesdk.FileEditRequest{
resp, err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{
Files: []workspacesdk.FileEdits{
{
Path: args.Path,
Edits: args.Edits,
},
},
IncludeDiff: true,
})
if err != nil {
return codersdk.Response{}, err
return WorkspaceEditFilesResponse{}, err
}
return codersdk.Response{
return WorkspaceEditFilesResponse{
Message: "File edited successfully.",
Files: resp.Files,
}, nil
},
}
@@ -1733,7 +1747,7 @@ type WorkspaceEditFilesArgs struct {
Files []workspacesdk.FileEdits `json:"files"`
}
var WorkspaceEditFiles = Tool[WorkspaceEditFilesArgs, codersdk.Response]{
var WorkspaceEditFiles = Tool[WorkspaceEditFilesArgs, WorkspaceEditFilesResponse]{
Tool: aisdk.Tool{
Name: ToolNameWorkspaceEditFiles,
Description: `Edit one or more files in a workspace.`,
@@ -1785,20 +1799,24 @@ var WorkspaceEditFiles = Tool[WorkspaceEditFilesArgs, codersdk.Response]{
},
MCPAnnotations: mcpDestructiveAnnotations,
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFilesArgs) (codersdk.Response, error) {
Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFilesArgs) (WorkspaceEditFilesResponse, error) {
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace)
if err != nil {
return codersdk.Response{}, err
return WorkspaceEditFilesResponse{}, err
}
defer conn.Close()
err = conn.EditFiles(ctx, workspacesdk.FileEditRequest{Files: args.Files})
resp, err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{
Files: args.Files,
IncludeDiff: true,
})
if err != nil {
return codersdk.Response{}, err
return WorkspaceEditFilesResponse{}, err
}
return codersdk.Response{
return WorkspaceEditFilesResponse{
Message: "File(s) edited successfully.",
Files: resp.Files,
}, nil
},
}
+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.