From 7ad212daf75a658a5a5ed32b22f8fe211ecc9f62 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 3 Jan 2026 18:51:12 -0700 Subject: [PATCH] feat(acp): structured diff content for edits Edit/multiedit/write tool results now include ToolDiffContent with full file before/after states, enabling proper diff rendering in ACP clients. Permission requests for edit operations also include diff content so clients can show proposed changes alongside the approval prompt. Tool response metadata now includes FilePath alongside OldContent and NewContent fields. Assisted-by: Claude Sonnet 4 via Crush --- internal/acp/sink.go | 79 ++++++++++++++++++++++++++----- internal/agent/tools/edit.go | 8 +++- internal/agent/tools/multiedit.go | 8 +++- internal/agent/tools/write.go | 18 ++++--- 4 files changed, 92 insertions(+), 21 deletions(-) diff --git a/internal/acp/sink.go b/internal/acp/sink.go index 9a9b71d9e8f9e3f4ca0d6c0a54e2ee3b2b95f59a..8628e89fc728b918bc020f0771688ef487bc94a7 100644 --- a/internal/acp/sink.go +++ b/internal/acp/sink.go @@ -110,16 +110,27 @@ func (s *Sink) HandlePermission(req permission.PermissionRequest, permissions pe slog.Debug("ACP permission request", "tool", req.ToolName, "action", req.Action) + // Build the tool call for the permission request. + toolCall := acp.RequestPermissionToolCall{ + ToolCallId: acp.ToolCallId(req.ToolCallID), + Title: acp.Ptr(req.Description), + Kind: acp.Ptr(acp.ToolKindEdit), + Status: acp.Ptr(acp.ToolCallStatusPending), + Locations: []acp.ToolCallLocation{{Path: req.Path}}, + RawInput: req.Params, + } + + // For edit tools, include diff content so the client can show the proposed + // changes. + if meta := extractEditParams(req.Params); meta != nil && meta.FilePath != "" { + toolCall.Content = []acp.ToolCallContent{ + acp.ToolDiffContent(meta.FilePath, meta.NewContent, meta.OldContent), + } + } + resp, err := s.conn.RequestPermission(s.ctx, acp.RequestPermissionRequest{ SessionId: acp.SessionId(s.sessionID), - ToolCall: acp.RequestPermissionToolCall{ - ToolCallId: acp.ToolCallId(req.ToolCallID), - Title: acp.Ptr(req.Description), - Kind: acp.Ptr(acp.ToolKindEdit), - Status: acp.Ptr(acp.ToolCallStatusPending), - Locations: []acp.ToolCallLocation{{Path: req.Path}}, - RawInput: req.Params, - }, + ToolCall: toolCall, Options: []acp.PermissionOption{ {Kind: acp.PermissionOptionKindAllowOnce, Name: "Allow", OptionId: "allow"}, {Kind: acp.PermissionOptionKindAllowAlways, Name: "Allow always", OptionId: "allow_always"}, @@ -149,6 +160,33 @@ func (s *Sink) HandlePermission(req permission.PermissionRequest, permissions pe } } +// editParams holds fields needed for diff content in permission requests. +type editParams struct { + FilePath string `json:"file_path"` + OldContent string `json:"old_content"` + NewContent string `json:"new_content"` +} + +// extractEditParams attempts to extract edit parameters from permission params. +func extractEditParams(params any) *editParams { + if params == nil { + return nil + } + + // Try JSON round-trip to extract fields. + data, err := json.Marshal(params) + if err != nil { + return nil + } + + var ep editParams + if err := json.Unmarshal(data, &ep); err != nil { + return nil + } + + return &ep +} + // translatePart converts a message part to an ACP session update. func (s *Sink) translatePart(msgID string, role message.MessageRole, part message.ContentPart) *acp.SessionUpdate { switch p := part.(type) { @@ -288,18 +326,37 @@ func toolKind(name string) acp.ToolKind { } } +// diffMetadata holds fields common to edit tool response metadata. +type diffMetadata struct { + FilePath string `json:"file_path"` + OldContent string `json:"old_content"` + NewContent string `json:"new_content"` +} + func (s *Sink) translateToolResult(tr message.ToolResult) *acp.SessionUpdate { status := acp.ToolCallStatusCompleted if tr.IsError { status = acp.ToolCallStatusFailed } + // For edit tools with metadata, emit diff content. + content := []acp.ToolCallContent{acp.ToolContent(acp.TextBlock(tr.Content))} + if !tr.IsError && tr.Metadata != "" { + switch tr.Name { + case "edit", "multiedit", "write": + var meta diffMetadata + if err := json.Unmarshal([]byte(tr.Metadata), &meta); err == nil && meta.FilePath != "" { + content = []acp.ToolCallContent{ + acp.ToolDiffContent(meta.FilePath, meta.NewContent, meta.OldContent), + } + } + } + } + update := acp.UpdateToolCall( acp.ToolCallId(tr.ToolCallID), acp.WithUpdateStatus(status), - acp.WithUpdateContent([]acp.ToolCallContent{ - acp.ToolContent(acp.TextBlock(tr.Content)), - }), + acp.WithUpdateContent(content), ) return &update } diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go index ccc115be2aa20113d8e3cbf91f1e644e90ce1b98..87259863aaef6c82b53603ca2fc3d7d89e0944d3 100644 --- a/internal/agent/tools/edit.go +++ b/internal/agent/tools/edit.go @@ -35,10 +35,11 @@ type EditPermissionsParams struct { } type EditResponseMetadata struct { - Additions int `json:"additions"` - Removals int `json:"removals"` + FilePath string `json:"file_path"` OldContent string `json:"old_content,omitempty"` NewContent string `json:"new_content,omitempty"` + Additions int `json:"additions"` + Removals int `json:"removals"` } const EditToolName = "edit" @@ -165,6 +166,7 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool return fantasy.WithResponseMetadata( fantasy.NewTextResponse("File created: "+filePath), EditResponseMetadata{ + FilePath: filePath, OldContent: "", NewContent: content, Additions: additions, @@ -298,6 +300,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool return fantasy.WithResponseMetadata( fantasy.NewTextResponse("Content deleted from file: "+filePath), EditResponseMetadata{ + FilePath: filePath, OldContent: oldContent, NewContent: newContent, Additions: additions, @@ -433,6 +436,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep return fantasy.WithResponseMetadata( fantasy.NewTextResponse("Content replaced in file: "+filePath), EditResponseMetadata{ + FilePath: filePath, OldContent: oldContent, NewContent: newContent, Additions: additions, diff --git a/internal/agent/tools/multiedit.go b/internal/agent/tools/multiedit.go index c4a3aa200c8325db87a6bb8d860cade1a8e7025d..4ee53274d195a27df989af48fdf75f5396a5db1f 100644 --- a/internal/agent/tools/multiedit.go +++ b/internal/agent/tools/multiedit.go @@ -44,10 +44,11 @@ type FailedEdit struct { } type MultiEditResponseMetadata struct { - Additions int `json:"additions"` - Removals int `json:"removals"` + FilePath string `json:"file_path"` OldContent string `json:"old_content,omitempty"` NewContent string `json:"new_content,omitempty"` + Additions int `json:"additions"` + Removals int `json:"removals"` EditsApplied int `json:"edits_applied"` EditsFailed []FailedEdit `json:"edits_failed,omitempty"` } @@ -219,6 +220,7 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call return fantasy.WithResponseMetadata( fantasy.NewTextResponse(message), MultiEditResponseMetadata{ + FilePath: params.FilePath, OldContent: "", NewContent: currentContent, Additions: additions, @@ -289,6 +291,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call return fantasy.WithResponseMetadata( fantasy.NewTextErrorResponse(fmt.Sprintf("no changes made - all %d edit(s) failed", len(failedEdits))), MultiEditResponseMetadata{ + FilePath: params.FilePath, EditsApplied: 0, EditsFailed: failedEdits, }, @@ -375,6 +378,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call return fantasy.WithResponseMetadata( fantasy.NewTextResponse(message), MultiEditResponseMetadata{ + FilePath: params.FilePath, OldContent: oldContent, NewContent: currentContent, Additions: additions, diff --git a/internal/agent/tools/write.go b/internal/agent/tools/write.go index 82684001372ee45b1d71fa34384e6e6c7a92db25..21e2435f72170d05929f9be35808c5fcc1de5d12 100644 --- a/internal/agent/tools/write.go +++ b/internal/agent/tools/write.go @@ -43,9 +43,12 @@ type writeTool struct { } type WriteResponseMetadata struct { - Diff string `json:"diff"` - Additions int `json:"additions"` - Removals int `json:"removals"` + FilePath string `json:"file_path"` + OldContent string `json:"old_content,omitempty"` + NewContent string `json:"new_content,omitempty"` + Diff string `json:"diff"` + Additions int `json:"additions"` + Removals int `json:"removals"` } const WriteToolName = "write" @@ -166,9 +169,12 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis result += getDiagnostics(filePath, lspClients) return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result), WriteResponseMetadata{ - Diff: diff, - Additions: additions, - Removals: removals, + FilePath: filePath, + OldContent: oldContent, + NewContent: params.Content, + Diff: diff, + Additions: additions, + Removals: removals, }, ), nil })