@@ -2,6 +2,7 @@ package acp
22
33import (
44 "encoding/json"
5+ "path/filepath"
56 "strings"
67 "testing"
78 "unicode"
@@ -300,21 +301,26 @@ func TestToolCallStart(t *testing.T) {
300301}
301302
302303func 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+
326516func TestPlanUpdateAndStatus (t * testing.T ) {
327517 upd := planUpdate ([]tools.PlanItem {
328518 {Content : "step a" , Status : "completed" },
0 commit comments