Files
BlackBean/pigo/internal/agenttool/edit_tool_test.go
T
2026-08-14 23:41:57 +08:00

178 lines
5.8 KiB
Go

package agenttool
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
)
func runEdit(t *testing.T, tool *EditTool, args map[string]any) agentcore.AgentToolResult {
t.Helper()
raw, err := json.Marshal(args)
if err != nil {
t.Fatalf("marshal args: %v", err)
}
res, gerr := tool.Execute(context.Background(), "call-1", raw, nil)
if gerr != nil {
t.Fatalf("execute returned go error: %v", gerr)
}
return res
}
func seedFile(t *testing.T, dir, name, content string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatalf("seed %s: %v", name, err)
}
return p
}
func TestEditToolUniqueMatch(t *testing.T) {
dir := t.TempDir()
p := seedFile(t, dir, "f.txt", "alpha\nbeta\ngamma\n")
tool := &EditTool{Root: dir}
res := runEdit(t, tool, map[string]any{"path": "f.txt", "old_string": "beta", "new_string": "BETA"})
if strings.Contains(resultText(res), "not found") || strings.Contains(resultText(res), "not unique") {
t.Fatalf("unexpected error: %q", resultText(res))
}
got, _ := os.ReadFile(p)
if string(got) != "alpha\nBETA\ngamma\n" {
t.Errorf("content = %q", got)
}
// Diff present.
if !strings.Contains(resultText(res), "-beta") || !strings.Contains(resultText(res), "+BETA") {
t.Errorf("diff missing markers: %q", resultText(res))
}
}
func TestEditToolNonUniqueErrors(t *testing.T) {
dir := t.TempDir()
seedFile(t, dir, "f.txt", "x\nx\nx\n")
tool := &EditTool{Root: dir}
res := runEdit(t, tool, map[string]any{"path": "f.txt", "old_string": "x", "new_string": "y"})
if !strings.Contains(resultText(res), "not unique") {
t.Errorf("expected non-unique error, got %q", resultText(res))
}
// File unchanged.
got, _ := os.ReadFile(filepath.Join(dir, "f.txt"))
if string(got) != "x\nx\nx\n" {
t.Errorf("file should be unchanged, got %q", got)
}
}
func TestEditToolReplaceAll(t *testing.T) {
dir := t.TempDir()
p := seedFile(t, dir, "f.txt", "x\nx\nx\n")
tool := &EditTool{Root: dir}
res := runEdit(t, tool, map[string]any{"path": "f.txt", "old_string": "x", "new_string": "y", "replace_all": true})
got, _ := os.ReadFile(p)
if string(got) != "y\ny\ny\n" {
t.Errorf("content = %q, want all replaced", got)
}
details, ok := res.Details.(map[string]any)
if !ok || details["replacements"] != 3 {
t.Errorf("expected 3 replacements, details = %+v", res.Details)
}
}
func TestEditToolNotFound(t *testing.T) {
dir := t.TempDir()
seedFile(t, dir, "f.txt", "hello\n")
tool := &EditTool{Root: dir}
res := runEdit(t, tool, map[string]any{"path": "f.txt", "old_string": "missing", "new_string": "x"})
if !strings.Contains(resultText(res), "not found") {
t.Errorf("expected not-found error, got %q", resultText(res))
}
}
func TestEditToolMissingFile(t *testing.T) {
tool := &EditTool{Root: t.TempDir()}
res := runEdit(t, tool, map[string]any{"path": "nope.txt", "old_string": "a", "new_string": "b"})
if !strings.Contains(resultText(res), "does not exist") {
t.Errorf("expected does-not-exist, got %q", resultText(res))
}
}
func TestEditToolIdenticalStrings(t *testing.T) {
dir := t.TempDir()
seedFile(t, dir, "f.txt", "a\n")
tool := &EditTool{Root: dir}
res := runEdit(t, tool, map[string]any{"path": "f.txt", "old_string": "a", "new_string": "a"})
if !strings.Contains(resultText(res), "identical") {
t.Errorf("expected identical error, got %q", resultText(res))
}
}
func TestEditToolPathTraversal(t *testing.T) {
dir := t.TempDir()
tool := &EditTool{Root: dir}
res := runEdit(t, tool, map[string]any{"path": "../x.txt", "old_string": "a", "new_string": "b"})
if !strings.Contains(resultText(res), "outside the workspace root") {
t.Errorf("expected boundary error, got %q", resultText(res))
}
}
func TestEditToolExtraRootsAllowsSkillModification(t *testing.T) {
work := t.TempDir()
skills := t.TempDir()
skillFile := filepath.Join(skills, "weather", "SKILL.md")
if err := os.MkdirAll(filepath.Dir(skillFile), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(skillFile, []byte("old body\n"), 0o644); err != nil {
t.Fatalf("seed skill: %v", err)
}
// Without ExtraRoots the out-of-workspace skill edit is rejected.
bounded := &EditTool{Root: work}
res := runEdit(t, bounded, map[string]any{"path": skillFile, "old_string": "old body", "new_string": "new body"})
if !strings.Contains(resultText(res), "outside the workspace root") {
t.Fatalf("expected boundary rejection without ExtraRoots, got %q", resultText(res))
}
// With the skills dir as an extra root the edit applies.
tool := &EditTool{Root: work, ExtraRoots: []string{skills}}
res = runEdit(t, tool, map[string]any{"path": skillFile, "old_string": "old body", "new_string": "new body"})
if strings.Contains(resultText(res), "outside the workspace root") {
t.Fatalf("edit still blocked with ExtraRoots: %q", resultText(res))
}
got, _ := os.ReadFile(skillFile)
if !strings.Contains(string(got), "new body") {
t.Fatalf("skill not modified, content = %q", got)
}
}
func TestEditToolMode(t *testing.T) {
tool := &EditTool{}
if tool.Name() != "edit" {
t.Errorf("name = %q", tool.Name())
}
if tool.ExecutionMode() != agentcore.ToolExecutionSequential {
t.Error("edit should be sequential")
}
var schema map[string]any
if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
t.Errorf("schema not valid JSON: %v", err)
}
}
func TestUnifiedDiff(t *testing.T) {
diff := unifiedDiff("f.txt", "a\nb\nc\n", "a\nB\nc\n")
if !strings.Contains(diff, "--- a/f.txt") || !strings.Contains(diff, "+++ b/f.txt") {
t.Errorf("missing header: %q", diff)
}
if !strings.Contains(diff, "-b") || !strings.Contains(diff, "+B") {
t.Errorf("missing change lines: %q", diff)
}
// Unchanged context lines carry a leading space.
if !strings.Contains(diff, " a") || !strings.Contains(diff, " c") {
t.Errorf("missing context lines: %q", diff)
}
}