3d403bd685
New tools: - file_edit: surgical line-range or pattern edits (yolo only) - git_status: show changed files (read-only) - git_diff: show unstaged/staged diff (read-only) - git_log: show recent commits (read-only) Agent improvements: - parallel execution for independent read-only tool calls - mutating tools (file_write, file_edit, shell_run) run sequentially - tool output preview in logs with [output] marker TUI improvements: - tool cards now show truncated output preview (up to 8 lines) - mergeToolLog updates placeholder cards with results - toolStatusText keeps activity bar single-line Documentation: - README updated with git tools and file_edit descriptions
276 lines
8.6 KiB
Go
276 lines
8.6 KiB
Go
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", "git_status", "git_diff", "git_log"} {
|
|
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", "git.status", "git.diff", "git.log"} {
|
|
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")
|
|
}
|
|
if _, ok := registry.Get("file_edit"); ok {
|
|
t.Fatal("read-only registry should not expose file_edit")
|
|
}
|
|
}
|
|
|
|
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", "file_edit", "shell_run", "file.write", "file.edit", "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", "git_status, git_diff, git_log"} {
|
|
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", "prefer file_edit"} {
|
|
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, git_status, git_diff, git_log, 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 ..")
|
|
}
|
|
}
|
|
|
|
func TestIsMutating(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if IsMutating(NewFileReadTool(dir)) {
|
|
t.Fatal("FileReadTool should not be mutating")
|
|
}
|
|
if !IsMutating(NewFileWriteTool(dir)) {
|
|
t.Fatal("FileWriteTool should be mutating")
|
|
}
|
|
if !IsMutating(NewShellRunTool(dir)) {
|
|
t.Fatal("ShellRunTool should be mutating")
|
|
}
|
|
}
|