Files
Sakurasan d5dac5a6fc feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、
builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、
编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。

产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt)
留在 AutoGo,宿主接入方式见 MIGRATION.md。
2026-10-26 00:54:46 +08:00

214 lines
6.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tools
import (
"context"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"unicode/utf8"
)
func lookupTool(t *testing.T, name string) *Tool {
t.Helper()
reg := NewRegistry()
for _, tl := range Builtin() {
if err := reg.Register(tl); err != nil {
t.Fatalf("register %s: %v", tl.Name, err)
}
}
tl, ok := reg.Get(name)
if !ok {
t.Fatalf("tool %q not registered", name)
}
return tl
}
// TestBashTimeoutIsEnforced:bash 的超时必须真的终止命令。
//
// 命令里 `sleep 60 &` 会留下一个继承 stdout 管道写端的子进程。修复前
// exec.CommandContext 只杀直接子进程,cmd.Run() 要等管道写端全部关闭才返回
// ——于是"120 秒超时"实测挂了 23 分钟(session 20261008-214717),整个回合静止。
func TestBashTimeoutIsEnforced(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("依赖 POSIX 进程组语义")
}
old := bashTimeout
bashTimeout = 400 * time.Millisecond
defer func() { bashTimeout = old }()
tl := lookupTool(t, "bash")
start := time.Now()
res, err := tl.Run(context.Background(), `{"command":"sleep 60 & sleep 60"}`)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("工具不该返回 error(失败走 Result.Err):%v", err)
}
if res.Err == nil {
t.Errorf("超时必须成为显式失败信号,text=%q", res.Text)
}
// WaitGrace 给 2 秒余量;再宽就是抓"完全没生效"的量级了。
if elapsed > 10*time.Second {
t.Errorf("超时没有生效:预算 %v,实际 %v", old, elapsed)
}
}
// TestBashCancelStopsTool:调用方取消(用户按停止)后不该再留下长时间阻塞。
func TestBashCancelStopsTool(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("依赖 POSIX 进程组语义")
}
tl := lookupTool(t, "bash")
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(300 * time.Millisecond)
cancel()
}()
start := time.Now()
res, _ := tl.Run(ctx, `{"command":"sleep 60 & sleep 60"}`)
if elapsed := time.Since(start); elapsed > 10*time.Second {
t.Errorf("取消后仍阻塞 %v(res.text=%q)", elapsed, res.Text)
}
}
// TestBashOutputTruncationKeepsUTF8:截断不能把一个汉字劈开。
// 中文一字 3 字节,16000 字节的硬切几乎必然落在字符中间,尾部非法 UTF-8
// 进 JSON 会变替换字符,也可能被模型网关直接拒掉。
func TestBashOutputTruncationKeepsUTF8(t *testing.T) {
old := bashMaxOutput
bashMaxOutput = 100
defer func() { bashMaxOutput = old }()
tl := lookupTool(t, "bash")
res, err := tl.Run(context.Background(),
`{"command":"awk 'BEGIN{for(i=0;i<2000;i++)printf \"中文测试\"}'"}`)
if err != nil {
t.Fatalf("run: %v", err)
}
if res.Err != nil {
t.Fatalf("命令失败:%s %v", res.Text, res.Err)
}
const marker = "\n...[truncated]"
body := strings.TrimSuffix(res.Text, marker)
if body == res.Text {
t.Fatalf("没有截断标记,len=%d text=%q", len(res.Text), res.Text)
}
if !utf8.ValidString(body) {
tail := len(body) - 9
if tail < 0 {
tail = 0
}
t.Errorf("截断处切坏了 UTF-8,尾部=%q", body[tail:])
}
if len(body) > bashMaxOutput {
t.Errorf("截断没生效:%d > %d", len(body), bashMaxOutput)
}
}
// TestBashRunsWithinWorkspace:workDir 语义不能被换掉时丢掉。
func TestBashRunsWithinWorkspace(t *testing.T) {
dir := t.TempDir()
reg := NewRegistry()
for _, tl := range BuiltinIn(dir, nil, nil) {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
tl, ok := reg.Get("bash")
if !ok {
t.Fatal("bash not registered")
}
res, err := tl.Run(context.Background(), `{"command":"pwd"}`)
if err != nil || res.Err != nil {
t.Fatalf("run: err=%v res=%+v", err, res)
}
// macOS 的临时目录带 /private 前缀,两侧都解引用再比。
got, want := eval(res.Text), dir
if want2 := eval(dir); want2 != "" {
want = want2
}
if got != want {
t.Errorf("没有在工作区里执行:got=%q want=%q", got, want)
}
}
func eval(s string) string {
p, err := filepath.EvalSymlinks(strings.TrimSpace(s))
if err != nil {
return ""
}
return p
}
// recordingSnap 收集留档钩子收到的改动。
type recordingSnap struct{ got []Change }
func (r *recordingSnap) Record(c Change) { r.got = append(r.got, c) }
// TestWriteEditFeedTheSnapshotHook:write/edit 必须在**落盘之后**把
// 路径/改前内容/是否新建/改后内容交给留档层。少一份或报错都算回归。
func TestWriteEditFeedTheSnapshotHook(t *testing.T) {
dir := t.TempDir()
snap := &recordingSnap{}
reg := NewRegistry()
for _, tl := range BuiltinIn(dir, NewFence(), snap) {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
w, _ := reg.Get("write")
e, _ := reg.Get("edit")
p := filepath.Join(dir, "s.txt")
// 新建:Existed=false,Before 为空
if _, err := w.Run(context.Background(),
`{"path":"`+p+`","content":"第一版"}`); err != nil {
t.Fatal(err)
}
if len(snap.got) != 1 || snap.got[0].Existed || string(snap.got[0].After) != "第一版" {
t.Fatalf("新建的留档不对:%+v", snap.got)
}
// 改动:Existed=true,Before 是旧内容
if _, err := w.Run(context.Background(),
`{"path":"`+p+`","content":"第二版"}`); err != nil {
t.Fatal(err)
}
g := snap.got[1]
if !g.Existed || string(g.Before) != "第一版" || string(g.After) != "第二版" {
t.Errorf("覆盖写的留档不对:%+v", g)
}
// edit:同样要交一份,且 Before 是编辑前全文
if _, err := e.Run(context.Background(),
`{"path":"`+p+`","old_string":"第二版","new_string":"第三版"}`); err != nil {
t.Fatal(err)
}
g = snap.got[2]
if !g.Existed || string(g.Before) != "第二版" || string(g.After) != "第三版" {
t.Errorf("edit 的留档不对:%+v", g)
}
// 失败的写不该留档(否则索引里会出现现实中没发生的改动)
if _, err := e.Run(context.Background(),
`{"path":"`+p+`","old_string":"不存在","new_string":"x"}`); err == nil {
t.Fatal("edit 应报错")
}
if len(snap.got) != 3 {
t.Errorf("失败的改动也留档了:%d 条", len(snap.got))
}
}
// TestSnapshotHookDisabledIsNoop:不传留档层时行为与从前完全一致。
func TestSnapshotHookDisabledIsNoop(t *testing.T) {
reg := NewRegistry()
for _, tl := range BuiltinIn(t.TempDir(), NewFence(), nil) {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
w, _ := reg.Get("write")
res, err := w.Run(context.Background(), `{"path":"x.txt","content":"hi"}`)
if err != nil || res.Err != nil {
t.Fatalf("write: %v %+v", err, res)
}
}