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

219 lines
8.0 KiB
Go
Raw 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"
"git.gopher.ink/mirrors2/AutoZero/llm"
"git.gopher.ink/mirrors2/AutoZero/session"
"git.gopher.ink/mirrors2/AutoZero/tools"
)
// 中断时保留半截回复的测试。scriptedProvider 不发 delta(它直接返回整条消息),
// 所以这里要一个会"先流几个字再断"的 provider——真实事故就是这个形状。
type cuttingProvider struct {
text string // 断之前流出的正文
reasoning string
err error
calls int
}
func (p *cuttingProvider) Stream(_ context.Context, _ string, _ []llm.Message, _ []llm.ToolDef,
_ llm.StreamOptions, onDelta func(llm.StreamDelta)) (llm.Message, error) {
p.calls++
if p.reasoning != "" && onDelta != nil {
onDelta(llm.StreamDelta{Reasoning: p.reasoning})
}
if p.text != "" && onDelta != nil {
onDelta(llm.StreamDelta{Text: p.text})
}
// provider 出错时返回零值 Message:半截内容不在返回值里(这正是它丢字的根因)。
return llm.Message{}, p.err
}
func cutAgent(t *testing.T, prov llm.Provider) (*Agent, *session.Session) {
t.Helper()
a, sess := newTestAgent(t)
a.Provider = prov
a.Tools = tools.NewRegistry()
a.Opt.Stdout = io.Discard
a.Opt.Stderr = io.Discard
return a, sess
}
// TestInterruptKeepsStreamedText:屏幕上都打出字了,会话里却一个字都没有——
// 刷新/重进就消失,模型下一轮也不知道自己说到哪。
func TestInterruptKeepsStreamedText(t *testing.T) {
noWait(t)
prov := &cuttingProvider{text: "已经分析到第三步", err: errors.New("openai: 流被掐断")}
a, sess := cutAgent(t, prov)
err := a.Run(context.Background(), "分析这个仓库")
if err == nil {
t.Fatal("这一轮该以失败结束")
}
var as []llm.Message
for _, m := range sess.Messages {
if m.Role == llm.RoleAssistant {
as = append(as, m)
}
}
if len(as) != 1 {
t.Fatalf("assistant 消息 %d 条,want 1:%+v", len(as), as)
}
if as[0].Content != "已经分析到第三步" {
t.Errorf("半截内容没原样保留:%q", as[0].Content)
}
// 半截就是半截,得让消费方看得出来:webui 的重试判定靠它区分"这条没写完"和
// "这一轮已经答完",漏了标记就会把可续跑的回合判成"没有可重试的消息"。
if !as[0].Interrupted {
t.Error("没打 interrupted 标记")
}
// Step 必须盖上(否则 trace 里看不出这是第几步);耗时用度不苛求正数——
// 假 provider 是瞬时返回的,真跑起来才有毫秒。
if as[0].Step != 1 || as[0].DurationMs < 0 || as[0].StartedAt == 0 {
t.Errorf("trace 没盖上:%+v", as[0])
}
// 回合账仍然记的是失败/停止,不是"说完了"。
if got := sess.TurnEnds[len(sess.TurnEnds)-1].Reason; got == "completed" {
t.Error("失败被记成了 completed")
}
}
// TestInterruptKeepsReasoning:思考文本也一样保留(前端展开的"思考过程"不该空掉)。
func TestInterruptKeepsReasoning(t *testing.T) {
noWait(t)
prov := &cuttingProvider{reasoning: "先看看入口在哪", err: errors.New("stream stalled")}
a, sess := cutAgent(t, prov)
_ = a.Run(context.Background(), "讲")
if len(sess.Messages) < 2 || sess.Messages[len(sess.Messages)-1].Reasoning != "先看看入口在哪" {
t.Fatalf("思考文本没保留:%+v", sess.Messages)
}
}
// TestInterruptNothingStreamed:连接一开就断时**不能**留一条空 assistant 消息——
// 它会被"没有 tool_calls ⇒ 说完了"读成一次正常的空回答,正是我们在治的那个病。
func TestInterruptNothingStreamed(t *testing.T) {
noWait(t)
prov := &cuttingProvider{err: errors.New("connection reset by peer")}
a, sess := cutAgent(t, prov)
if err := a.Run(context.Background(), "讲"); err == nil {
t.Fatal("该失败")
}
for _, m := range sess.Messages {
if m.Role == llm.RoleAssistant {
t.Fatalf("什么都没流出来,却落了一条 assistant:%+v", m)
}
}
}
// TestInterruptNeverPersistsPartialToolCalls:半截的工具参数是没写完的 JSON。
// 落了就会有一条永远配不上对的 tool_use,Anthropic 会拒绝整条会话(见
// synthMissingToolResults 的注释)。这里断言它压根不会被写进会话。
func TestInterruptNeverPersistsPartialToolCalls(t *testing.T) {
noWait(t)
prov := &cuttingProvider{text: `我想调用 write,参数 {"path":"a.go",`, err: errors.New("aborted")}
a, sess := cutAgent(t, prov)
_ = a.Run(context.Background(), "写个文件")
last := sess.Messages[len(sess.Messages)-1]
if len(last.ToolCalls) != 0 {
t.Errorf("半截 tool_calls 被落盘了:%+v", last.ToolCalls)
}
if !strings.Contains(last.Content, "write") {
t.Error("正文没按原样保留")
}
}
// TestCancelKeepsPartialIsIdempotentAcrossRetries:空回复重试环里每次 call() 都会
// 清空累积器,落盘的只能是**最后一次**尝试流出的内容,不能把两轮的字拼在一起。
func TestCancelKeepsPartialIsIdempotentAcrossRetries(t *testing.T) {
noWait(t)
prov := &retryThenCutProvider{}
a, sess := cutAgent(t, prov)
_ = a.Run(context.Background(), "讲")
var texts []string
for _, m := range sess.Messages {
if m.Role == llm.RoleAssistant {
texts = append(texts, m.Content)
}
}
if len(texts) != 1 || texts[0] != "第二次才流出的字" {
t.Errorf("落盘内容不对(重试拼接或丢失):%+v", texts)
}
}
// TestAbandonStreamFiresBeforeRetry:被丢弃的那次尝试流出的字,宿主需要知道——
// 它已经打到屏幕上了,重跑一次就是两遍同样的话。通知只给**正文**的字节数
// (思考走另一条通道,服务端没有它的字节账)。
func TestAbandonStreamFiresBeforeRetry(t *testing.T) {
noWait(t)
type abandon struct {
reason string
bytes int
}
var got []abandon
prov := &cuttingProvider{text: "第一遍说出的话", err: llm.ErrTruncatedStream}
a, sess := cutAgent(t, prov)
a.Opt.OnAbandon = func(reason string, n int) { got = append(got, abandon{reason, n}) }
if err := a.Run(context.Background(), "讲"); err == nil {
t.Fatal("这一轮该以失败结束")
}
// 预算现在按时间算(不再是一个固定次数),所以这里只钉"每次重试都作废一次,
// 而且不止两次"——旧的 2 次上限正是"网关抖一下就中断整轮"的根源。
if len(got) < 3 {
t.Fatalf("作废通知只有 %d 次,重试预算没有生效:%+v", len(got), got)
}
for i, ab := range got {
if ab.bytes != len("第一遍说出的话") {
t.Errorf("第 %d 次通知带的字节数 = %d, want %d", i+1, ab.bytes, len("第一遍说出的话"))
}
if ab.reason == "" {
t.Errorf("第 %d 次通知没带原因", i+1)
}
}
// 会话里始终只有一份:作废只影响屏幕,落盘走的是最后一次尝试攒下的内容。
var kept int
for _, m := range sess.Messages {
if m.Role == llm.RoleAssistant {
kept++
if strings.Count(m.Content, "第一遍说出的话") != 1 {
t.Errorf("半截内容被拼成了多份:%q", m.Content)
}
}
}
if kept != 1 {
t.Fatalf("assistant 消息 %d 条,want 1", kept)
}
}
// TestAbandonStreamSilentWhenNothingStreamed:什么都没流出来时不发空通知——
// 宿主白擦一次屏幕,什么都没换回来。
func TestAbandonStreamSilentWhenNothingStreamed(t *testing.T) {
noWait(t)
called := 0
a, _ := cutAgent(t, &cuttingProvider{err: errors.New("connection reset by peer")})
a.Opt.OnAbandon = func(string, int) { called++ }
_ = a.Run(context.Background(), "讲")
if called != 0 {
t.Fatalf("什么都没流出,却通知了 %d 次", called)
}
}
// retryThenCutProvider:第一次报空补全(触发重试环),第二次流出几个字后断掉。
type retryThenCutProvider struct{ n int }
func (p *retryThenCutProvider) Stream(_ context.Context, _ string, _ []llm.Message, _ []llm.ToolDef,
_ llm.StreamOptions, onDelta func(llm.StreamDelta)) (llm.Message, error) {
p.n++
if p.n == 1 {
return llm.Message{}, llm.ErrEmptyCompletion
}
if onDelta != nil {
onDelta(llm.StreamDelta{Text: "第二次才流出的字"})
}
return llm.Message{}, errors.New("stream cut")
}