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

529 lines
16 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 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)
}
}