Skip to content
23 changes: 21 additions & 2 deletions internal/acp/translate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package acp

import (
"encoding/json"
"path/filepath"
"strings"
"unicode/utf8"

Expand Down Expand Up @@ -120,14 +121,32 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
}

func toolResultContent(result agent.ToolResult) []ToolCallContent {
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
text := strings.TrimRight(result.Output, "\n")
if text == "" {
text = result.Display.Summary
}
if text == "" {
return nil
return appendToolResultDiffs(content, result.FileDiffs)
}
content = append(content, ToolContent(TextBlock(text)))
return appendToolResultDiffs(content, result.FileDiffs)
}

func appendToolResultDiffs(content []ToolCallContent, diffs []tools.FileDiff) []ToolCallContent {
for _, diff := range diffs {
if !filepath.IsAbs(diff.Path) || (!diff.OldExists && !diff.NewExists) {
continue
}
newText := diff.NewText
var oldText *string
if diff.OldExists {
old := diff.OldText
oldText = &old
}
content = append(content, ToolCallContent{Type: "diff", Path: diff.Path, OldText: oldText, NewText: &newText})
}
return []ToolCallContent{ToolContent(TextBlock(text))}
return content
}

func toolResultLocations(result agent.ToolResult) []ToolCallLocation {
Expand Down
48 changes: 47 additions & 1 deletion internal/acp/translate_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package acp

import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"unicode/utf8"
Expand Down Expand Up @@ -74,19 +76,24 @@ func TestToolCallStart(t *testing.T) {
}

func TestToolCallResult(t *testing.T) {
path := filepath.Join(t.TempDir(), "a.go")
ok := toolCallResult(agent.ToolResult{
ToolCallID: "tc1",
Name: "edit_file",
Status: tools.StatusOK,
Output: "applied\n",
ChangedFiles: []string{"a.go", ""},
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before\n", NewText: "after\n"}},
})
if ok.SessionUpdate != UpdateToolCallUpdate || ok.Status != ToolStatusCompleted {
t.Fatalf("unexpected ok result: %+v", ok)
}
if len(ok.Content) != 1 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
if len(ok.Content) != 2 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
t.Fatalf("unexpected content: %+v", ok.Content)
}
if diff := ok.Content[1]; diff.Type != "diff" || diff.Path != path || diff.OldText == nil || *diff.OldText != "before\n" || diff.NewText == nil || *diff.NewText != "after\n" {
t.Fatalf("unexpected diff content: %+v", diff)
}
if len(ok.Locations) != 1 || ok.Locations[0].Path != "a.go" {
t.Fatalf("blank changed files should be dropped, got %+v", ok.Locations)
}
Expand All @@ -97,6 +104,45 @@ func TestToolCallResult(t *testing.T) {
}
}

func TestToolCallDiffJSONPreservesRequiredEmptyNewText(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty.txt")
content := appendToolResultDiffs(nil, []tools.FileDiff{
{Path: path, OldExists: false, NewExists: true, NewText: ""},
{Path: path, OldExists: true, NewExists: false, OldText: ""},
})
if len(content) != 2 {
t.Fatalf("diff content = %#v", content)
}
for index, diff := range content {
encoded, err := json.Marshal(diff)
if err != nil {
t.Fatal(err)
}
var wire map[string]any
if err := json.Unmarshal(encoded, &wire); err != nil {
t.Fatal(err)
}
if wire["path"] != path || wire["newText"] != "" {
t.Fatalf("wire diff %d = %s", index, encoded)
}
if index == 0 && wire["oldText"] != nil {
t.Fatalf("create oldText = %#v, want null", wire["oldText"])
}
}
}

func TestToolCallResultEmitsOnlyRedactedFileDiffs(t *testing.T) {
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
path := filepath.Join(t.TempDir(), "secret.txt")
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{{
Path: path, OldExists: true, NewExists: true, OldText: "token=" + secret, NewText: "safe",
}}})
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusError, FileDiffs: scrubbed.FileDiffs})
if len(update.Content) != 1 || update.Content[0].OldText == nil || strings.Contains(*update.Content[0].OldText, secret) {
t.Fatalf("ACP content leaked unredacted diff: %#v", update.Content)
}
}

func TestPlanUpdateAndStatus(t *testing.T) {
upd := planUpdate([]tools.PlanItem{
{Content: "step a", Status: "completed"},
Expand Down
27 changes: 24 additions & 3 deletions internal/acp/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,30 @@ type ToolCallContent struct {
// type == "content"
Content *ContentBlock `json:"content,omitempty"`
// type == "diff"
Path string `json:"path,omitempty"`
OldText string `json:"oldText,omitempty"`
NewText string `json:"newText,omitempty"`
Path string `json:"path,omitempty"`
OldText *string `json:"oldText,omitempty"`
NewText *string `json:"newText,omitempty"`
}

// MarshalJSON preserves ACP's discriminated content union. A diff always has
// path and newText (including an intentionally empty deletion value); oldText
// is JSON null for a newly created file. Other content variants omit all diff
// fields rather than serializing irrelevant nulls.
func (content ToolCallContent) MarshalJSON() ([]byte, error) {
if content.Type == "diff" {
return json.Marshal(struct {
Type string `json:"type"`
Path string `json:"path"`
OldText *string `json:"oldText"`
NewText *string `json:"newText"`
}{
Type: content.Type, Path: content.Path, OldText: content.OldText, NewText: content.NewText,
})
}
return json.Marshal(struct {
Type string `json:"type"`
Content *ContentBlock `json:"content,omitempty"`
}{Type: content.Type, Content: content.Content})
}

func ToolContent(block ContentBlock) ToolCallContent {
Expand Down
7 changes: 7 additions & 0 deletions internal/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -1527,6 +1527,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
Images: result.Images,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
Expand Down Expand Up @@ -1833,6 +1834,10 @@ func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, n
}

func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolResult {
// PrePermissionRejecter runs before Registry.RunWithOptions, so it must
// explicitly cross the same transcript/redaction boundary before its result
// can be forwarded through ACP.
result = tools.ScrubResultSecrets(result)
output, outputRedacted := scrubInterceptedOutput(result.Output)
display := result.Display
summary, summaryRedacted := scrubInterceptedOutput(display.Summary)
Expand Down Expand Up @@ -1860,6 +1865,7 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
Meta: meta,
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ChangeSummaries: result.ChangeSummaries,
Display: display,
LoadedTools: loadedToolsFromResult(meta),
Expand Down Expand Up @@ -2153,6 +2159,7 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
Expand Down
17 changes: 17 additions & 0 deletions internal/agent/loop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ type mockProvider struct {
requests []zeroruntime.CompletionRequest
}

func TestPrePermissionRejectScrubsFileDiffs(t *testing.T) {
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
result := toolResultFromPrePermissionReject(ToolCall{ID: "call", Name: "test"}, tools.Result{
Status: tools.StatusError,
FileDiffs: []tools.FileDiff{{
Path: filepath.Join(t.TempDir(), "secret.txt"),
OldExists: true,
NewExists: true,
OldText: "token=" + secret,
NewText: "safe",
}},
})
if len(result.FileDiffs) != 1 || strings.Contains(result.FileDiffs[0].OldText, secret) || !result.Redacted {
t.Fatalf("pre-permission FileDiff = %#v, redacted = %t", result.FileDiffs, result.Redacted)
}
}

func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
call := ToolCall{Name: tools.ExecCommandToolName}
Expand Down
1 change: 1 addition & 0 deletions internal/agent/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ type ToolResult struct {
Images []zeroruntime.ImageBlock
Redacted bool
ChangedFiles []string
FileDiffs []tools.FileDiff
// ChangeSummaries are non-selectable generated-tree summaries emitted by
// command execution; callers must not schedule per-file work from them.
ChangeSummaries []execution.Change
Expand Down
49 changes: 48 additions & 1 deletion internal/tools/diff_preview.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,59 @@
package tools

import udiff "github.com/aymanbagabas/go-udiff"
import (
"path/filepath"
"unicode/utf8"

udiff "github.com/aymanbagabas/go-udiff"
)

// maxToolPreviewBytes caps the inline diff a write tool appends to its result, so
// a large generated file can't flood the transcript or balloon the persisted
// session events. Past this the tool falls back to its summary line alone.
const maxToolPreviewBytes = 48 * 1024

// FileDiff is a human-facing before/after file change. Registry-boundary
// redaction applies to both sides before any caller receives it.
type FileDiff struct {
// Path is the canonical absolute path required by ACP diff content. The
// separate ChangedFiles result remains workspace-relative for local UI use.
Path string
// OldExists and NewExists distinguish an empty file from a missing side of a
// create/delete/move. Empty strings alone cannot encode that difference.
OldExists bool
NewExists bool
OldText string
NewText string
}

// boundedFileDiff declines rather than truncating: a truncated side would look
// like an exact file replacement. Callers keep ChangedFiles as the safe
// fallback for large, unsafe, or unchanged content. Newlines, carriage returns,
// and tabs are normal text; the remaining C0/C1 controls are rejected rather
// than normalized, so they cannot split a secret before transcript redaction.
func boundedFileDiff(path, oldText, newText string, oldExists, newExists bool) (FileDiff, bool) {
if !filepath.IsAbs(path) || (!oldExists && !newExists) ||
(oldExists == newExists && oldText == newText) ||
!utf8.ValidString(oldText) || !utf8.ValidString(newText) ||
unsafeDiffText(oldText) || unsafeDiffText(newText) ||
len(oldText)+len(newText) > maxToolPreviewBytes {
return FileDiff{}, false
}
return FileDiff{Path: path, OldExists: oldExists, NewExists: newExists, OldText: oldText, NewText: newText}, true
}

func unsafeDiffText(text string) bool {
for _, r := range text {
if r == '\n' || r == '\r' || r == '\t' {
continue
}
if r < 0x20 || (r >= 0x7f && r <= 0x9f) {
return true
}
}
return false
}

// boundedUnifiedDiff returns a unified diff of oldContent -> newContent labelled
// with path, suitable for the TUI's diff card renderer. A create (oldContent "")
// yields an all-additions (green) preview; an overwrite/edit yields red/green.
Expand Down
82 changes: 82 additions & 0 deletions internal/tools/diff_preview_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package tools

import (
"path/filepath"
"strings"
"testing"
)

func TestBoundedFileDiffRefusesPartialOrBinaryContent(t *testing.T) {
path := filepath.Join(t.TempDir(), "a.txt")
if diff, ok := boundedFileDiff(path, "old", "new", true, true); !ok || diff.Path != path || !diff.OldExists || !diff.NewExists || diff.OldText != "old" || diff.NewText != "new" {
t.Fatalf("small text diff = %#v, %t", diff, ok)
}
for _, tc := range []struct {
name string
old string
new string
}{
{"unchanged", "same", "same"},
{"binary old", string([]byte{0xff}), "text"},
{"binary new", "text", string([]byte{0xff})},
{"nul old", "token=sk-proj-abc\x00def", "text"},
{"escape new", "text", "token=sk-proj-abc\x1bdef"},
{"c1 old", "token=sk-proj-abc\u0085def", "text"},
{"too large", strings.Repeat("a", maxToolPreviewBytes), "b"},
} {
t.Run(tc.name, func(t *testing.T) {
if diff, ok := boundedFileDiff(path, tc.old, tc.new, true, true); ok || diff != (FileDiff{}) {
t.Fatalf("unexpected diff = %#v, %t", diff, ok)
}
})
}
}

func TestBoundedFileDiffPreservesEmptyFileOperations(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty.txt")
for _, tc := range []struct {
name string
oldExists, newExists bool
}{
{name: "create", newExists: true},
{name: "delete", oldExists: true},
} {
t.Run(tc.name, func(t *testing.T) {
diff, ok := boundedFileDiff(path, "", "", tc.oldExists, tc.newExists)
if !ok || diff.OldExists != tc.oldExists || diff.NewExists != tc.newExists {
t.Fatalf("empty %s = %#v, %t", tc.name, diff, ok)
}
})
}
}

func TestStructuredPatchFileDiffsPreserveEmptyOperationsAndResultBudget(t *testing.T) {
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
changes := []structuredPatchChange{
{kind: structuredPatchAdd, to: structuredPatchTarget{absolute: filepath.Join(root, "created"), relative: "created"}},
{kind: structuredPatchDelete, from: structuredPatchTarget{absolute: filepath.Join(root, "deleted"), relative: "deleted"}},
{kind: structuredPatchUpdate, from: structuredPatchTarget{absolute: filepath.Join(root, "from"), relative: "from"}, to: structuredPatchTarget{absolute: filepath.Join(root, "to"), relative: "to"}},
}
diffs := fileDiffsFromStructuredPatch(".", changes)
if len(diffs) != 4 {
t.Fatalf("empty create/delete/move diffs = %#v", diffs)
}
for _, diff := range diffs {
if diff.Path == "" || (!diff.OldExists && !diff.NewExists) {
t.Fatalf("invalid diff = %#v", diff)
}
}

large := strings.Repeat("x", 20*1024)
budgeted := fileDiffsFromStructuredPatch(".", []structuredPatchChange{
{kind: structuredPatchAdd, to: structuredPatchTarget{absolute: filepath.Join(root, "one")}, after: large},
{kind: structuredPatchAdd, to: structuredPatchTarget{absolute: filepath.Join(root, "two")}, after: large},
{kind: structuredPatchAdd, to: structuredPatchTarget{absolute: filepath.Join(root, "three")}, after: large},
})
if len(budgeted) != 2 {
t.Fatalf("aggregate file-diff budget = %d diffs, want 2", len(budgeted))
}
}
3 changes: 3 additions & 0 deletions internal/tools/edit_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
result := okResult(summary)
result.ChangedFiles = []string{relativePath}
if diff, ok := boundedFileDiff(absolutePath, content, updated, true, true); ok {
result.FileDiffs = []FileDiff{diff}
}
// Card-only preview (Display.Preview): the model's Output stays the one-line
// summary, so the red/green diff costs zero model tokens.
result.Display = Display{Summary: fmt.Sprintf("Edited %s", relativePath), Kind: "diff", Preview: boundedUnifiedDiff(relativePath, content, updated)}
Expand Down
Loading
Loading