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。
This commit is contained in:
Sakurasan committed 2026-10-26 00:54:46 +08:00
commit d5dac5a6fc
140 files changed
+26497

No files matched your search

+528
View File
@@ -0,0 +1,528 @@
package session
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.gopher.ink/mirrors2/AutoZero/llm"
)
// TestMain 把整个包的 HOME 钉在临时目录里。
//
// 这不是形式主义:New() 会往 ~/.autogo/sessions 真的建文件,而漏写一行
// t.Setenv("HOME", …) 的测试就会把垃圾塞进用户自己的会话目录(本项目真发生过)。
// 单个用例的隔离仍然保留(有的用例要换 HOME),但兜底不该依赖每个人都记得。
func TestMain(m *testing.M) {
home, err := os.MkdirTemp("", "autogo-session-test-home")
if err != nil {
panic(err)
}
_ = os.Setenv("HOME", home)
code := m.Run()
_ = os.RemoveAll(home)
os.Exit(code)
}
// TestRoundTripWithImages:含图片的消息 JSONL 持久化后完整恢复。
func TestRoundTripWithImages(t *testing.T) {
t.Setenv("HOME", t.TempDir())
s, err := New()
if err != nil {
t.Fatal(err)
}
msgs := []llm.Message{
{Role: llm.RoleUser, Content: "take a screenshot"},
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "computer", Input: `{"action":"screenshot"}`}}},
{Role: llm.RoleTool, ToolCallID: "c1", Content: "screenshot saved",
Images: []llm.Image{{Path: "/tmp/autogo-shot-1.png"}}},
}
for _, m := range msgs {
if err := s.Append(m); err != nil {
t.Fatal(err)
}
}
got, err := Load(s.Path)
if err != nil {
t.Fatal(err)
}
if len(got.Messages) != 3 {
t.Fatalf("want 3 messages, got %d", len(got.Messages))
}
last := got.Messages[2]
if len(last.Images) != 1 || last.Images[0].Path != "/tmp/autogo-shot-1.png" {
t.Fatalf("images not persisted: %+v", last.Images)
}
if filepath.Base(s.Path) == "" {
t.Fatal("session path empty")
}
}
// TestRoundTripTrace:trace 字段(时间戳/耗时/步骤序号)随 JSONL 完整往返。
func TestRoundTripTrace(t *testing.T) {
t.Setenv("HOME", t.TempDir())
s, err := New()
if err != nil {
t.Fatal(err)
}
in := llm.Message{
Role: llm.RoleAssistant, Content: "done",
StartedAt: 1700000000000, DurationMs: 1234, Step: 5,
}
if err := s.Append(in); err != nil {
t.Fatal(err)
}
got, err := Load(s.Path)
if err != nil {
t.Fatal(err)
}
if len(got.Messages) != 1 {
t.Fatalf("want 1 message, got %d", len(got.Messages))
}
last := got.Messages[0]
if last.StartedAt != in.StartedAt || last.DurationMs != in.DurationMs || last.Step != in.Step {
t.Fatalf("trace not persisted: %+v", last)
}
}
// TestLoadLegacyLineWithoutTrace:旧版 JSONL(无 trace 字段)必须能读,零值且不报错。
func TestLoadLegacyLineWithoutTrace(t *testing.T) {
t.Setenv("HOME", t.TempDir())
s, err := New()
if err != nil {
t.Fatal(err)
}
legacy := `{"role":"assistant","content":"old","tool_calls":[{"id":"c1","name":"read","input":"{}"}]}` + "\n"
if err := os.WriteFile(s.Path, []byte(legacy), 0o644); err != nil {
t.Fatal(err)
}
got, err := Load(s.Path)
if err != nil {
t.Fatalf("legacy line must load without error: %v", err)
}
if len(got.Messages) != 1 {
t.Fatalf("want 1 message, got %d", len(got.Messages))
}
m := got.Messages[0]
if m.Content != "old" || len(m.ToolCalls) != 1 {
t.Fatalf("legacy content lost: %+v", m)
}
if m.StartedAt != 0 || m.DurationMs != 0 || m.Step != 0 {
t.Fatalf("legacy message should have zero trace, got %+v", m)
}
}
// TestLoadToleratesHugeLine:**回归**——单行超过 bufio.Scanner 默认 64KB 上限时,
// 整份会话仍然要能读出来。
//
// 事故现场:20260916 的一个会话里有一条 assistant 消息带 204KB 的 reasoning,
// 于是 Load 返回 "bufio.Scanner: token too long",列表把读不出来的会话直接跳过,
// 整个会话在 WebUI 里消失、历史也打不开。
func TestLoadToleratesHugeLine(t *testing.T) {
t.Setenv("HOME", t.TempDir())
dir := Dir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "big.jsonl")
huge := strings.Repeat("r", 300<<10) // 300KB 的推理文本
lines := []llm.Message{
{Role: llm.RoleUser, Content: "开始"},
{Role: llm.RoleAssistant, Reasoning: huge, Content: "想完了"},
{Role: llm.RoleAssistant, Content: "结束"},
}
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
enc := json.NewEncoder(f)
for _, m := range lines {
if err := enc.Encode(m); err != nil {
t.Fatal(err)
}
}
_ = f.Close()
// 先确认这份文件确实会踩到 Scanner 的默认上限(否则这个测试是假的)。
raw, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
sc := bufio.NewScanner(raw)
for sc.Scan() {
}
if sc.Err() == nil {
t.Skip("构造的数据没超过 Scanner 上限,测不出问题")
}
_ = raw.Close()
sess, err := Load(path)
if err != nil {
t.Fatalf("超长行不该让整份会话读不出来: %v", err)
}
if len(sess.Messages) != 3 {
t.Fatalf("消息数 = %d, want 3", len(sess.Messages))
}
if len(sess.Messages[1].Reasoning) != len(huge) {
t.Errorf("超长字段被截断了: %d", len(sess.Messages[1].Reasoning))
}
}
// TestRetireToLeavesMemoryIntactWhenWriteFails:游标写不进盘时,内存里的活跃视图
// 一条都不能少——否则就是"界面已经短了、盘上还是全长"的分裂状态,用户下一次
// 发消息会基于一个不存在的历史继续。
func TestRetireToLeavesMemoryIntactWhenWriteFails(t *testing.T) {
dir := t.TempDir()
s := &Session{Path: filepath.Join(dir, "s.jsonl")}
for i := 0; i < 4; i++ {
s.Messages = append(s.Messages, llm.Message{Role: llm.RoleUser, Content: "x"})
}
if err := s.Rewrite(); err != nil {
t.Fatal(err)
}
// 注意是文件本身只读,不是目录:追加一条游标只需要文件写权限,只读目录拦不住它
// (这本身是好事——回退在目录被锁的情况下仍然能落盘)。
if err := os.Chmod(s.Path, 0o444); err != nil {
t.Skipf("造不出只读文件:%v", err)
}
t.Cleanup(func() { _ = os.Chmod(s.Path, 0o644) })
if err := s.RetireTo(2); err == nil {
t.Fatal("文件只读,追加游标该失败")
}
if len(s.Messages) != 4 || s.Retired != 0 {
t.Fatalf("失败后内存视图 %d 条 / 退役 %d,want 4/0", len(s.Messages), s.Retired)
}
}
// TestRewriteIsAtomicReplace:Rewrite 走 tmp+rename,成功后不留临时文件,
// 文件行数与内存条数一致(会话文件是唯一事实源)。
func TestRewriteIsAtomicReplace(t *testing.T) {
s := &Session{Path: filepath.Join(t.TempDir(), "s.jsonl")}
s.Messages = []llm.Message{
{Role: llm.RoleUser, Content: "一"},
{Role: llm.RoleAssistant, Content: "二"},
}
if err := s.Rewrite(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(s.Path + ".tmp"); !os.IsNotExist(err) {
t.Fatalf("临时文件没被 rename 收掉:%v", err)
}
got, err := Load(s.Path)
if err != nil {
t.Fatal(err)
}
if len(got.Messages) != 2 || got.Messages[1].Content != "二" {
t.Fatalf("重写后的会话 = %+v", got.Messages)
}
}
// TestTailFromPrompt:切点与尾部副本。只读——归档必须在退役之前发生。
func TestTailFromPrompt(t *testing.T) {
t.Setenv("HOME", t.TempDir())
s, err := New()
if err != nil {
t.Fatal(err)
}
s.Messages = []llm.Message{
{Role: llm.RoleUser, Content: "第一个"},
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "t1", Name: "bash"}}},
{Role: llm.RoleTool, ToolCallID: "t1", Content: "ok"},
{Role: llm.RoleUser, Content: "第二个"},
{Role: llm.RoleAssistant, Content: "答完"},
}
before := len(s.Messages)
idx, dropped := s.TailFromPrompt(2)
if idx != 3 || len(dropped) != 2 {
t.Fatalf("idx=%d dropped=%d,want 3/2", idx, len(dropped))
}
if len(s.Messages) != before {
t.Fatalf("TailFromPrompt 不该改动会话,现在 %d 条", len(s.Messages))
}
if _, d := s.TailFromPrompt(9); d != nil {
t.Fatal("越界的号返回了尾部")
}
}
// TestRetireToKeepsHistory:回退只追加一条游标——活跃视图短了,文件一行没少。
// 这是整套设计的核心断言:**被回退掉的内容仍然在盘上**。
func TestRetireToKeepsHistory(t *testing.T) {
t.Setenv("HOME", t.TempDir())
s, err := New()
if err != nil {
t.Fatal(err)
}
for i := 1; i <= 4; i++ {
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: string(rune('a' + i))}); err != nil {
t.Fatal(err)
}
}
if err := s.RetireTo(2); err != nil {
t.Fatal(err)
}
if len(s.Messages) != 2 || s.Retired != 2 {
t.Fatalf("内存视图 %d 条 / 退役 %d 条,want 2/2", len(s.Messages), s.Retired)
}
got, err := Load(s.Path)
if err != nil {
t.Fatal(err)
}
if len(got.Messages) != 2 {
t.Fatalf("重读后活跃视图 = %d 条,want 2", len(got.Messages))
}
if got.Retired != 2 {
t.Fatalf("重读后退役计数 = %d,want 2", got.Retired)
}
// 文件里还得数得出 4 条消息 + 1 条游标:回退没删任何东西。
lines := rawLines(t, s.Path)
if len(lines) != 5 {
t.Fatalf("文件行数 = %d,want 5(4 条消息 + 1 条游标)", len(lines))
}
}
// TestRetireThenAppend:回退之后接着说话,新消息排在**缩短后的**视图上,
// 而不是被文件里那些退役消息的位置带跑。
func TestRetireThenAppend(t *testing.T) {
t.Setenv("HOME", t.TempDir()) // 不隔离就会往用户真实的 ~/.autogo/sessions 里写垃圾
s, err := New()
if err != nil {
t.Fatal(err)
}
for _, c := range []string{"u1", "a1", "u2", "a2"} {
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: c}); err != nil {
t.Fatal(err)
}
}
if err := s.RetireTo(2); err != nil {
t.Fatal(err)
}
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "u3"}); err != nil {
t.Fatal(err)
}
got, err := Load(s.Path)
if err != nil {
t.Fatal(err)
}
var view []string
for _, m := range got.Messages {
view = append(view, m.Content)
}
if strings.Join(view, ",") != "u1,a1,u3" {
t.Fatalf("活跃视图 = %v,want [u1 a1 u3]", view)
}
if got.Retired != 2 {
t.Fatalf("退役计数 = %d, want 2", got.Retired)
}
}
// TestHalfWrittenCursorIsInert:崩溃留在文件里的半行游标必须**什么都不做**。
// 选这个失败方向是有道理的:最坏结果是这次回退没生效(用户再按一次),
// 而不是历史丢了。
func TestHalfWrittenCursorIsInert(t *testing.T) {
t.Setenv("HOME", t.TempDir())
s, err := New()
if err != nil {
t.Fatal(err)
}
for _, c := range []string{"u1", "a1", "u2"} {
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: c}); err != nil {
t.Fatal(err)
}
}
f, err := os.OpenFile(s.Path, os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
t.Fatal(err)
}
// 写到一半就断:没有换行、JSON 不完整。
_, _ = f.WriteString(`{"kind":"autogo.curso`)
_ = f.Close()
got, err := Load(s.Path)
if err != nil {
t.Fatalf("半行不该让整份会话读不出来:%v", err)
}
if len(got.Messages) != 3 || got.Retired != 0 {
t.Fatalf("活跃视图 = %d 条 / 退役 %d,want 3/0", len(got.Messages), got.Retired)
}
}
// TestLoadSkipsRolelessJunk:没有 role 的行不是消息。旧实现会把任意 JSON 对象
// 收成一条空消息,于是崩溃残留/手工编辑留下的垃圾行会变成"发给 API 的非法消息"。
func TestLoadSkipsRolelessJunk(t *testing.T) {
path := filepath.Join(t.TempDir(), "junk.jsonl")
body := `{"role":"user","content":"真消息"}
{"whatever":1}
not json at all
{"role":"assistant","content":"第二条"}
{"kind":"autogo.cursor","keep":1}
`
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
got, err := Load(path)
if err != nil {
t.Fatal(err)
}
if len(got.Messages) != 1 || got.Messages[0].Content != "真消息" {
var view []string
for _, m := range got.Messages {
view = append(view, m.Content)
}
t.Fatalf("活跃视图 = %v,want 只剩「真消息」", view)
}
if got.Retired != 1 {
t.Fatalf("退役计数 = %d, want 1", got.Retired)
}
}
// rawLines 数文件里的非空行(绕开 Load 的视图语义)。
func rawLines(t *testing.T, path string) []string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var out []string
for _, l := range strings.Split(string(b), "\n") {
if strings.TrimSpace(l) != "" {
out = append(out, l)
}
}
return out
}
// TestRewriteKeepsRetiredHistory:Rewrite 不能顺手抹掉回退留下的历史。
//
// 这不是假想的坑:collectToolImages 给图片回填缓存文件名之后就会 Rewrite 整个
// 会话文件。如果那次重写只写活跃视图,一次图片缓存就把刚做的回退历史删干净了。
func TestRewriteKeepsRetiredHistory(t *testing.T) {
t.Setenv("HOME", t.TempDir())
s, err := New()
if err != nil {
t.Fatal(err)
}
for _, c := range []string{"m1", "m2", "m3", "m4"} {
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: c}); err != nil {
t.Fatal(err)
}
}
if err := s.RetireTo(2); err != nil { // 回退:m3/m4 退役
t.Fatal(err)
}
// 模拟"更新已有消息的字段后整体重写"
s.Messages[0].Content = "m1-改过"
if err := s.Rewrite(); err != nil {
t.Fatal(err)
}
got, err := Load(s.Path)
if err != nil {
t.Fatal(err)
}
if len(got.Messages) != 2 || got.Messages[0].Content != "m1-改过" {
t.Fatalf("活跃视图 = %+v", got.Messages)
}
if got.Retired != 2 {
t.Fatalf("退役计数 = %d, want 2(历史必须在)", got.Retired)
}
if n := len(rawLines(t, s.Path)); n != 5 {
t.Fatalf("文件行数 = %d, want 5(2 退役 + 游标 + 2 活跃)", n)
}
}
// TestResetTrulyClears:/clear 走 Reset,退役历史与回合账一起清掉——
// 否则"清空"只清视图,文件反而越清越肥。
func TestResetTrulyClears(t *testing.T) {
t.Setenv("HOME", t.TempDir())
s, err := New()
if err != nil {
t.Fatal(err)
}
for _, c := range []string{"a", "b", "c"} {
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: c}); err != nil {
t.Fatal(err)
}
}
if err := s.RetireTo(1); err != nil {
t.Fatal(err)
}
s.AppendTurnEnd(TurnEnd{Reason: "completed", Step: 3, Ms: 12})
s.Reset()
if err := s.Rewrite(); err != nil {
t.Fatal(err)
}
if n := len(rawLines(t, s.Path)); n != 0 {
t.Fatalf("清空后文件还有 %d 行", n)
}
got, err := Load(s.Path)
if err != nil {
t.Fatal(err)
}
if len(got.Messages) != 0 || got.Retired != 0 || len(got.TurnEnds) != 0 {
t.Fatalf("清空不彻底:%d 条 / 退役 %d / 回合账 %d", len(got.Messages), got.Retired, len(got.TurnEnds))
}
}
// TestTurnEndRoundTrip:回合结束原因必须能在会话文件里读到。
// 只导出 JSONL 做取证时,"为什么停"不能靠猜。
func TestTurnEndRoundTrip(t *testing.T) {
t.Setenv("HOME", t.TempDir())
s, err := New()
if err != nil {
t.Fatal(err)
}
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "干活"}); err != nil {
t.Fatal(err)
}
s.AppendTurnEnd(TurnEnd{Reason: "max_turns", Step: 120, Ms: 987654})
s.AppendTurnEnd(TurnEnd{Reason: "failed", Error: "openai: 模型回复被截断", Step: 33, Ms: 62061})
got, err := Load(s.Path)
if err != nil {
t.Fatal(err)
}
if len(got.TurnEnds) != 2 {
t.Fatalf("回合账 = %+v, want 2 条", got.TurnEnds)
}
if got.TurnEnds[0].Reason != "max_turns" || got.TurnEnds[0].Step != 120 || got.TurnEnds[0].Ms != 987654 {
t.Fatalf("第一条不对:%+v", got.TurnEnds[0])
}
if !strings.Contains(got.TurnEnds[1].Error, "截断") {
t.Fatalf("错误原因没留下:%+v", got.TurnEnds[1])
}
if got.TurnEnds[0].At == 0 {
t.Fatal("回合账没带时刻")
}
// 回合账不是消息:不能混进上下文。
if len(got.Messages) != 1 {
t.Fatalf("上下文里混进了非消息行:%d 条", len(got.Messages))
}
}
// TestSyntheticPromptNotCounted:后台通知不是人工提问——CountPrompts 与
// PromptBoundary 都不应把它算进去,否则 rewind 的号会整体偏移。
func TestSyntheticPromptNotCounted(t *testing.T) {
msgs := []llm.Message{
{Role: llm.RoleUser, Content: "first"},
{Role: llm.RoleUser, Content: "[background] done", Synthetic: true},
{Role: llm.RoleUser, Content: "second"},
}
if got := CountPrompts(msgs); got != 2 {
t.Fatalf("CountPrompts = %d, want 2", got)
}
if idx := PromptBoundary(msgs, 1); idx != 0 {
t.Fatalf("boundary 1 = %d, want 0", idx)
}
if idx := PromptBoundary(msgs, 2); idx != 2 {
t.Fatalf("boundary 2 = %d, want 2", idx)
}
if idx := PromptBoundary(msgs, 3); idx != -1 {
t.Fatalf("boundary 3 = %d, want -1 (synthetic is not prompt 3)", idx)
}
}