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:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user