Files
AutoZero/agent/abandoned_test.go
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

226 lines
8.3 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 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: "<summary>- 发现 nginx 里 upstream 端口写错</summary>"}
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: "<summary>- 端口写错</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("压缩路径上取背景说明把自己锁死了")
}
}