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) } }