mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* 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
1445 lines
47 KiB
Go
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")
|
|
}
|