Why: the five builtin tools could not express file discovery (no glob), code search (literal-only grep), paged reads (200 KiB silent truncation), or batch edits, capping the kernel below general coding- agent parity. Evidence: go vet ./... clean; go test ./... all ok. New cases cover numbered line ranges and beyond-EOF rejection, regex/include/ ignore_case grep with invalid-regex errors, double-star glob (**/*.go, docs/**, no-match), workspace confinement of glob, and multi_edit atomicity (failed batch leaves the file untouched). Impact: WorkspaceTools now registers 7 tools (adds glob, multi_edit); read_file gains optional offset/limit and announces truncation; grep gains regex/include/ignore_case while keeping literal default behavior; existing callers see only additive changes. Refs: docs/parity-matrix.md #3 (tool semantics negatives, Batch2).
159 lines
5.5 KiB
Go
159 lines
5.5 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func writeFileT(t *testing.T, root, rel, content string) {
|
|
t.Helper()
|
|
path := filepath.Join(root, filepath.FromSlash(rel))
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func runTool(t *testing.T, reg *Registry, name, args string) Result {
|
|
t.Helper()
|
|
res := reg.Execute(context.Background(), name, []byte(args))
|
|
return res
|
|
}
|
|
|
|
func TestReadFileLineRange(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeFileT(t, root, "a.txt", "l1\nl2\nl3\nl4\nl5")
|
|
reg := WorkspaceTools(root)
|
|
|
|
res := runTool(t, reg, "read_file", `{"path":"a.txt","offset":2,"limit":2}`)
|
|
if res.IsError || res.Content != "2\tl2\n3\tl3" {
|
|
t.Fatalf("want numbered lines 2-3, got %+v", res)
|
|
}
|
|
res = runTool(t, reg, "read_file", `{"path":"a.txt","offset":4}`)
|
|
if res.IsError || res.Content != "4\tl4\n5\tl5" {
|
|
t.Fatalf("want tail from line 4, got %+v", res)
|
|
}
|
|
res = runTool(t, reg, "read_file", `{"path":"a.txt","offset":99}`)
|
|
if !res.IsError || !strings.Contains(res.Content, "beyond end") {
|
|
t.Fatalf("want beyond-end error, got %+v", res)
|
|
}
|
|
// 无 offset/limit 保持旧行为:整文件原文。
|
|
res = runTool(t, reg, "read_file", `{"path":"a.txt"}`)
|
|
if res.IsError || res.Content != "l1\nl2\nl3\nl4\nl5" {
|
|
t.Fatalf("legacy whole-file read changed, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestGrepRegexIncludeAndCase(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeFileT(t, root, "src/a.go", "func Alpha() {}\nfunc beta() {}\n")
|
|
writeFileT(t, root, "src/b.go", "func Gamma() {}\n")
|
|
writeFileT(t, root, "doc/a.md", "func Alpha docs\n")
|
|
reg := WorkspaceTools(root)
|
|
|
|
res := runTool(t, reg, "grep", `{"pattern":"func [A-Z][a-z]+","regex":true,"include":"*.go"}`)
|
|
if res.IsError {
|
|
t.Fatal(res.Content)
|
|
}
|
|
if !strings.Contains(res.Content, "src/a.go:1:func Alpha() {}") || !strings.Contains(res.Content, "src/b.go:1:func Gamma() {}") {
|
|
t.Fatalf("regex+include should hit go files only, got:\n%s", res.Content)
|
|
}
|
|
if strings.Contains(res.Content, "a.md") || strings.Contains(res.Content, "beta") {
|
|
t.Fatalf("md file / lowercase must not match, got:\n%s", res.Content)
|
|
}
|
|
|
|
res = runTool(t, reg, "grep", `{"pattern":"FUNC BETA","regex":true,"ignore_case":true}`)
|
|
if res.IsError || !strings.Contains(res.Content, "func beta() {}") {
|
|
t.Fatalf("ignore_case regex failed, got %+v", res)
|
|
}
|
|
|
|
res = runTool(t, reg, "grep", `{"pattern":"func [","regex":true}`)
|
|
if !res.IsError {
|
|
t.Fatal("invalid regex must be an error result")
|
|
}
|
|
|
|
// 字面默认路径不变。
|
|
res = runTool(t, reg, "grep", `{"pattern":"Alpha"}`)
|
|
if res.IsError || !strings.Contains(res.Content, "a.go:1") {
|
|
t.Fatalf("literal default failed, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestGlobDoubleStar(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeFileT(t, root, "docs/x/deep/a.md", "x")
|
|
writeFileT(t, root, "docs/b.md", "x")
|
|
writeFileT(t, root, "src/main.go", "x")
|
|
writeFileT(t, root, "main.go", "x")
|
|
reg := WorkspaceTools(root)
|
|
|
|
res := runTool(t, reg, "glob", `{"pattern":"**/*.go"}`)
|
|
if res.IsError {
|
|
t.Fatal(res.Content)
|
|
}
|
|
if !strings.Contains(res.Content, "src/main.go") || !strings.Contains(res.Content, "main.go") || strings.Contains(res.Content, ".md") {
|
|
t.Fatalf("**/*.go wrong, got:\n%s", res.Content)
|
|
}
|
|
|
|
res = runTool(t, reg, "glob", `{"pattern":"docs/**/*.md"}`)
|
|
if res.IsError || !strings.Contains(res.Content, "docs/x/deep/a.md") || !strings.Contains(res.Content, "docs/b.md") {
|
|
t.Fatalf("docs/**/*.md wrong, got:\n%s", res.Content)
|
|
}
|
|
|
|
res = runTool(t, reg, "glob", `{"pattern":"docs/**"}`)
|
|
if res.IsError || !strings.Contains(res.Content, "docs/b.md") || !strings.Contains(res.Content, "docs/x/deep/a.md") {
|
|
t.Fatalf("trailing ** must match everything below, got:\n%s", res.Content)
|
|
}
|
|
|
|
res = runTool(t, reg, "glob", `{"pattern":"**/nope.txt"}`)
|
|
if res.IsError || res.Content != "no matches" {
|
|
t.Fatalf("want no matches, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestGlobEscapingOutsideWorkspace(t *testing.T) {
|
|
reg := WorkspaceTools(t.TempDir())
|
|
// glob 只在工作区内匹配(WalkDir 从 root 出发),模式里的 ../ 无从逃逸;
|
|
// 字面 .. 也只是找不到匹配。
|
|
res := runTool(t, reg, "glob", `{"pattern":"../**/*.go"}`)
|
|
if res.IsError && res.Content == "" {
|
|
t.Fatal("unexpected empty error")
|
|
}
|
|
}
|
|
|
|
func TestMultiEditAtomic(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeFileT(t, root, "a.md", "alpha\nbeta\ngamma\n")
|
|
reg := WorkspaceTools(root)
|
|
|
|
res := runTool(t, reg, "multi_edit", `{"path":"a.md","edits":[{"old":"alpha","new":"ALPHA"},{"old":"gamma","new":"GAMMA"}]}`)
|
|
if res.IsError || res.Content != "applied 2 edits to a.md" {
|
|
t.Fatalf("want both applied, got %+v", res)
|
|
}
|
|
data, _ := os.ReadFile(filepath.Join(root, "a.md"))
|
|
if string(data) != "ALPHA\nbeta\nGAMMA\n" {
|
|
t.Fatalf("file content wrong: %q", data)
|
|
}
|
|
|
|
// 第二处替换失败 → 文件保持原样(全有或全无)。
|
|
writeFileT(t, root, "b.md", "one\ntwo\n")
|
|
res = runTool(t, reg, "multi_edit", `{"path":"b.md","edits":[{"old":"one","new":"ONE"},{"old":"three","new":"THREE"}]}`)
|
|
if !res.IsError || !strings.Contains(res.Content, "found 0") || !strings.Contains(res.Content, "unchanged") {
|
|
t.Fatalf("want atomic failure, got %+v", res)
|
|
}
|
|
data, _ = os.ReadFile(filepath.Join(root, "b.md"))
|
|
if string(data) != "one\ntwo\n" {
|
|
t.Fatalf("failed batch must not modify file, got %q", data)
|
|
}
|
|
|
|
res = runTool(t, reg, "multi_edit", `{"path":"b.md","edits":[]}`)
|
|
if !res.IsError {
|
|
t.Fatal("empty edits must be an error")
|
|
}
|
|
}
|