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