Files
Agent-Native/runtime/tools/code_tools_test.go
T
weichen b105e71533 Add glob, multi_edit, regex grep, and line-range reads to the workspace toolset
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).
2026-10-01 05:29:12 +00:00

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