package tools import ( "context" "encoding/json" "os" "path/filepath" "slices" "strings" "testing" ) func TestFileTools(t *testing.T) { dir := t.TempDir() ctx := context.Background() write := NewFileWriteTool(dir) if out, err := write.Execute(ctx, json.RawMessage(`{"path":"notes/a.txt","content":"hello world"}`)); err != nil { t.Fatal(err) } else if !strings.Contains(out, "wrote 11 bytes") { t.Fatalf("write output = %q", out) } read := NewFileReadTool(dir) if out, err := read.Execute(ctx, json.RawMessage(`{"path":"notes/a.txt"}`)); err != nil { t.Fatal(err) } else if out != "hello world" { t.Fatalf("read output = %q", out) } list := NewFileListTool(dir) if out, err := list.Execute(ctx, json.RawMessage(`{"path":"notes"}`)); err != nil { t.Fatal(err) } else if out != "a.txt" { t.Fatalf("list output = %q", out) } search := NewFileSearchTool(dir) if out, err := search.Execute(ctx, json.RawMessage(`{"query":"hello","path":"notes"}`)); err != nil { t.Fatal(err) } else if !strings.Contains(out, "hello world") { t.Fatalf("search output = %q", out) } } func TestFileSearchSupportsContextAndMaxResults(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "notes.txt") content := strings.Join([]string{"before", "needle one", "middle", "needle two", "after"}, "\n") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } search := NewFileSearchTool(dir) out, err := search.walkSearch(context.Background(), fileSearchArgs{Query: "needle", ContextLines: 1, MaxResults: 1}, dir) if err != nil { t.Fatal(err) } for _, want := range []string{"-- ", "before", "needle one", "middle", "stopped after 1 matches"} { if !strings.Contains(out, want) { t.Fatalf("search output missing %q:\n%s", want, out) } } if strings.Contains(out, "needle two") { t.Fatalf("search should stop after max_results:\n%s", out) } } func TestFormatResultKeepsHeadAndTail(t *testing.T) { input := strings.Join([]string{"head-1", "head-2", strings.Repeat("x", 220), "tail-1", "tail-2"}, "\n") out := FormatResultLimit(input, 220) for _, want := range []string{"head-1", "truncated: showing head and tail", "tail-2"} { if !strings.Contains(out, want) { t.Fatalf("formatted output missing %q:\n%s", want, out) } } if len(out) > 220 { t.Fatalf("formatted output len = %d, want <= 220", len(out)) } } func TestShellRunTool(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "x.txt"), []byte("x"), 0o644); err != nil { t.Fatal(err) } tool := NewShellRunTool(dir) out, err := tool.Execute(context.Background(), json.RawMessage(`{"command":"pwd && ls"}`)) if err != nil { t.Fatal(err) } if !strings.Contains(out, dir) || !strings.Contains(out, "x.txt") { t.Fatalf("output = %q", out) } } func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) { registry := ReadOnlyBuiltins(t.TempDir()) var names []string for _, def := range registry.Definitions() { names = append(names, def.Function.Name) if strings.Contains(def.Function.Name, ".") { t.Fatalf("tool name contains dot: %s", def.Function.Name) } } for _, want := range []string{"file_list", "file_read", "file_search", "code_symbols"} { if !slices.Contains(names, want) { t.Fatalf("definitions missing %s: %#v", want, names) } } for _, alias := range []string{"file.list", "file.read", "file.search", "code.symbols"} { if _, ok := registry.Get(alias); !ok { t.Fatalf("alias missing: %s", alias) } } if _, ok := registry.Get("file_write"); ok { t.Fatal("read-only registry should not expose file_write") } } func TestCodeSymbolsToolListsGoSymbols(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "sample.go") if err := os.WriteFile(path, []byte(`package sample import "context" type Worker struct{} type Runner interface{ Run(context.Context) error } func NewWorker() *Worker { return &Worker{} } func (w *Worker) Run(ctx context.Context) error { return nil } `), 0o644); err != nil { t.Fatal(err) } tool := NewCodeSymbolsTool(dir) out, err := tool.Execute(context.Background(), json.RawMessage(`{"path":"sample.go"}`)) if err != nil { t.Fatal(err) } for _, want := range []string{"package sample", "imports: context", "type Worker struct", "type Runner interface", "func NewWorker", "method *Worker.Run"} { if !strings.Contains(out, want) { t.Fatalf("symbols output missing %q:\n%s", want, out) } } } func TestRegistryCanExposeWebSearch(t *testing.T) { registry := ReadOnlyBuiltins(t.TempDir()) registry.Register(NewBuiltinSearchTool(nil)) registry.Register(NewBuiltinFetchTool(nil)) var names []string for _, def := range registry.Definitions() { names = append(names, def.Function.Name) } for _, want := range []string{"web_search", "web_fetch"} { if !slices.Contains(names, want) { t.Fatalf("definitions missing %s: %#v", want, names) } } for _, want := range []string{"web_search", "web_fetch"} { if _, ok := registry.Get(want); !ok { t.Fatalf("%s missing", want) } } } func TestBuiltinsExposeYoloTools(t *testing.T) { registry := Builtins(t.TempDir()) for _, name := range []string{"file_write", "shell_run", "file.write", "shell.run"} { if _, ok := registry.Get(name); !ok { t.Fatalf("tool missing: %s", name) } } } func TestAgentInstructionsExplainCommandModes(t *testing.T) { readOnly := AgentInstructions("/tmp/project", false) for _, want := range []string{"Shell command execution is disabled", "--yolo", "Do not call tools for greetings", "Do not explore the project preemptively", "Do not call file_list just to discover context"} { if !strings.Contains(readOnly, want) { t.Fatalf("read-only instructions missing %q:\n%s", want, readOnly) } } yolo := AgentInstructions("/tmp/project", true) for _, want := range []string{"use shell_run", "commands written in backticks", "Do not start interactive", "non-interactive local commands", "Do not call tools for greetings"} { if !strings.Contains(yolo, want) { t.Fatalf("yolo instructions missing %q:\n%s", want, yolo) } } } func TestAgentInstructionsExplainWebSearch(t *testing.T) { withSearch := AgentInstructions("/tmp/project", false) for _, want := range []string{"use web_search", "Use web_fetch", "latest information", "source URLs", "Available tools: file_list, file_read, file_search, code_symbols, web_search, web_fetch"} { if !strings.Contains(withSearch, want) { t.Fatalf("web search instructions missing %q:\n%s", want, withSearch) } } } func TestResolvePathRejectsAbsolutePaths(t *testing.T) { base := t.TempDir() _, err := resolvePath(base, "/etc/passwd") if err == nil { t.Fatal("expected error for absolute path") } if !strings.Contains(err.Error(), "absolute paths are not allowed") { t.Fatalf("unexpected error: %v", err) } } func TestResolvePathRejectsEscapingBaseDir(t *testing.T) { base := filepath.Join(t.TempDir(), "project") if err := os.MkdirAll(base, 0o755); err != nil { t.Fatal(err) } _, err := resolvePath(base, "../../../etc/passwd") if err == nil { t.Fatal("expected error for path escaping base dir") } if !strings.Contains(err.Error(), "escapes the project directory") { t.Fatalf("unexpected error: %v", err) } } func TestResolvePathAllowsSubdirTraversal(t *testing.T) { base := t.TempDir() if err := os.MkdirAll(filepath.Join(base, "a", "b"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(base, "a", "target.txt"), []byte("ok"), 0o644); err != nil { t.Fatal(err) } // "a/b/../target.txt" resolves to "a/target.txt" which is within base. resolved, err := resolvePath(base, "a/b/../target.txt") if err != nil { t.Fatal(err) } want := filepath.Join(base, "a", "target.txt") if resolved != want { t.Fatalf("resolved = %q, want %q", resolved, want) } } func TestResolvePathRejectsDotDotThatEscapes(t *testing.T) { base := t.TempDir() sub := filepath.Join(base, "a") if err := os.MkdirAll(sub, 0o755); err != nil { t.Fatal(err) } _, err := resolvePath(sub, "..") if err == nil { t.Fatal("expected error for path escaping via ..") } }