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

340 lines
10 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.
// 后台任务的 kill/等待用 syscall.Kill 驱动,Windows 上没有这个调用。
//go:build !windows
package tools
import (
"context"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"syscall"
"testing"
"time"
"unicode/utf8"
"git.gopher.ink/mirrors2/AutoZero/scratch"
)
var jobIDRe = regexp.MustCompile(`\b(bg-[0-9a-f]{8}|bg-\d+)\b`)
// resetBG 清空进程级作业表。作业表故意跨回合共享(同一次 a2go 进程里都看得见),
// 所以测试必须自己隔开,否则前一个用例的作业会出现在后一个用例的列表里。
func resetBG(t *testing.T) {
t.Helper()
bgMu.Lock()
bgJobs = map[string]*bgJob{}
bgSeq = nil
bgMu.Unlock()
}
func bgStart(t *testing.T, command string) (id, logPath string) {
t.Helper()
tl := lookupTool(t, "bash_bg")
res, err := tl.Run(context.Background(), `{"action":"start","command":`+jsonQuote(command)+`}`)
if err != nil {
t.Fatalf("start: %v", err)
}
id = jobIDRe.FindString(res.Text)
if id == "" {
t.Fatalf("没拿到作业号:%q", res.Text)
}
line := regexp.MustCompile(`log: (\S+)`).FindStringSubmatch(res.Text)
if line == nil {
t.Fatalf("没给出日志路径:%q", res.Text)
}
return id, line[1]
}
func jsonQuote(s string) string {
// 只在测试里用最省事的方式造 JSON 字符串字面量。
return `"` + strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), `"`, `\"`) + `"`
}
func bgStatus(t *testing.T, args string) string {
t.Helper()
tl := lookupTool(t, "bg_status")
res, err := tl.Run(context.Background(), args)
if err != nil {
t.Fatalf("bg_status: %v", err)
}
return res.Text
}
// TestBackgroundJobPushesCompletion:作业结束要主动通知启动它的会话,
// 带上作业号、退出状态、日志路径与输出尾部;模型不必再轮询。
func TestBackgroundJobPushesCompletion(t *testing.T) {
resetBG(t)
t.Setenv("HOME", t.TempDir())
done := make(chan BackgroundDone, 1)
j, err := startJob(t.TempDir(), "printf 'hello-bg'", "sess-42", func(ev BackgroundDone) {
done <- ev
})
if err != nil {
t.Fatal(err)
}
select {
case ev := <-done:
if ev.JobID != j.ID || ev.SessionID != "sess-42" {
t.Fatalf("notification identity: %+v (job %s)", ev, j.ID)
}
if ev.Exit != "exit 0" {
t.Fatalf("exit = %q", ev.Exit)
}
if !strings.Contains(ev.Tail, "hello-bg") {
t.Fatalf("tail missing output: %q", ev.Tail)
}
if ev.LogPath == "" {
t.Fatal("log path missing")
}
case <-time.After(5 * time.Second):
t.Fatal("no completion notification")
}
}
// TestBackgroundJobNotifiesOnFailure:非零退出同样推送,状态要如实。
func TestBackgroundJobNotifiesOnFailure(t *testing.T) {
resetBG(t)
t.Setenv("HOME", t.TempDir())
done := make(chan BackgroundDone, 1)
if _, err := startJob(t.TempDir(), "echo boom >&2; exit 3", "s1", func(ev BackgroundDone) {
done <- ev
}); err != nil {
t.Fatal(err)
}
select {
case ev := <-done:
if !strings.Contains(ev.Exit, "3") {
t.Fatalf("exit = %q", ev.Exit)
}
if !strings.Contains(ev.Tail, "boom") {
t.Fatalf("tail missing stderr: %q", ev.Tail)
}
case <-time.After(5 * time.Second):
t.Fatal("no completion notification")
}
}
// TestBashBgStartReturnsImmediately:start 不等命令跑完。
// 这是这条通道存在的理由——长任务不该占着 120 秒的同步窗口。
func TestBashBgStartReturnsImmediately(t *testing.T) {
resetBG(t)
t.Setenv("HOME", t.TempDir())
start := time.Now()
id, logPath := bgStart(t, "sleep 30")
if el := time.Since(start); el > 2*time.Second {
t.Errorf("start 阻塞了 %v,后台通道等于没有", el)
}
defer func() { _, _ = logPath, id }()
// 日志必须落在 scratch 根里:那才是 read 工具够得着的地方(围栏内)。
if !strings.HasPrefix(logPath, filepath.Clean(scratch.Dir())) {
t.Errorf("日志不在 scratch 根内:%s (scratch=%s)", logPath, scratch.Dir())
}
out := bgStatus(t, `{"id":`+jsonQuote(id)+`}`)
if !strings.Contains(out, "running") {
t.Errorf("状态应是 running:%q", out)
}
// 收尾:停掉,别留 sleep 30 给测试进程。
tl := lookupTool(t, "bash_bg")
if _, err := tl.Run(context.Background(), `{"action":"stop","id":`+jsonQuote(id)+`}`); err != nil {
t.Fatal(err)
}
}
// TestBashBgStopKillsWholeTree:stop 必须带走作业派生的子进程。
// 只杀 `sh -c` 本体的话,`sleep 60 &` 会作为孤儿继续跑(bash 工具那类挂死的
// 同一根源),模型看到的"已停止"和实际的 CPU/网络占用就对不上。
func TestBashBgStopKillsWholeTree(t *testing.T) {
resetBG(t)
if runtime.GOOS == "windows" {
t.Skip("依赖 POSIX 进程组语义")
}
t.Setenv("HOME", t.TempDir())
// 后台孙子 + 前台本体;把孙子的 pid 写进日志,供事后验尸。
id, logPath := bgStart(t, "sleep 60 & echo $! ; sleep 60")
// 先等孙子真的起来(日志里出现 pid)。先 stop 再找 pid 的话,stop 可能早于
// echo 执行,日志永远是空的,测试就什么都没验证。
deadline := time.Now().Add(5 * time.Second)
pid := 0
for time.Now().Before(deadline) && pid == 0 {
if data, err := os.ReadFile(logPath); err == nil {
if n, ok := firstPID(string(data)); ok {
pid = n
}
}
time.Sleep(20 * time.Millisecond)
}
if pid == 0 {
t.Fatal("日志里读不到孙子 pid,无法验证进程树")
}
if err := syscall.Kill(pid, 0); err != nil {
t.Fatalf("孙子进程 %d 本该在跑:%v", pid, err)
}
tl := lookupTool(t, "bash_bg")
if _, err := tl.Run(context.Background(), `{"action":"stop","id":`+jsonQuote(id)+`}`); err != nil {
t.Fatal(err)
}
// stop 之后连孙子一起没:只杀 sh 本体的话它就变成孤儿。
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if syscall.Kill(pid, 0) != nil {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Errorf("孙子进程 %d 在 stop 之后仍在运行:没带走进程组", pid)
}
// TestBashBgCapturesOutputAndReportsExit:作业结束要报退出原因,
// 输出要能取回(含中文,验证尾部截断不切字符)。
func TestBashBgCapturesOutputAndReportsExit(t *testing.T) {
resetBG(t)
t.Setenv("HOME", t.TempDir())
id, _ := bgStart(t, "printf '第一段\\n'; sleep 0.2; printf '中文输出行\\n'")
deadline := time.Now().Add(15 * time.Second)
var out string
for time.Now().Before(deadline) {
out = bgStatus(t, `{"id":`+jsonQuote(id)+`,"tail":4000}`)
if strings.Contains(out, "exited") {
break
}
time.Sleep(50 * time.Millisecond)
}
if !strings.Contains(out, "exited (exit 0)") {
t.Errorf("应报退出:\n%s", out)
}
for _, want := range []string{"第一段", "中文输出行"} {
if !strings.Contains(out, want) {
t.Errorf("输出尾部缺 %q:\n%s", want, out)
}
}
}
// TestBgTailSnapsToRuneBoundary:尾部起点落在汉字中间时必须往后推到首字节,
// 否则送给模型的是半个字(进 JSON 就是替换字符)。
func TestBgTailSnapsToRuneBoundary(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "job.log")
body := strings.Repeat("中文测试行", 2000) // 每个汉字 3 字节
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
j := &bgJob{LogPath: path}
// 逐个 tail 长度扫一遍:任何一种切法都必须产出合法 UTF-8。
for n := 1; n < 40; n++ {
got := j.tail(n)
if !utf8.ValidString(got) {
t.Fatalf("tail=%d 切出非法 UTF-8:%q", n, got)
}
}
if !strings.HasPrefix(j.tail(10), "…") {
t.Error("被截断的尾部应带省略号,提示模型前面还有内容")
}
}
// TestBgTailHandlesBigLog:后台作业最常见的正是下载/构建,进度条能刷出几十 MB。
// 取尾部只许读末尾那一段(实现走 ReadAt,见 bashbg.go)。
func TestBgTailHandlesBigLog(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "big.log")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
line := strings.Repeat("x", 1024) + "\n"
for i := 0; i < 20000; i++ { // ~20MB
if _, err := f.WriteString(line); err != nil {
t.Fatal(err)
}
}
if _, err := f.WriteString("最后一行\n"); err != nil {
t.Fatal(err)
}
_ = f.Close()
j := &bgJob{LogPath: path}
start := time.Now()
got := j.tail(bgDefaultTail)
if el := time.Since(start); el > time.Second {
t.Errorf("取尾部花了 %v,疑似把整个日志读进内存", el)
}
if !strings.Contains(got, "最后一行") {
t.Errorf("尾部没带上最后一行:%q", got)
}
}
// TestBashBgListAndUnknownID:不传 id 是列表;查不存在的 id 要自解释。
func TestBashBgListAndUnknownID(t *testing.T) {
resetBG(t)
t.Setenv("HOME", t.TempDir())
out := bgStatus(t, `{}`)
if !strings.Contains(out, "no background jobs") {
t.Errorf("空表提示:%q", out)
}
t.Cleanup(func() { resetBG(t) })
id, _ := bgStart(t, "sleep 5")
out = bgStatus(t, `{}`)
if !strings.Contains(out, id) || !strings.Contains(out, "job(s)") {
t.Errorf("列表里应有该作业:%q", out)
}
out = bgStatus(t, `{"id":"bg-deadbeef"}`)
if !strings.Contains(out, "no such job") {
t.Errorf("未知 id 应说明:\n%s", out)
}
// 收尾
tl := lookupTool(t, "bash_bg")
_, _ = tl.Run(context.Background(), `{"action":"stop","id":`+jsonQuote(id)+`}`)
}
// TestBackgroundToolsRegistered:工具必须在内置表里,且语义标注正确——
// 标注错了,轮询输出就会每次都弹确认(后台的意义当场归零)。
func TestBackgroundToolsRegistered(t *testing.T) {
reg := NewRegistry()
for _, tl := range Builtin() {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
start, ok := reg.Get("bash_bg")
if !ok {
t.Fatal("bash_bg 没注册")
}
if !start.SideEffect || start.Kind != KindExec {
t.Errorf("bash_bg 必须是副作用 + 执行类:%+v", start)
}
status, ok := reg.Get("bg_status")
if !ok {
t.Fatal("bg_status 没注册")
}
if !status.ReadOnly || !status.ConcurrencySafe || status.SideEffect {
t.Errorf("bg_status 必须是只读可并发(轮询不该要审批):%+v", status)
}
}
// firstPID 从日志里取第一个数字串当 pid。
func firstPID(s string) (int, bool) {
for _, f := range strings.Fields(s) {
n := 0
for _, r := range f {
if r < '0' || r > '9' {
goto next
}
n = n*10 + int(r-'0')
}
if n > 0 {
return n, true
}
next:
}
return 0, false
}