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。
340 lines
10 KiB
Go
340 lines
10 KiB
Go
// 后台任务的 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
|
||
}
|