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) } }