package agent
import (
"context"
"errors"
"io"
"strings"
"testing"
"unicode/utf8"
"git.gopher.ink/mirrors2/AutoZero/llm"
)
// 摘要请求自身的预算。没有它,"压缩"这件事会在上下文最大的时候恰好失效:
// 要摘要的那份历史有多大,摘要请求就有多大,于是它和真实调用一起被网关挡下——
// 用户看到的是"空回复/400",实际是上下文再也降不下来了。
func manyMsgs(n int) []llm.Message {
out := make([]llm.Message, 0, n)
for i := 0; i < n; i++ {
role := llm.RoleUser
if i%3 == 1 {
role = llm.RoleAssistant
} else if i%3 == 2 {
role = llm.RoleTool
}
out = append(out, llm.Message{Role: role,
Content: strings.Repeat("中", 40) + itoa(i)})
}
return out
}
func itoa(i int) string {
if i == 0 {
return "0"
}
var b []byte
for ; i > 0; i /= 10 {
b = append([]byte{byte('0' + i%10)}, b...)
}
return string(b)
}
// TestSerializeForSummaryBudget:超预算时保头 + 保尾 + 中间留一行说明。
// 保留第一条是因为"用户最初要什么"正是交接文档最不能丢的一句;
// 留一行说明而不是悄悄少说一段,否则摘要看起来像覆盖了全程。
func TestSerializeForSummaryBudget(t *testing.T) {
msgs := manyMsgs(40)
const budget = 4000
got := serializeForSummary(msgs, 8000, budget)
if len(got) > budget {
t.Errorf("输出 %d 字节,超出预算 %d", len(got), budget)
}
if !strings.Contains(got, "0\n") || !strings.Contains(got, msgs[0].Content) {
t.Error("第一条(原始诉求)必须保留")
}
if !strings.Contains(got, msgs[len(msgs)-1].Content) {
t.Error("最近一条必须保留")
}
if !strings.Contains(got, "因预算省略") {
t.Error("被略过的中间段要留一行说明,不能静默")
}
if !utf8.ValidString(got) {
t.Error("按字节裁剪切坏了中文")
}
// 预算为 0 = 不限(旧行为),未配置窗口的入口不该被换算改变。
full := serializeForSummary(msgs, 8000, 0)
if strings.Contains(full, "因预算省略") || !strings.Contains(full, msgs[7].Content) {
t.Error("totalBudget<=0 时应完整平铺")
}
}
// TestAutocompactShrinksAfterSizeFailure:摘要请求太大被拒时,缩小预算再试一次。
// 这是单向门的自救路径:第一次发不出去,减半就该发得出去。
func TestAutocompactShrinksAfterSizeFailure(t *testing.T) {
const limit = 3000
var sizes []int
a, _ := newTestAgent(t)
a.Opt.Stderr = io.Discard
a.ContextWindow, a.CompactAt = 4000, 0.85 // 预算 = 阈值的一半 ≈ 5100 字节
a.Provider = &scriptedProvider{fn: func(_ int, _ string, msgs []llm.Message, _ []llm.ToolDef) (llm.Message, error) {
n := len(msgs[0].Content)
sizes = append(sizes, n)
if n > limit {
return llm.Message{}, errors.New("HTTP 400: request too large")
}
return llm.Message{Role: llm.RoleAssistant, Content: "ok"}, nil
}}
out, err := a.autocompact(context.Background(), manyMsgs(60))
if err != nil {
t.Fatalf("缩小预算后该成功:%v(各次输入 %v)", err, sizes)
}
if len(sizes) < 2 {
t.Fatalf("只试了一次(%v),没走缩小重试", sizes)
}
if sizes[0] <= sizes[1] {
t.Errorf("第二次的输入该更小:%d → %d", sizes[0], sizes[1])
}
if len(out) == 0 || !strings.Contains(out[0].Content, "ok") {
t.Errorf("摘要没接上:%+v", out[0])
}
}
// TestAutocompactGivesUpOncePerTurn:全都失败时,本回合只撞一次——不记这个标记,
// 一次 30 步的回合就是 30 次注定失败的摘要调用(慢、烧钱、且对用户完全静默)。
// 失败同时必须经 OnCompact 报出去(Err 非空),否则用户只会看到后面的网关错误。
func TestAutocompactGivesUpOncePerTurn(t *testing.T) {
a, sess := newTestAgent(t)
a.Opt.Stderr = io.Discard
a.ContextWindow, a.CompactAt, a.MinCompactTokens = 1000, 0.5, 1
calls := 0
a.Provider = &scriptedProvider{fn: func(_ int, _ string, _ []llm.Message, _ []llm.ToolDef) (llm.Message, error) {
calls++
return llm.Message{}, errors.New("HTTP 400: request too large")
}}
var events []CompactionResult
a.Opt.OnCompact = func(r CompactionResult) { events = append(events, r) }
for i := 0; i < 6; i++ {
sess.Append(llm.Message{Role: llm.RoleUser, Content: strings.Repeat("经", 200)})
if _, err := a.contextView(context.Background()); err != nil {
t.Fatal(err)
}
}
if calls != summaryRetries {
t.Errorf("摘要调用 %d 次,want %d(本回合放弃后不该再撞)", calls, summaryRetries)
}
if !a.compactGaveUp {
t.Error("compactGaveUp 没置上")
}
if len(events) != 1 || events[0].Err == "" {
t.Fatalf("失败要报给 UI 一次,实际 %+v", events)
}
// 下一回合(Run 开头清零)该再给一次机会。
a.compactGaveUp = false
if _, err := a.contextView(context.Background()); err != nil {
t.Fatal(err)
}
if calls != summaryRetries*2 {
t.Errorf("清零后应再试一轮:%d", calls)
}
}