feat(acp): structured diff content for edits

Amolith created

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

Change summary

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(-)

Detailed changes

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
 }

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,

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,

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
 		})