Files
zero/internal/tools/write_tools_test.go
KRATOS faec8ee201 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
2026-09-15 08:31:39 +08:00

1445 lines
47 KiB
Go

package tools
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
)
func mustReadTestFile(t *testing.T, path string) string {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(content)
}
func TestCoreToolsExposeWriteAndPlanTools(t *testing.T) {
toolset := CoreToolsScoped(t.TempDir(), nil)
byName := make(map[string]Tool, len(toolset))
for _, tool := range toolset {
byName[tool.Name()] = tool
}
for _, name := range []string{"write_file", "edit_file", "apply_patch"} {
tool, ok := byName[name]
if !ok {
t.Fatalf("expected core tools to include %s", name)
}
if tool.Safety().SideEffect != SideEffectWrite {
t.Fatalf("%s side effect = %s, want write", name, tool.Safety().SideEffect)
}
if tool.Safety().Permission != PermissionPrompt {
t.Fatalf("%s permission = %s, want prompt", name, tool.Safety().Permission)
}
}
planTool, ok := byName["update_plan"]
if !ok {
t.Fatalf("expected core tools to include update_plan")
}
if planTool.Safety().Permission != PermissionAllow {
t.Fatalf("update_plan permission = %s, want allow", planTool.Safety().Permission)
}
}
func TestRegistryBlocksPromptToolsWithoutGrant(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "blocked.txt")
registry := NewRegistry()
registry.Register(NewScopedWriteFileTool(root, nil))
result := registry.Run(context.Background(), "write_file", map[string]any{
"path": "blocked.txt",
"content": "nope",
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "Permission required for write_file") {
t.Fatalf("expected permission error, got %q", result.Output)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("expected file to remain absent, stat err=%v", err)
}
}
func TestRegistryRunsPromptToolsWithGrant(t *testing.T) {
root := t.TempDir()
registry := NewRegistry()
registry.Register(NewScopedWriteFileTool(root, nil))
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "allowed.txt",
"content": "hello",
}, RunOptions{PermissionGranted: true})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "allowed.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "hello" {
t.Fatalf("expected written content, got %q", string(content))
}
}
func TestWriteFileToolCreatesAndProtectsExistingFiles(t *testing.T) {
root := t.TempDir()
tool := NewScopedWriteFileTool(root, nil)
created := tool.Run(context.Background(), map[string]any{
"path": "nested/file.txt",
"content": "first",
})
if created.Status != StatusOK {
t.Fatalf("expected create ok, got %s: %s", created.Status, created.Output)
}
if !strings.Contains(created.Output, "Created nested/file.txt") {
t.Fatalf("unexpected create output: %q", created.Output)
}
refused := tool.Run(context.Background(), map[string]any{
"path": "nested/file.txt",
"content": "second",
})
if refused.Status != StatusError {
t.Fatalf("expected overwrite refusal, got %s", refused.Status)
}
content, err := os.ReadFile(filepath.Join(root, "nested", "file.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "first" {
t.Fatalf("expected original content, got %q", string(content))
}
overwrote := tool.Run(context.Background(), map[string]any{
"path": "nested/file.txt",
"content": "second",
"overwrite": true,
})
if overwrote.Status != StatusOK {
t.Fatalf("expected overwrite ok, got %s: %s", overwrote.Status, overwrote.Output)
}
content, err = os.ReadFile(filepath.Join(root, "nested", "file.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "second" {
t.Fatalf("expected overwritten content, got %q", string(content))
}
}
func TestWriteFileToolRecordsCreatedFileButNotOverwrite(t *testing.T) {
root := t.TempDir()
registry := NewRegistry()
registry.Register(NewScopedWriteFileTool(root, nil))
tracker := NewFileTracker()
created := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "scratch.txt",
"content": "first",
}, RunOptions{PermissionGranted: true, FileTracker: tracker})
if created.Status != StatusOK {
t.Fatalf("expected create ok, got %s: %s", created.Status, created.Output)
}
absPath := filepath.Join(root, "scratch.txt")
absPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
t.Fatalf("EvalSymlinks(%q): %v", absPath, err)
}
if got := tracker.CreatedFiles(); len(got) != 1 || got[0] != absPath {
t.Fatalf("CreatedFiles() = %v, want [%s]", got, absPath)
}
overwrote := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "scratch.txt",
"content": "second",
"overwrite": true,
}, RunOptions{PermissionGranted: true, FileTracker: tracker})
if overwrote.Status != StatusOK {
t.Fatalf("expected overwrite ok, got %s: %s", overwrote.Status, overwrote.Output)
}
// Overwriting an existing file is not a "new" creation, so CreatedFiles
// must not gain a second (duplicate) entry for it.
if got := tracker.CreatedFiles(); len(got) != 1 || got[0] != absPath {
t.Fatalf("CreatedFiles() after overwrite = %v, want unchanged [%s]", got, absPath)
}
}
func TestApplyPatchRecordsCreatedFileButNotExistingEdits(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "existing.txt"), []byte("old\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := NewRegistry()
registry.Register(NewScopedApplyPatchTool(root, nil))
tracker := NewFileTracker()
patch := strings.Join([]string{
"diff --git a/scratch.txt b/scratch.txt",
"new file mode 100644",
"index 0000000..e965047",
"--- /dev/null",
"+++ b/scratch.txt",
"@@ -0,0 +1 @@",
"+hello",
"diff --git a/existing.txt b/existing.txt",
"--- a/existing.txt",
"+++ b/existing.txt",
"@@ -1 +1 @@",
"-old",
"+new",
"",
}, "\n")
result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{
"patch": patch,
}, RunOptions{PermissionGranted: true, FileTracker: tracker})
if result.Status != StatusOK {
if gitApplyUnavailable(result.Output) {
t.Skipf("git binary unavailable: %s", result.Output)
}
t.Fatalf("expected apply_patch ok, got %s: %s", result.Status, result.Output)
}
absPath := filepath.Join(root, "scratch.txt")
absPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
t.Fatalf("EvalSymlinks(%q): %v", absPath, err)
}
if got := tracker.CreatedFiles(); len(got) != 1 || got[0] != absPath {
t.Fatalf("CreatedFiles() = %v, want [%s]", got, absPath)
}
}
func TestWriteFileSummaryReportsLineCount(t *testing.T) {
root := t.TempDir()
tool := NewScopedWriteFileTool(root, nil)
// Three lines, no trailing newline -> "3 lines" (not a byte count).
result := tool.Run(context.Background(), map[string]any{
"path": "multi.txt",
"content": "one\ntwo\nthree",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "(3 lines)") {
t.Fatalf("summary should report a line count, got %q", result.Output)
}
if strings.Contains(result.Output, "bytes") {
t.Errorf("summary should no longer report bytes: %q", result.Output)
}
}
func TestWriteFileToolAllowsEmptyContent(t *testing.T) {
root := t.TempDir()
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "empty.txt",
"content": "",
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "empty.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "" {
t.Fatalf("expected empty file, got %q", string(content))
}
}
func TestWriteFileToolReportsTypeErrorsForEmptyAllowedStrings(t *testing.T) {
result := NewScopedWriteFileTool(t.TempDir(), nil).Run(context.Background(), map[string]any{
"path": "bad.txt",
"content": 42,
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "content must be a string") {
t.Fatalf("expected string type error, got %q", result.Output)
}
}
func TestWriteFileToolRejectsOutsideWorkspace(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "outside.txt")
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
"path": outside,
"content": "secret",
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "must stay inside the workspace") {
t.Fatalf("expected workspace error, got %q", result.Output)
}
if _, err := os.Stat(outside); !os.IsNotExist(err) {
t.Fatalf("expected outside file to remain absent, stat err=%v", err)
}
}
func TestWriteFileToolRejectsSymlinkParent(t *testing.T) {
root := t.TempDir()
realDirectory := filepath.Join(root, "real")
if err := os.MkdirAll(realDirectory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(realDirectory, filepath.Join(root, "link")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "link/escape.txt",
"content": "secret",
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "must not traverse symlink") {
t.Fatalf("expected symlink error, got %q", result.Output)
}
if _, err := os.Stat(filepath.Join(realDirectory, "escape.txt")); !os.IsNotExist(err) {
t.Fatalf("expected symlink target file to remain absent, stat err=%v", err)
}
}
func TestEditFileToolReplacesExactStrings(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "code.go")
writeTestFile(t, path, "const a = 1\nconst b = 2\n")
path, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
result := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "code.go",
"old_string": "const a = 1",
"new_string": "const a = 42",
})
if result.Status != StatusOK {
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "const a = 42\nconst b = 2\n" {
t.Fatalf("unexpected edited content: %q", string(content))
}
}
func TestEditFileToolReplacesCRLF(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "code.go")
writeTestFile(t, path, "const a = 1\r\nconst b = 2\r\n")
result := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "code.go",
"old_string": "const a = 1\nconst b = 2",
"new_string": "const a = 42\nconst b = 24",
})
if result.Status != StatusOK {
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "const a = 42\r\nconst b = 24\r\n" {
t.Fatalf("unexpected edited content: %q", string(content))
}
}
func TestEditFileToolEmitsUnifiedDiff(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "code.go")
writeTestFile(t, path, "const a = 1\nconst b = 2\n")
path, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
res := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "code.go", "old_string": "const a = 1", "new_string": "const a = 42",
})
if res.Status != StatusOK {
t.Fatalf("edit failed: %s", res.Output)
}
// The model-facing Output stays the one-line summary; the red/green diff lives
// on the card-only Display.Preview, so it costs the model zero tokens.
if !strings.HasPrefix(res.Output, "Successfully edited") {
t.Fatalf("summary must be the Output: %q", res.Output)
}
if strings.Contains(res.Output, "@@") {
t.Fatalf("Output must NOT carry the diff (card-only preview): %q", res.Output)
}
for _, want := range []string{"@@", "-const a = 1", "+const a = 42"} {
if !strings.Contains(res.Display.Preview, want) {
t.Fatalf("edit preview missing diff marker %q: %q", want, res.Display.Preview)
}
}
if got := res.FileDiffs; len(got) != 1 || got[0].Path != path || !got[0].OldExists || !got[0].NewExists || got[0].OldText != "const a = 1\nconst b = 2\n" || got[0].NewText != "const a = 42\nconst b = 2\n" {
t.Fatalf("file diffs = %#v", got)
}
}
func TestWriteFileToolEmitsAdditionsDiff(t *testing.T) {
root := t.TempDir()
res := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "new.txt", "content": "line one\nline two\n",
})
if res.Status != StatusOK {
t.Fatalf("write failed: %s", res.Output)
}
if strings.Contains(res.Output, "@@") {
t.Fatalf("Output must stay summary-only (the diff is card-only): %q", res.Output)
}
for _, want := range []string{"@@", "+line one", "+line two"} {
if !strings.Contains(res.Display.Preview, want) {
t.Fatalf("new-file preview missing additions diff %q: %q", want, res.Display.Preview)
}
}
if strings.Contains(res.Display.Preview, "\n-line") {
t.Fatalf("a fresh-create diff must have no removed lines: %q", res.Display.Preview)
}
path, err := filepath.EvalSymlinks(filepath.Join(root, "new.txt"))
if err != nil {
t.Fatal(err)
}
if got := res.FileDiffs; len(got) != 1 || got[0].Path != path || got[0].OldExists || !got[0].NewExists || got[0].OldText != "" || got[0].NewText != "line one\nline two\n" {
t.Fatalf("file diffs = %#v", got)
}
}
func TestWriteFileToolOverwriteEmitsRedGreenDiff(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "f.txt"), "old line\nkeep\n")
res := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "f.txt", "content": "new line\nkeep\n", "overwrite": true,
})
if res.Status != StatusOK {
t.Fatalf("overwrite failed: %s", res.Output)
}
if strings.Contains(res.Output, "@@") {
t.Fatalf("Output must stay summary-only (the diff is card-only): %q", res.Output)
}
for _, want := range []string{"-old line", "+new line"} {
if !strings.Contains(res.Display.Preview, want) {
t.Fatalf("overwrite preview missing %q: %q", want, res.Display.Preview)
}
}
}
func TestWriteFileToolOmitsDiffWhenOverwritePreimageCannotBeRead(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "private.txt")
writeTestFile(t, path, "before\n")
tool := NewScopedWriteFileTool(root, nil).(writeFileTool)
tool.readFile = func(string) ([]byte, error) { return nil, os.ErrPermission }
registry := NewRegistry()
registry.Register(tool)
result := registry.RunWithOptions(context.Background(), tool.Name(), map[string]any{
"path": "private.txt", "content": "after\n", "overwrite": true,
}, RunOptions{PermissionGranted: true})
if result.Status != StatusOK {
t.Fatalf("write = %s", result.Output)
}
if len(result.FileDiffs) != 0 {
t.Fatalf("unreadable preimage must not produce a create-like diff: %#v", result.FileDiffs)
}
if got, err := os.ReadFile(path); err != nil || string(got) != "after\n" {
t.Fatalf("written content = %q, err = %v", got, err)
}
}
func TestEditFileToolAllowsDeletingRegions(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "notes.txt")
writeTestFile(t, path, "keep\nremove\nkeep\n")
result := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "notes.txt",
"old_string": "remove\n",
"new_string": "",
})
if result.Status != StatusOK {
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "keep\nkeep\n" {
t.Fatalf("unexpected edited content: %q", string(content))
}
}
func TestEditFileToolRejectsMissingAndAmbiguousMatches(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "dup.txt")
writeTestFile(t, path, "x\nx\n")
tool := NewScopedEditFileTool(root, nil)
missing := tool.Run(context.Background(), map[string]any{
"path": "dup.txt",
"old_string": "missing",
"new_string": "y",
})
if missing.Status != StatusError || !strings.Contains(missing.Output, "Could not find") {
t.Fatalf("expected missing error, got %s: %s", missing.Status, missing.Output)
}
ambiguous := tool.Run(context.Background(), map[string]any{
"path": "dup.txt",
"old_string": "x",
"new_string": "y",
})
if ambiguous.Status != StatusError || !strings.Contains(ambiguous.Output, "matches 2 locations") {
t.Fatalf("expected ambiguity error, got %s: %s", ambiguous.Status, ambiguous.Output)
}
all := tool.Run(context.Background(), map[string]any{
"path": "dup.txt",
"old_string": "x",
"new_string": "y",
"replace_all": true,
})
if all.Status != StatusOK {
t.Fatalf("expected replace_all ok, got %s: %s", all.Status, all.Output)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "y\ny\n" {
t.Fatalf("expected all replacements, got %q", string(content))
}
}
func TestApplyPatchToolAppliesUnifiedDiff(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
patch := strings.Join([]string{
"diff --git a/hello.txt b/hello.txt",
"--- a/hello.txt",
"+++ b/hello.txt",
"@@ -1,2 +1,2 @@",
" hello",
"-old",
"+new",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{
"patch": patch,
})
if result.Status != StatusOK {
t.Fatalf("expected patch ok, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if strings.ReplaceAll(string(content), "\r\n", "\n") != "hello\nnew\n" {
t.Fatalf("unexpected patched content: %q", string(content))
}
}
func TestApplyPatchToolAppliesStructuredPatch(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: hello.txt",
"@@",
" hello",
"-old",
"+new",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nnew\n" {
t.Fatalf("structured patch content = %q", got)
}
}
func TestApplyPatchToolAppliesStructuredAddAndMove(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "old.txt"), "old\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Add File: nested/new.txt",
"+created",
"*** Update File: old.txt",
"*** Move to: moved.txt",
"@@",
"-old",
"+moved",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
}
if got, err := os.ReadFile(filepath.Join(root, "nested", "new.txt")); err != nil || string(got) != "created\n" {
t.Fatalf("added file = %q, err = %v", got, err)
}
if got, err := os.ReadFile(filepath.Join(root, "moved.txt")); err != nil || string(got) != "moved\n" {
t.Fatalf("moved file = %q, err = %v", got, err)
}
if _, err := os.Stat(filepath.Join(root, "old.txt")); !os.IsNotExist(err) {
t.Fatalf("old source should be removed, stat err = %v", err)
}
if got := result.ChangedFiles; strings.Join(got, ",") != "nested/new.txt,old.txt,moved.txt" {
t.Fatalf("ChangedFiles = %v", got)
}
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatal(err)
}
if got, want := result.FileDiffs, []FileDiff{
{Path: filepath.Join(resolvedRoot, "nested", "new.txt"), OldExists: false, NewExists: true, OldText: "", NewText: "created\n"},
{Path: filepath.Join(resolvedRoot, "old.txt"), OldExists: true, NewExists: false, OldText: "old\n", NewText: ""},
{Path: filepath.Join(resolvedRoot, "moved.txt"), OldExists: false, NewExists: true, OldText: "", NewText: "moved\n"},
}; !reflect.DeepEqual(got, want) {
t.Fatalf("FileDiffs = %#v, want %#v", got, want)
}
}
func TestApplyPatchToolStructuredPatchMatchesWhitespaceTolerantly(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), " hello \n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: hello.txt",
"@@",
"-hello",
"+goodbye",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should tolerate surrounding whitespace, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := string(content); got != "goodbye\n" {
t.Fatalf("structured patch content = %q", got)
}
}
func TestApplyPatchToolStructuredPatchPreservesWhitespaceTolerantContext(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.go")
original := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt()\n\t}\n}\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.go",
"@@",
" if cond {",
" doWork()",
"-\t\tlogIt()",
"+\t\tlogIt(ctx)",
" }",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
want := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt(ctx)\n\t}\n}\n"
if got := mustReadTestFile(t, path); got != want {
t.Fatalf("structured patch rewrote unchanged context:\n got %q\nwant %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchPreservesMixedContextLineEndings(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
original := "alpha\r\n\tcontext\r\n\told\nomega\r\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
" alpha",
" context",
"-\told",
"+\tnew",
" omega",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
want := "alpha\r\n\tcontext\r\n\tnew\nomega\r\n"
if got := mustReadTestFile(t, path); got != want {
t.Fatalf("structured patch rewrote context line endings:\n got %q\nwant %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchInsertsAtContext(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "example.txt"), "anchor\nmiddle\nremove\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@ anchor",
"+inserted",
"@@ middle",
"-remove",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured insertion failed: %s", result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "example.txt"))
if err != nil {
t.Fatal(err)
}
if got, want := string(content), "anchor\ninserted\nmiddle\n"; got != want {
t.Fatalf("structured insertion content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchRejectsAmbiguousMatch(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.go")
original := "func a() {\n\treturn nil\n}\n\nfunc b() {\n\treturn nil\n}\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.go",
"@@",
"-\treturn nil",
"+\treturn errNotFound",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError || !strings.Contains(strings.ToLower(result.Output), "ambiguous") {
t.Fatalf("ambiguous structured patch should be refused, got %s: %s", result.Status, result.Output)
}
if got := mustReadTestFile(t, path); got != original {
t.Fatalf("ambiguous structured patch changed the file: %q", got)
}
}
func TestApplyPatchToolStructuredPatchPreservesCRLF(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
writeTestFile(t, path, "alpha\r\nbravo\r\ncharlie\r\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-bravo",
"+BRAVO",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "alpha\r\nBRAVO\r\ncharlie\r\n"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchPreservesMissingFinalNewline(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
writeTestFile(t, path, "alpha\nbravo")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-alpha",
"+ALPHA",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "ALPHA\nbravo"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchKeepsInsertedEmptyFileWithoutFinalNewline(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "empty.txt")
writeTestFile(t, path, "")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: empty.txt",
"@@",
"+content",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "content"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchRejectsOutOfOrderEndHunk(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
original := "alpha\nbravo\ncharlie\ndelta\necho\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-delta",
"+DELTA",
"@@",
"-delta",
"-echo",
"+tail",
"*** End of File",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("out-of-order structured patch should be refused, got %s: %s", result.Status, result.Output)
}
if got := mustReadTestFile(t, path); got != original {
t.Fatalf("out-of-order structured patch changed the file: %q", got)
}
}
func TestStructuredPatchAddDoesNotOverwriteRacedDestination(t *testing.T) {
root := t.TempDir()
targetPath := filepath.Join(root, "created.txt")
writeTestFile(t, targetPath, "other writer\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
change := structuredPatchChange{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: targetPath, relative: "created.txt"},
after: "patch content\n", mode: 0o644,
}
_, err = applyStructuredPatchChanges(workspace, ".", []structuredPatchChange{change}, nil)
if err == nil || !errors.Is(err, os.ErrExist) {
t.Fatalf("raced add destination = %v, want os.ErrExist", err)
}
if strings.Contains(err.Error(), "exclusive-copy fallback") {
t.Fatalf("existing destination should be reported directly: %v", err)
}
if got := mustReadTestFile(t, targetPath); got != "other writer\n" {
t.Fatalf("raced add overwrote another writer's file: %q", got)
}
}
func TestStructuredPatchFailedDeleteDoesNotRecreateMissingFile(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
missingPath := filepath.Join(root, "missing.txt")
change := structuredPatchChange{
kind: structuredPatchDelete,
from: structuredPatchTarget{absolute: missingPath, relative: "missing.txt"},
before: "removed by another writer\n", mode: 0o644,
}
_, err = applyStructuredPatchChanges(workspace, ".", []structuredPatchChange{change}, nil)
if err == nil {
t.Fatal("delete of an already removed file should fail")
}
if _, statErr := os.Stat(missingPath); !os.IsNotExist(statErr) {
t.Fatalf("failed delete recreated the missing file: %v", statErr)
}
}
// A move whose source vanishes after planning is refused by the pre-commit
// recheck before the destination is published, so nothing is left half-done.
func TestStructuredPatchMoveWithMissingSourceIsRefusedBeforePublishing(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.txt")
destinationPath := filepath.Join(root, "destination.txt")
writeTestFile(t, sourcePath, "removed source\n")
change := structuredPatchChange{
kind: structuredPatchUpdate,
from: structuredPatchTarget{absolute: sourcePath, relative: "source.txt"},
to: structuredPatchTarget{absolute: destinationPath, relative: "destination.txt"},
before: "removed source\n", after: "moved content\n", mode: 0o644,
}
// The source exists at planning time and disappears just before commit.
removed := false
structuredPatchBeforeCommit = func(structuredPatchChange) {
if err := os.Remove(sourcePath); err != nil {
t.Fatal(err)
}
removed = true
}
defer func() { structuredPatchBeforeCommit = nil }()
_, err = applyStructuredPatchChanges(workspace, ".", []structuredPatchChange{change}, nil)
if !removed {
t.Fatal("pre-commit hook did not run")
}
if err == nil || !strings.Contains(err.Error(), "before commit") || strings.Contains(err.Error(), "partially applied") {
t.Fatalf("move with a missing source = %v, want a pre-commit refusal with nothing applied", err)
}
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
t.Fatalf("failed move recreated a missing source: %v", statErr)
}
if _, statErr := os.Stat(destinationPath); !os.IsNotExist(statErr) {
t.Fatalf("refused move must not publish the destination: %v", statErr)
}
}
func TestStructuredPatchUpdatePreservesCompetingWriteBeforeRename(t *testing.T) {
for _, tc := range []struct {
name string
competing string
replaceIdentity bool
}{
{name: "content changed", competing: "competing writer\n"},
{name: "identity changed with same content", competing: "planned\n", replaceIdentity: true},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
targetPath := filepath.Join(root, "file.txt")
writeTestFile(t, targetPath, "planned\n")
patch := "*** Begin Patch\n*** Update File: file.txt\n@@\n-planned\n+patched\n*** End Patch\n"
hookRan := false
var competingInfo os.FileInfo
structuredPatchBeforeRename = func(change structuredPatchChange) {
if tc.replaceIdentity {
tempPath := filepath.Join(root, "competing.tmp")
writeTestFile(t, tempPath, tc.competing)
if err := os.Rename(tempPath, targetPath); err != nil {
t.Fatal(err)
}
} else if err := os.WriteFile(targetPath, []byte(tc.competing), 0o644); err != nil {
t.Fatal(err)
}
var err error
competingInfo, err = os.Stat(targetPath)
if err != nil {
t.Fatal(err)
}
hookRan = true
}
t.Cleanup(func() { structuredPatchBeforeRename = nil })
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if !hookRan {
t.Fatal("pre-rename hook did not run")
}
if result.Status != StatusError || len(result.ChangedFiles) != 0 || len(result.FileDiffs) != 0 {
t.Fatalf("raced update = status=%s changed=%#v diffs=%#v output=%q", result.Status, result.ChangedFiles, result.FileDiffs, result.Output)
}
if got := mustReadTestFile(t, targetPath); got != tc.competing {
t.Fatalf("raced update overwrote competing content: %q", got)
}
finalInfo, err := os.Stat(targetPath)
if err != nil || !os.SameFile(competingInfo, finalInfo) {
t.Fatalf("raced update replaced the competing file identity: %v", err)
}
})
}
}
func TestStructuredPatchCreateOnlyFallsBackWhenHardLinksUnsupported(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.tmp")
writeTestFile(t, sourcePath, "complete content\n")
if err := workspace.Chmod("source.tmp", 0o444); err != nil {
t.Fatal(err)
}
committed, err := publishStructuredPatchNoReplaceWith(
workspace,
"source.tmp",
"target.txt",
0o444,
func(string, string) error { return errors.New("hard links unsupported") },
)
if err != nil || !committed {
t.Fatalf("fallback publish = committed %v, error %v", committed, err)
}
if got := mustReadTestFile(t, filepath.Join(root, "target.txt")); got != "complete content\n" {
t.Fatalf("fallback target content = %q", got)
}
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
t.Fatalf("read-only source was not cleaned up: %v", statErr)
}
}
func TestStructuredPatchCopyFailureReportsSurvivingPartialTarget(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.tmp")
writeTestFile(t, sourcePath, "complete content\n")
copyErr := errors.New("injected copy failure")
cleanupErr := errors.New("injected target cleanup failure")
committed, err := copyStructuredPatchNoReplaceWith(
workspace,
"source.tmp",
"target.txt",
0o644,
func(destination io.Writer, _ io.Reader) (int64, error) {
written, writeErr := io.WriteString(destination, "partial")
if writeErr != nil {
return int64(written), writeErr
}
return int64(written), copyErr
},
func(root *os.Root, name string) error {
if name == "target.txt" {
return cleanupErr
}
return removeStructuredPatchTemp(root, name)
},
)
if !committed || !errors.Is(err, copyErr) || !errors.Is(err, cleanupErr) {
t.Fatalf("copy failure = committed %v, error %v", committed, err)
}
targetPath := filepath.Join(root, "target.txt")
if got := mustReadTestFile(t, targetPath); got != "partial" {
t.Fatalf("partial target content = %q", got)
}
}
func TestIncompleteStructuredPatchNoReplaceWriteReportsOnlyVerifiedTarget(t *testing.T) {
for name, content := range map[string]string{
"complete publication": "complete content\n",
"partial publication": "partial",
} {
t.Run(name, func(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
targetPath := filepath.Join(root, "target.txt")
writeTestFile(t, targetPath, content)
change := structuredPatchChange{
kind: structuredPatchAdd,
to: structuredPatchTarget{
requested: "target.txt",
relative: "target.txt",
absolute: targetPath,
},
after: "complete content\n",
mode: 0o644,
}
outcome := incompleteStructuredPatchWrite(workspace, change, true)
if content == change.after {
if len(outcome.completed) != 1 || outcome.completed[0].kind != structuredPatchAdd || len(outcome.incompletePaths) != 0 {
t.Fatalf("verified publication outcome = %#v", outcome)
}
return
}
if len(outcome.completed) != 0 || !slices.Equal(outcome.incompletePaths, []string{"target.txt"}) {
t.Fatalf("partial publication outcome = %#v", outcome)
}
})
}
}
func TestStructuredPatchPartialFailureLeavesCompletedChangeAndClearsTrackedState(t *testing.T) {
root := t.TempDir()
trackedPath := filepath.Join(root, "tracked.txt")
writeTestFile(t, trackedPath, "original\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
trackedPath, err = filepath.EvalSymlinks(trackedPath)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(trackedPath)
if err != nil {
t.Fatal(err)
}
tracker := NewFileTracker()
tracker.Record(trackedPath, []byte("original\n"), info)
tracker.RecordSeenRange(trackedPath, 1, 1, 1)
trackedTarget := structuredPatchTarget{absolute: trackedPath, relative: "tracked.txt"}
blockedPath := filepath.Join(root, "blocked")
if err := os.Mkdir(blockedPath, 0o755); err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(blockedPath, "sentinel"), "keep\n")
changes := []structuredPatchChange{
{
kind: structuredPatchUpdate, from: trackedTarget, to: trackedTarget,
before: "original\n", after: "updated\n", mode: 0o644,
},
{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: blockedPath, relative: "blocked"},
after: "cannot replace a non-empty directory\n", mode: 0o644,
},
}
_, err = applyStructuredPatchChanges(workspace, ".", changes, tracker)
if err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("second change = %v, want partial-application error", err)
}
if got := mustReadTestFile(t, trackedPath); got != "updated\n" {
t.Fatalf("completed change was unexpectedly altered: %q", got)
}
if _, tracked := tracker.Version(trackedPath); tracked || tracker.SeenWhole(trackedPath) {
t.Fatal("partial failure retained stale file-tracker state")
}
}
func TestApplyPatchToolRejectsMalformedOrEscapingStructuredPatchBeforeWriting(t *testing.T) {
root := t.TempDir()
tool := NewScopedApplyPatchTool(root, nil)
for name, patch := range map[string]string{
"empty update": strings.Join([]string{
"*** Begin Patch",
"*** Update File: target.txt",
"*** End Patch",
}, "\n"),
"escape": strings.Join([]string{
"*** Begin Patch",
"*** Add File: ../outside.txt",
"+nope",
"*** End Patch",
}, "\n"),
} {
t.Run(name, func(t *testing.T) {
result := tool.Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("expected error, got %s: %s", result.Status, result.Output)
}
if strings.Contains(result.Output, "No valid patches") {
t.Fatalf("structured patch should fail before git apply, got %q", result.Output)
}
if name == "empty update" {
if _, err := os.Stat(filepath.Join(root, "target.txt")); !os.IsNotExist(err) {
t.Fatalf("empty update must not write target file, stat err = %v", err)
}
}
})
}
if _, err := os.Stat(filepath.Join(filepath.Dir(root), "outside.txt")); !os.IsNotExist(err) {
t.Fatalf("escaping patch must not write outside workspace, stat err = %v", err)
}
}
// A hunk-body line that removes content beginning with "-- " appears in the diff
// as "--- ..."; it must NOT be mistaken for a file header (which previously made
// apply_patch reject a valid patch as targeting an outside path).
func TestApplyPatchToolHandlesHunkBodyLookingLikeHeader(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.md"), "keep\n-- /etc/old\n")
patch := strings.Join([]string{
"diff --git a/notes.md b/notes.md",
"--- a/notes.md",
"+++ b/notes.md",
"@@ -1,2 +1,1 @@",
" keep",
"--- /etc/old",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("expected patch ok (hunk body must not be parsed as a header), got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "notes.md"))
if err != nil {
t.Fatal(err)
}
if strings.ReplaceAll(string(content), "\r\n", "\n") != "keep\n" {
t.Fatalf("unexpected patched content: %q", string(content))
}
}
func TestApplyPatchToolRejectsHunkCountInflationHidingEscapePath(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "safe.txt"), "old\n")
// A crafted section heading after the closing "@@" injects a "+9,999999"
// token. If parseHunkCounts scanned the whole line it would treat 999999 as
// the new-line count, stay stuck in hunk mode, and swallow the second file
// header below — hiding the ../escape.txt write from path validation.
patch := strings.Join([]string{
"diff --git a/safe.txt b/safe.txt",
"--- a/safe.txt",
"+++ b/safe.txt",
"@@ -1,1 +1,1 @@ +9,999999",
"-old",
"+new",
"--- a/../escape.txt",
"+++ b/../escape.txt",
"@@ -1,1 +1,1 @@",
"-secret",
"+pwned",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("crafted hunk header must not hide the out-of-workspace path, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "must stay inside the workspace") {
t.Fatalf("expected workspace-confinement rejection, got %q", result.Output)
}
}
func TestApplyPatchToolRejectsSymlinkPath(t *testing.T) {
root := t.TempDir()
realDirectory := filepath.Join(root, "real")
if err := os.MkdirAll(realDirectory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(realDirectory, filepath.Join(root, "link")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
patch := strings.Join([]string{
"diff --git a/link/new.txt b/link/new.txt",
"new file mode 100644",
"index 0000000..e965047",
"--- /dev/null",
"+++ b/link/new.txt",
"@@ -0,0 +1 @@",
"+hello",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{
"patch": patch,
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "must not traverse symlink") {
t.Fatalf("expected symlink error, got %q", result.Output)
}
if _, err := os.Stat(filepath.Join(realDirectory, "new.txt")); !os.IsNotExist(err) {
t.Fatalf("expected symlink target file to remain absent, stat err=%v", err)
}
}
func TestApplyPatchToolRejectsOutsideWorkspace(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{
"cwd": outside,
"patch": strings.Join([]string{
"diff --git a/nope.txt b/nope.txt",
"--- a/nope.txt",
"+++ b/nope.txt",
"@@ -0,0 +1 @@",
"+nope",
"",
}, "\n"),
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "must stay inside the workspace") {
t.Fatalf("expected workspace error, got %q", result.Output)
}
}
// Finding 3: apply_patch with cwd != "." must report WORKSPACE-relative
// ChangedFiles (cwd-prefixed), not cwd-relative paths. Otherwise the session's
// rewind/diff layer keys off the wrong path.
func TestApplyPatchReportsWorkspaceRelativeChangedFilesUnderCwd(t *testing.T) {
root := t.TempDir()
subdir := filepath.Join(root, "sub", "dir")
if err := os.MkdirAll(subdir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(subdir, "a.txt"), []byte("one\n"), 0o644); err != nil {
t.Fatal(err)
}
patch := "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-one\n+two\n"
res := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch, "cwd": "sub/dir"})
if res.Status != StatusOK {
if gitApplyUnavailable(res.Output) {
t.Skipf("git binary unavailable: %s", res.Output)
}
t.Fatalf("apply_patch with cwd failed (possible regression): %s", res.Output)
}
if len(res.ChangedFiles) != 1 || res.ChangedFiles[0] != "sub/dir/a.txt" {
t.Fatalf("ChangedFiles = %v, want [sub/dir/a.txt]", res.ChangedFiles)
}
}
func TestWriteFileReportsChangedFileAndDisplay(t *testing.T) {
root := t.TempDir()
res := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{"path": "notes.txt", "content": "hello"})
if res.Status != StatusOK {
t.Fatalf("status=%s output=%s", res.Status, res.Output)
}
if len(res.ChangedFiles) != 1 || res.ChangedFiles[0] != "notes.txt" {
t.Fatalf("ChangedFiles = %v, want [notes.txt]", res.ChangedFiles)
}
if res.Display.Kind != "file" {
t.Errorf("Display.Kind = %q, want file", res.Display.Kind)
}
if res.Display.Summary == "" {
t.Error("expected a non-empty Display.Summary")
}
}
func TestEditFileReportsChangedFileAndDisplay(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "f.txt"), []byte("alpha beta"), 0o644); err != nil {
t.Fatal(err)
}
res := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{"path": "f.txt", "old_string": "alpha", "new_string": "gamma"})
if res.Status != StatusOK {
t.Fatalf("status=%s output=%s", res.Status, res.Output)
}
if len(res.ChangedFiles) != 1 || res.ChangedFiles[0] != "f.txt" {
t.Fatalf("ChangedFiles = %v, want [f.txt]", res.ChangedFiles)
}
if res.Display.Kind != "diff" {
t.Errorf("Display.Kind = %q, want diff", res.Display.Kind)
}
}
func TestApplyPatchReportsChangedFiles(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("one\n"), 0o644); err != nil {
t.Fatal(err)
}
patch := "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-one\n+two\n"
res := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if res.Status != StatusOK {
if gitApplyUnavailable(res.Output) {
t.Skipf("git binary unavailable: %s", res.Output)
}
t.Fatalf("apply_patch failed (possible regression): %s", res.Output)
}
found := false
for _, f := range res.ChangedFiles {
if f == "a.txt" {
found = true
}
}
if !found {
t.Fatalf("expected a.txt in ChangedFiles, got %v", res.ChangedFiles)
}
if res.Display.Kind != "diff" {
t.Errorf("Display.Kind = %q, want diff", res.Display.Kind)
}
}
func TestWriteFileAcceptsContentAlias(t *testing.T) {
root := t.TempDir()
// minimax-style: content under an alias key instead of "content".
res := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "shop.html",
"contents": "<html>hi</html>",
})
if res.Status != StatusOK {
t.Fatalf("alias content should write, got %s: %s", res.Status, res.Output)
}
got, _ := os.ReadFile(filepath.Join(root, "shop.html"))
if string(got) != "<html>hi</html>" {
t.Fatalf("file content = %q", got)
}
}
// gitApplyUnavailable reports whether an apply_patch failure is due to the git
// binary being absent (an environment condition worth skipping) rather than a
// real regression (which must fail the test). apply_patch shells out to
// `git apply`; a missing binary surfaces as exec's "executable file not found".
func gitApplyUnavailable(output string) bool {
return strings.Contains(output, "executable file not found")
}