Files
zero/internal/tools/write_tools_test.go
euxaristiaandClaude Sonnet 5 97ef90d27a chore: remove code unreachable on every supported platform (#706)
* chore: remove code unreachable on every supported platform

The Security and code health job's advisory deadcode step reports 152
unreachable functions on main. Running the same check under GOOS=linux,
windows, and darwin shows 13 of those are false positives whose callers
live in windows build-tagged files, so only functions unreachable on all
three platforms are touched.

Functions with no references anywhere are deleted, including the
internal/reasoning package, which nothing imports. Functions that only
tests reference move into per-package export_test.go files so tests keep
their seams while the production binary sheds the code. Test call sites
of deleted convenience wrappers are ported to the live APIs (scoped tool
constructors, tools.DeferredSource, FinishReason checks).

Kept despite the report: cron.Store.Runs (only reader of runs.jsonl,
which production writes), and agenteval.TraceEventKeys and
MissingTraceEvents (used across packages by the trace contract test).

* docs(config): correct ValidateFile's errors.As claim

Message is a flattened string (.Error() already called on the parse
error), not a wrapped error, so callers can't recover the concrete
json error type via errors.As. Fix the comment to match.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* chore(trace,skills): drop obsolete Sink adapters and fix stale doc references

The dead-code pass removed the Emit methods but left Sink, NDJSONSink, and
TextSink exported with a contract nothing implements; remove the types with
their contract. Also remove the stray blank line at EOF of trace.go flagged
by gofmt, and update skills package docs and HOW_ZERO_WORKS.md to stop
directing callers to DiscoveryRoots/Duplicates, which are now test seams.

Co-Authored-By: Claude Fable 5 <[email protected]>

* chore: clean up remaining review nits on dead code removal

Replace a pointless fmt.Errorf(...%w...).Error() with fmt.Sprintf
using %v, since the wrapped error was discarded immediately anyway.
Remove a comment block that was duplicated verbatim.

* docs(skills): drop remaining references to test-only List/Get

Load and MergedSkills comments still named List/Get as production
entry points after they moved to export_test.go. Point callers at
Load and ListFromRoots instead.

* chore: remove now-unreferenced export_test.go seams

execSessionManager.len() and likelySandboxDenied/sandboxDenialKind had
no remaining callers after the sandbox unify-command-execution rebase;
the production code they wrapped for tests no longer exists.

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-24 09:04:41 +08:00

747 lines
24 KiB
Go

package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
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")
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()
writeTestFile(t, filepath.Join(root, "code.go"), "const a = 1\nconst b = 2\n")
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)
}
}
}
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)
}
}
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 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))
}
}
// 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")
}