package agent import ( "context" "errors" "io" "strings" "testing" "time" "git.gopher.ink/mirrors2/AutoZero/llm" "git.gopher.ink/mirrors2/AutoZero/session" ) // 「从这里开始总结」:/rewind 摘掉一条分支之后,那段历史不该就此从模型视野里彻底消失。 // 它查出来的事实是重走一遍最贵的部分;但它同时也全是"未完成动作",原样留着模型就会 // 把用户刚放弃的活重新捡起来。所以压成一条**写明已放弃**的背景说明,注入 system 后缀。 // branchProvider 记下每次调用的 system,并按用例要求答复或失败。 type branchProvider struct { systems []string summary string err error } func (p *branchProvider) Stream(_ context.Context, system string, _ []llm.Message, _ []llm.ToolDef, _ llm.StreamOptions, _ func(llm.StreamDelta)) (llm.Message, error) { p.systems = append(p.systems, system) if p.err != nil { return llm.Message{}, p.err } return llm.Message{Role: llm.RoleAssistant, Content: p.summary}, nil } // branchAgent 按真实装配挂 SystemSuffix:cmd/autogo 的 buildAgent 就是这么接的, // 背景说明经这条后缀下发,不挂就等于没测。 func branchAgent(t *testing.T, p llm.Provider) *Agent { t.Helper() t.Setenv("HOME", t.TempDir()) sess, err := session.New() if err != nil { t.Fatal(err) } a := &Agent{Provider: p, Session: sess, System: "test", Opt: Options{Stdout: io.Discard, Stderr: io.Discard}} a.SystemSuffix = a.DynamicSections return a } // bigBranch 造一段"值得总结"的被摘消息(估算过 abandonedMinTokens 那条地板线)。 func bigBranch(n int) []llm.Message { msgs := make([]llm.Message, 0, n) for i := 0; i < n; i++ { if i%2 == 0 { msgs = append(msgs, llm.Message{Role: llm.RoleUser, Content: strings.Repeat("排查", 1200)}) } else { msgs = append(msgs, llm.Message{Role: llm.RoleAssistant, Content: strings.Repeat("结论", 1200)}) } } return msgs } func TestSummarizeAbandonedGoesIntoSystemSuffix(t *testing.T) { p := &branchProvider{summary: "- 发现 nginx 里 upstream 端口写错"} a := branchAgent(t, p) a.Session.Append(llm.Message{Role: llm.RoleUser, Content: "换个方向重来"}) res, err := a.SummarizeAbandoned(context.Background(), bigBranch(4)) if err != nil { t.Fatalf("该压成背景说明:%v", err) } if res.Summarized != 4 || res.TokensAfter >= res.TokensBefore { t.Errorf("账不对:%+v", res) } sys := a.sysNow() for _, want := range []string{"已被用户回退掉的分支", "不要执行其中未完成的动作", "nginx"} { if !strings.Contains(sys, want) { t.Errorf("system 后缀缺 %q:%s", want, sys) } } // transcript 一行不改:摘掉的消息只以摘要的形态回来。 if len(a.Session.Messages) != 1 { t.Errorf("会话被动了:%d 条", len(a.Session.Messages)) } // 喂给摘要那条调用的 system 必须是"放弃分支"口径,不是常规压缩那条"接着干"。 if len(p.systems) != 1 || !strings.Contains(p.systems[0], "ABANDONED") { t.Errorf("用了错的摘要指令:%v", p.systems) } } // 这条钉住踩过的坑:常规压缩的收尾话术是"根据这份摘要接着干"。背景说明一旦被卷进 // 压缩输入,"别接着干"就会被改写成待办——用户刚放弃的分支原地复活。所以它住 system, // 压多少轮都只有一份,且绝不进消息列表。 func TestAbandonedNoteNeverEntersCompaction(t *testing.T) { a := branchAgent(t, &branchProvider{summary: "- 端口写错"}) a.ContextWindow, a.CompactAt, a.MinCompactTokens = 400, 0.85, 50 for i := 0; i < 6; i++ { a.Session.Append(llm.Message{Role: llm.RoleUser, Content: strings.Repeat("当下的活", 300)}) a.Session.Append(llm.Message{Role: llm.RoleAssistant, Content: strings.Repeat("做了一半", 300)}) } if _, err := a.SummarizeAbandoned(context.Background(), bigBranch(2)); err != nil { t.Fatal(err) } for round := 0; round < 2; round++ { view, err := a.contextView(context.Background()) if err != nil { t.Fatal(err) } for j, m := range view { if strings.Contains(m.Content, "已被用户回退掉的分支") { t.Fatalf("第 %d 轮:说明混进了消息列表 view[%d]", round, j) } } } if n := strings.Count(a.sysNow(), "已被用户回退掉的分支"); n != 1 { t.Errorf("压缩之后 system 里有 %d 份说明,want 1", n) } } // 太短的分支不值得为它烧一次调用,而且一次请求都不该发出去。 func TestSummarizeAbandonedSkipsTinyBranch(t *testing.T) { p := &branchProvider{summary: "x"} a := branchAgent(t, p) _, err := a.SummarizeAbandoned(context.Background(), []llm.Message{ {Role: llm.RoleUser, Content: "就一句话"}, {Role: llm.RoleAssistant, Content: "就一个答"}}) if !errors.Is(err, ErrAbandonedTooSmall) { t.Fatalf("err = %v, want ErrAbandonedTooSmall", err) } if len(p.systems) != 0 { t.Error("太短的分支不该发摘要请求") } if n := a.AbandonedNotes(); n != 0 { t.Errorf("背景说明条数 = %d, want 0", n) } } // 反复回退时旧的先掉:不然 system 会被一串越来越长的"背景"拖死。 func TestSummarizeAbandonedCapsNotes(t *testing.T) { a := branchAgent(t, &branchProvider{summary: "s"}) for i := 0; i < 6; i++ { if _, err := a.SummarizeAbandoned(context.Background(), bigBranch(2)); err != nil { t.Fatal(err) } } if n := a.AbandonedNotes(); n != abandonedNoteCap { t.Errorf("背景说明条数 = %d, want %d", n, abandonedNoteCap) } } // 摘要失败不能把回退本身弄成失败:原因要如实交出去,也不留下半条背景。 func TestSummarizeAbandonedFailureIsHonest(t *testing.T) { boom := errors.New("gateway refused") a := branchAgent(t, &branchProvider{err: boom}) res, err := a.SummarizeAbandoned(context.Background(), bigBranch(2)) if !errors.Is(err, boom) { t.Fatalf("err = %v, want 原样交出", err) } if !strings.Contains(res.Err, "gateway refused") { t.Errorf("结果里没记下失败原因:%+v", res) } if a.AbandonedNotes() != 0 { t.Error("失败后残留了背景说明") } } // undo 把原文接回来了,"这条路已放弃"的背景说明就该撤掉。 func TestClearAbandonedNotes(t *testing.T) { a := branchAgent(t, &branchProvider{summary: "s"}) if _, err := a.SummarizeAbandoned(context.Background(), bigBranch(2)); err != nil { t.Fatal(err) } a.ClearAbandonedNotes() if a.AbandonedNotes() != 0 { t.Fatal("没清干净") } if strings.Contains(a.sysNow(), "已被用户回退掉的分支") { t.Error("清空后 system 里还带着背景说明") } } // 手动 /compact 之后说明必须还在:它讲的是另一条分支,被顺手清掉就是又一次失忆。 func TestAbandonedNoteSurvivesManualCompact(t *testing.T) { a := branchAgent(t, &branchProvider{summary: "s"}) a.ContextWindow, a.CompactAt, a.MinCompactTokens = 100000, 0.85, 100 a.Session.Append(llm.Message{Role: llm.RoleUser, Content: strings.Repeat("当下的活", 3000)}) if _, err := a.SummarizeAbandoned(context.Background(), bigBranch(2)); err != nil { t.Fatal(err) } if _, err := a.CompactNow(context.Background()); err != nil { t.Fatal(err) } if n := a.AbandonedNotes(); n != 1 { t.Fatalf("压缩把背景说明也清了:%d", n) } } // 死锁守卫:AbandonedSection 经 sysNow 注入,而 sysNow 在 contextView/CompactNow 里是 // **持着 compactMu** 跑的。说明若跟着抢 compactMu 就是自己等自己——真死过一次, // 栈是 AbandonedSection ← SummarizeAbandoned / ← estimateTokens。 func TestAbandonedSectionDoesNotDeadlockCompaction(t *testing.T) { a := branchAgent(t, &branchProvider{summary: "s"}) a.ContextWindow, a.CompactAt, a.MinCompactTokens = 400, 0.85, 50 for i := 0; i < 4; i++ { a.Session.Append(llm.Message{Role: llm.RoleUser, Content: strings.Repeat("活", 400)}) a.Session.Append(llm.Message{Role: llm.RoleAssistant, Content: strings.Repeat("做", 400)}) } if _, err := a.SummarizeAbandoned(context.Background(), bigBranch(2)); err != nil { t.Fatal(err) } done := make(chan struct{}) go func() { defer close(done) if _, err := a.contextView(context.Background()); err != nil { t.Error(err) } if _, err := a.CompactNow(context.Background()); err != nil { t.Error(err) } }() select { case <-done: case <-time.After(10 * time.Second): t.Fatal("压缩路径上取背景说明把自己锁死了") } }