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。
219 lines
8.0 KiB
Go
219 lines
8.0 KiB
Go
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")
|
||
}
|