Skip to content

Commit faec8ee

Browse files
authored
feat(acp): emit structured file changes in tool updates (#999)
* feat(tools): retain bounded structured file diffs * feat(acp): emit structured file changes in tool updates * fix(acp): harden structured file diff transport * fix(tools): reject unsafe rich diff previews * fix(acp): preserve safe rich diff semantics * fix(tools): bind structured diffs to committed changes * fix(acp): preserve structured diff identity * fix(tools): close rich diff race gaps * fix(tools): report partial patch paths from workspace * fix(tools): finalize structured diff evidence * fix(tools): separate unverified patch writes * test(tools): cover failed formatter recovery * fix(acp): serialize file deletions as nullable diffs * fix(tools): recreate files after formatter failure * test(acp): require nullable diff fields on wire
1 parent c1937df commit faec8ee

21 files changed

Lines changed: 2161 additions & 128 deletions

‎internal/acp/translate.go‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package acp
33
import (
44
"encoding/json"
55
"net/url"
6+
"path/filepath"
67
"strings"
78
"unicode"
89
"unicode/utf8"
@@ -246,22 +247,59 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
246247
}
247248

248249
func toolResultContent(result agent.ToolResult) []ToolCallContent {
250+
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
249251
text := strings.TrimRight(result.Output, "\n")
250252
if text == "" {
251253
text = result.Display.Summary
252254
}
253255
if text == "" {
254-
return nil
256+
return appendToolResultDiffs(content, result.FileDiffs)
257+
}
258+
content = append(content, ToolContent(TextBlock(text)))
259+
return appendToolResultDiffs(content, result.FileDiffs)
260+
}
261+
262+
func appendToolResultDiffs(content []ToolCallContent, diffs []tools.FileDiff) []ToolCallContent {
263+
for _, diff := range diffs {
264+
if !filepath.IsAbs(diff.Path) || (!diff.OldExists && !diff.NewExists) {
265+
continue
266+
}
267+
var oldText *string
268+
if diff.OldExists {
269+
old := diff.OldText
270+
oldText = &old
271+
}
272+
var newText *string
273+
if diff.NewExists {
274+
updated := diff.NewText
275+
newText = &updated
276+
}
277+
content = append(content, ToolCallContent{Type: "diff", Path: diff.Path, OldText: oldText, NewText: newText})
255278
}
256-
return []ToolCallContent{ToolContent(TextBlock(text))}
279+
return content
257280
}
258281

259282
func toolResultLocations(result agent.ToolResult) []ToolCallLocation {
260-
locs := make([]ToolCallLocation, 0, len(result.ChangedFiles))
283+
locs := make([]ToolCallLocation, 0, len(result.FileDiffs)+len(result.ChangedFiles))
284+
seen := make(map[string]bool, len(result.FileDiffs)+len(result.ChangedFiles))
285+
for _, diff := range result.FileDiffs {
286+
path := diff.Path
287+
if path == "" || seen[path] {
288+
continue
289+
}
290+
seen[path] = true
291+
locs = append(locs, ToolCallLocation{Path: path})
292+
}
293+
// FileDiff.Path is canonical absolute path data while ChangedFiles is
294+
// normally workspace-relative. Without the trusted workspace root these
295+
// coordinate systems cannot be correlated safely: a suffix match would let
296+
// /workspace/sub/a.go consume the fallback for a distinct root a.go. The
297+
// shared seen set deduplicates only identities already exactly comparable.
261298
for _, f := range result.ChangedFiles {
262-
if strings.TrimSpace(f) == "" {
299+
if f == "" || seen[f] {
263300
continue
264301
}
302+
seen[f] = true
265303
locs = append(locs, ToolCallLocation{Path: f})
266304
}
267305
return locs

‎internal/acp/translate_test.go‎

Lines changed: 193 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package acp
22

33
import (
44
"encoding/json"
5+
"path/filepath"
56
"strings"
67
"testing"
78
"unicode"
@@ -300,21 +301,26 @@ func TestToolCallStart(t *testing.T) {
300301
}
301302

302303
func TestToolCallResult(t *testing.T) {
304+
path := filepath.Join(t.TempDir(), "a.go")
303305
ok := toolCallResult(agent.ToolResult{
304306
ToolCallID: "tc1",
305307
Name: "edit_file",
306308
Status: tools.StatusOK,
307309
Output: "applied\n",
308310
ChangedFiles: []string{"a.go", ""},
311+
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before\n", NewText: "after\n"}},
309312
})
310313
if ok.SessionUpdate != UpdateToolCallUpdate || ok.Status != ToolStatusCompleted {
311314
t.Fatalf("unexpected ok result: %+v", ok)
312315
}
313-
if len(ok.Content) != 1 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
316+
if len(ok.Content) != 2 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
314317
t.Fatalf("unexpected content: %+v", ok.Content)
315318
}
316-
if len(ok.Locations) != 1 || ok.Locations[0].Path != "a.go" {
317-
t.Fatalf("blank changed files should be dropped, got %+v", ok.Locations)
319+
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" {
320+
t.Fatalf("unexpected diff content: %+v", diff)
321+
}
322+
if len(ok.Locations) != 2 || ok.Locations[0].Path != path || ok.Locations[1].Path != "a.go" {
323+
t.Fatalf("unproven absolute/relative aliases must both remain visible, got %+v", ok.Locations)
318324
}
319325

320326
failed := toolCallResult(agent.ToolResult{ToolCallID: "tc2", Status: tools.StatusError, Output: "boom"})
@@ -323,6 +329,190 @@ func TestToolCallResult(t *testing.T) {
323329
}
324330
}
325331

332+
func TestToolCallDiffJSONDistinguishesEmptyFilesAndDeletion(t *testing.T) {
333+
path := filepath.Join(t.TempDir(), "empty.txt")
334+
content := appendToolResultDiffs(nil, []tools.FileDiff{
335+
{Path: path, OldExists: false, NewExists: true, NewText: ""},
336+
{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: ""},
337+
{Path: path, OldExists: true, NewExists: false, OldText: "before"},
338+
})
339+
if len(content) != 3 {
340+
t.Fatalf("diff content = %#v", content)
341+
}
342+
for index, diff := range content {
343+
encoded, err := json.Marshal(diff)
344+
if err != nil {
345+
t.Fatal(err)
346+
}
347+
var wire map[string]any
348+
if err := json.Unmarshal(encoded, &wire); err != nil {
349+
t.Fatal(err)
350+
}
351+
if wire["path"] != path {
352+
t.Fatalf("wire diff %d = %s", index, encoded)
353+
}
354+
switch index {
355+
case 0:
356+
oldText, present := wire["oldText"]
357+
if !present || oldText != nil || wire["newText"] != "" {
358+
t.Fatalf("create diff = %s, want null oldText and empty newText", encoded)
359+
}
360+
case 1:
361+
if wire["oldText"] != "before" || wire["newText"] != "" {
362+
t.Fatalf("empty replacement diff = %s", encoded)
363+
}
364+
case 2:
365+
newText, present := wire["newText"]
366+
if wire["oldText"] != "before" || !present || newText != nil {
367+
t.Fatalf("deletion diff = %s, want oldText and null newText", encoded)
368+
}
369+
}
370+
}
371+
}
372+
373+
func TestToolResultLocationsPreserveDistinctPathIdentities(t *testing.T) {
374+
root := t.TempDir()
375+
rootPath := filepath.Join(root, "a.go")
376+
nestedPath := filepath.Join(root, "sub", "a.go")
377+
diff := func(path string) tools.FileDiff {
378+
return tools.FileDiff{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}
379+
}
380+
for _, tc := range []struct {
381+
name string
382+
diffs []tools.FileDiff
383+
want []string
384+
}{
385+
{name: "both rich", diffs: []tools.FileDiff{diff(rootPath), diff(nestedPath)}, want: []string{rootPath, nestedPath, "a.go", filepath.Join("sub", "a.go")}},
386+
{name: "root rich", diffs: []tools.FileDiff{diff(rootPath)}, want: []string{rootPath, "a.go", filepath.Join("sub", "a.go")}},
387+
{name: "nested rich", diffs: []tools.FileDiff{diff(nestedPath)}, want: []string{nestedPath, "a.go", filepath.Join("sub", "a.go")}},
388+
} {
389+
t.Run(tc.name, func(t *testing.T) {
390+
locations := toolResultLocations(agent.ToolResult{
391+
ChangedFiles: []string{"a.go", filepath.Join("sub", "a.go")},
392+
FileDiffs: tc.diffs,
393+
})
394+
if len(locations) != len(tc.want) {
395+
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
396+
}
397+
for index := range tc.want {
398+
if locations[index].Path != tc.want[index] {
399+
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
400+
}
401+
}
402+
})
403+
}
404+
}
405+
406+
func TestToolCallResultPreservesWhitespaceInFilePaths(t *testing.T) {
407+
relativePath := " report.txt "
408+
absolutePath := filepath.Join(t.TempDir(), relativePath)
409+
update := toolCallResult(agent.ToolResult{
410+
ChangedFiles: []string{relativePath},
411+
FileDiffs: []tools.FileDiff{{
412+
Path: absolutePath, OldExists: true, NewExists: true, OldText: "before", NewText: "after",
413+
}},
414+
})
415+
if len(update.Content) != 1 || update.Content[0].Path != absolutePath {
416+
t.Fatalf("diff content path = %#v, want %q", update.Content, absolutePath)
417+
}
418+
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
419+
t.Fatalf("locations = %#v, want exact paths %q and %q", update.Locations, absolutePath, relativePath)
420+
}
421+
}
422+
423+
func TestToolResultLocationsDeduplicateOnlyExactPaths(t *testing.T) {
424+
path := filepath.Join(t.TempDir(), "a.go")
425+
locations := toolResultLocations(agent.ToolResult{
426+
ChangedFiles: []string{path, path},
427+
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}},
428+
})
429+
if len(locations) != 1 || locations[0].Path != path {
430+
t.Fatalf("exact duplicate locations = %#v", locations)
431+
}
432+
}
433+
434+
func TestDeletedFileEmitsDiffAndKeepsLocations(t *testing.T) {
435+
relativePath := "deleted.go"
436+
absolutePath := filepath.Join(t.TempDir(), relativePath)
437+
update := toolCallResult(agent.ToolResult{
438+
ChangedFiles: []string{relativePath},
439+
FileDiffs: []tools.FileDiff{{
440+
Path: absolutePath, OldExists: true, NewExists: false, OldText: "before",
441+
}},
442+
})
443+
if len(update.Content) != 1 || update.Content[0].OldText == nil || *update.Content[0].OldText != "before" || update.Content[0].NewText != nil {
444+
t.Fatalf("deleted file diff = %#v, want oldText with null newText", update.Content)
445+
}
446+
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
447+
t.Fatalf("deleted file locations = %#v", update.Locations)
448+
}
449+
}
450+
451+
func TestToolCallResultEmitsOnlyRedactedFileDiffs(t *testing.T) {
452+
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
453+
path := filepath.Join(t.TempDir(), "secret.txt")
454+
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{{
455+
Path: path, OldExists: true, NewExists: true, OldText: "token=" + secret, NewText: "safe",
456+
}}})
457+
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusError, FileDiffs: scrubbed.FileDiffs})
458+
if len(update.Content) != 1 || update.Content[0].OldText == nil || strings.Contains(*update.Content[0].OldText, secret) {
459+
t.Fatalf("ACP content leaked unredacted diff: %#v", update.Content)
460+
}
461+
}
462+
463+
func TestToolCallResultOmitsSemanticallyUnchangedRedactedDiff(t *testing.T) {
464+
oldSecret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
465+
newSecret := "ghp_9876543210ZYXWVUTSRQPONMLKJIHGFEDCBA"
466+
scrubbed := tools.ScrubResultSecrets(tools.Result{
467+
ChangedFiles: []string{"credentials.txt"},
468+
FileDiffs: []tools.FileDiff{{
469+
Path: filepath.Join(t.TempDir(), "credentials.txt"), OldExists: true, NewExists: true,
470+
OldText: "token=" + oldSecret, NewText: "token=" + newSecret,
471+
}},
472+
})
473+
update := toolCallResult(agent.ToolResult{
474+
ToolCallID: "call", Status: tools.StatusOK,
475+
ChangedFiles: scrubbed.ChangedFiles, FileDiffs: scrubbed.FileDiffs,
476+
})
477+
if len(update.Content) != 0 {
478+
t.Fatalf("ACP emitted semantically unchanged redacted diff: %#v", update.Content)
479+
}
480+
if len(update.Locations) != 1 || update.Locations[0].Path != "credentials.txt" {
481+
t.Fatalf("ACP path fallback = %#v", update.Locations)
482+
}
483+
}
484+
485+
func TestToolCallResultOmitsDefaultIgnorableSplitSecretsOnEitherSide(t *testing.T) {
486+
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
487+
for name, separator := range map[string]string{
488+
"combining grapheme joiner": "\u034f",
489+
"variation selector": "\ufe0f",
490+
} {
491+
for _, side := range []string{"old", "new"} {
492+
t.Run(name+" "+side, func(t *testing.T) {
493+
obfuscated := secret[:20] + separator + secret[20:]
494+
diff := tools.FileDiff{
495+
Path: filepath.Join(t.TempDir(), "secret.txt"), OldExists: true, NewExists: true,
496+
OldText: "safe old", NewText: "safe new",
497+
}
498+
if side == "old" {
499+
diff.OldText = obfuscated
500+
} else {
501+
diff.NewText = obfuscated
502+
}
503+
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{diff}})
504+
if !scrubbed.Redacted || len(scrubbed.FileDiffs) != 0 {
505+
t.Fatalf("registry boundary retained an obfuscated secret: %#v", scrubbed)
506+
}
507+
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusOK, FileDiffs: scrubbed.FileDiffs})
508+
if len(update.Content) != 0 {
509+
t.Fatalf("ACP content retained an obfuscated secret: %#v", update.Content)
510+
}
511+
})
512+
}
513+
}
514+
}
515+
326516
func TestPlanUpdateAndStatus(t *testing.T) {
327517
upd := planUpdate([]tools.PlanItem{
328518
{Content: "step a", Status: "completed"},

‎internal/acp/types.go‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,9 +284,30 @@ type ToolCallContent struct {
284284
// type == "content"
285285
Content *ContentBlock `json:"content,omitempty"`
286286
// type == "diff"
287-
Path string `json:"path,omitempty"`
288-
OldText string `json:"oldText,omitempty"`
289-
NewText string `json:"newText,omitempty"`
287+
Path string `json:"path,omitempty"`
288+
OldText *string `json:"oldText,omitempty"`
289+
NewText *string `json:"newText,omitempty"`
290+
}
291+
292+
// MarshalJSON preserves ACP's discriminated content union. A diff always has
293+
// path, while nullable oldText/newText distinguish creation, deletion, and an
294+
// existing file replaced with empty content. Other content variants omit all
295+
// diff fields rather than serializing irrelevant nulls.
296+
func (content ToolCallContent) MarshalJSON() ([]byte, error) {
297+
if content.Type == "diff" {
298+
return json.Marshal(struct {
299+
Type string `json:"type"`
300+
Path string `json:"path"`
301+
OldText *string `json:"oldText"`
302+
NewText *string `json:"newText"`
303+
}{
304+
Type: content.Type, Path: content.Path, OldText: content.OldText, NewText: content.NewText,
305+
})
306+
}
307+
return json.Marshal(struct {
308+
Type string `json:"type"`
309+
Content *ContentBlock `json:"content,omitempty"`
310+
}{Type: content.Type, Content: content.Content})
290311
}
291312

292313
func ToolContent(block ContentBlock) ToolCallContent {

‎internal/agent/loop.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1527,6 +1527,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
15271527
Images: result.Images,
15281528
Redacted: result.Redacted,
15291529
ChangedFiles: result.ChangedFiles,
1530+
FileDiffs: result.FileDiffs,
15301531
ChangeSummaries: result.ChangeSummaries,
15311532
Display: result.HumanDisplay(),
15321533
Outcome: result.Outcome,
@@ -1833,6 +1834,10 @@ func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, n
18331834
}
18341835

18351836
func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolResult {
1837+
// PrePermissionRejecter runs before Registry.RunWithOptions, so it must
1838+
// explicitly cross the same transcript/redaction boundary before its result
1839+
// can be forwarded through ACP.
1840+
result = tools.ScrubResultSecrets(result)
18361841
output, outputRedacted := scrubInterceptedOutput(result.Output)
18371842
display := result.Display
18381843
summary, summaryRedacted := scrubInterceptedOutput(display.Summary)
@@ -1860,6 +1865,7 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
18601865
Meta: meta,
18611866
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
18621867
ChangedFiles: result.ChangedFiles,
1868+
FileDiffs: result.FileDiffs,
18631869
ChangeSummaries: result.ChangeSummaries,
18641870
Display: display,
18651871
LoadedTools: loadedToolsFromResult(meta),
@@ -2153,6 +2159,7 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
21532159
Meta: result.Meta,
21542160
Redacted: result.Redacted,
21552161
ChangedFiles: result.ChangedFiles,
2162+
FileDiffs: result.FileDiffs,
21562163
ChangeSummaries: result.ChangeSummaries,
21572164
Display: result.HumanDisplay(),
21582165
Outcome: result.Outcome,

‎internal/agent/loop_test.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ type mockProvider struct {
2727
requests []zeroruntime.CompletionRequest
2828
}
2929

30+
func TestPrePermissionRejectScrubsFileDiffs(t *testing.T) {
31+
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
32+
result := toolResultFromPrePermissionReject(ToolCall{ID: "call", Name: "test"}, tools.Result{
33+
Status: tools.StatusError,
34+
FileDiffs: []tools.FileDiff{{
35+
Path: filepath.Join(t.TempDir(), "secret.txt"),
36+
OldExists: true,
37+
NewExists: true,
38+
OldText: "token=" + secret,
39+
NewText: "safe",
40+
}},
41+
})
42+
if len(result.FileDiffs) != 1 || strings.Contains(result.FileDiffs[0].OldText, secret) || !result.Redacted {
43+
t.Fatalf("pre-permission FileDiff = %#v, redacted = %t", result.FileDiffs, result.Redacted)
44+
}
45+
}
46+
3047
func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
3148
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
3249
call := ToolCall{Name: tools.ExecCommandToolName}

0 commit comments

Comments
 (0)