Files
AutoZero/agent/sideeffect_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

165 lines
6.0 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"
"testing"
"git.gopher.ink/mirrors2/AutoZero/llm"
"git.gopher.ink/mirrors2/AutoZero/tools"
)
// TestToolEventMarksSideEffects:副作用标记要随工具事件传给宿主,前端才能在
// 回合失败时禁用自动重试。
func TestToolEventMarksSideEffects(t *testing.T) {
prov := &scriptedProvider{fn: func(call int, _ string, _ []llm.Message, _ []llm.ToolDef) (llm.Message, error) {
if call == 1 {
return llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{
{ID: "r1", Name: "rotool", Input: "{}"},
{ID: "s1", Name: "sidetool", Input: "{}"},
}}, nil
}
return llm.Message{Role: llm.RoleAssistant, Content: "done"}, nil
}}
ro := &tools.Tool{Name: "rotool", ReadOnly: true, ConcurrencySafe: true, Run: func(context.Context, string) (tools.Result, error) {
return tools.TextResult("r"), nil
}}
side := &tools.Tool{Name: "sidetool", SideEffect: true, Kind: tools.KindExec, Run: func(context.Context, string) (tools.Result, error) {
return tools.TextResult("s"), nil
}}
a, _ := newTestAgent(t, ro, side)
a.Provider = prov
started := map[string]bool{}
a.Opt.OnTool = func(ev ToolEvent) {
if !ev.Done {
started[ev.Name] = ev.SideEffect
}
}
if err := a.Run(context.Background(), "go"); err != nil {
t.Fatal(err)
}
if !started["sidetool"] {
t.Error("side-effect tool must be marked in ToolEvent")
}
if started["rotool"] {
t.Error("read-only tool must not be marked side-effect")
}
}
// TestSideEffectsDisableWholeTurnRetry:副作用已经执行后,回合失败要带
// ErrAfterSideEffects,宿主据此只给手动重试;工具本身只跑一次。
func TestSideEffectsDisableWholeTurnRetry(t *testing.T) {
runs := 0
calls := 0
prov := &scriptedProvider{fn: func(call int, _ string, _ []llm.Message, _ []llm.ToolDef) (llm.Message, error) {
calls++
if calls == 1 {
return llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{
{ID: "s1", Name: "sidetool", Input: "{}"},
}}, nil
}
return llm.Message{}, errors.New("boom")
}}
side := &tools.Tool{Name: "sidetool", SideEffect: true, Kind: tools.KindExec, Run: func(context.Context, string) (tools.Result, error) {
runs++
return tools.TextResult("done"), nil
}}
a, _ := newTestAgent(t, side)
a.Provider = prov
err := a.Run(context.Background(), "go")
if !errors.Is(err, ErrAfterSideEffects) {
t.Fatalf("err = %v, want ErrAfterSideEffects", err)
}
if runs != 1 {
t.Fatalf("side effect ran %d times, want 1", runs)
}
}
// TestSideEffectResultIsDurableBeforeNextTool:同一批串行工具里,前一个副作用
// 工具的结果必须在后一个开始前就进会话——崩溃/重试时才能知道它已经跑过。
func TestSideEffectResultIsDurableBeforeNextTool(t *testing.T) {
var sessHasPrior bool
var sessionMessages func() []llm.Message
prov := &scriptedProvider{fn: func(call int, _ string, _ []llm.Message, _ []llm.ToolDef) (llm.Message, error) {
if call == 1 {
return llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{
{ID: "t1", Name: "first", Input: "{}"},
{ID: "t2", Name: "second", Input: "{}"},
}}, nil
}
return llm.Message{Role: llm.RoleAssistant, Content: "done"}, nil
}}
first := &tools.Tool{Name: "first", SideEffect: true, Kind: tools.KindExec, Run: func(context.Context, string) (tools.Result, error) {
return tools.TextResult("first-result"), nil
}}
second := &tools.Tool{Name: "second", SideEffect: true, Kind: tools.KindExec, Run: func(context.Context, string) (tools.Result, error) {
for _, m := range sessionMessages() {
if m.Role == llm.RoleTool && m.ToolCallID == "t1" {
sessHasPrior = true
}
}
return tools.TextResult("second-result"), nil
}}
a, s := newTestAgent(t, first, second)
a.Provider = prov
sessionMessages = func() []llm.Message { return s.Messages }
if err := a.Run(context.Background(), "go"); err != nil {
t.Fatal(err)
}
if !sessHasPrior {
t.Fatal("second tool ran before the first tool's result was persisted")
}
}
// TestResumeKeepsSideEffectFence:续跑一个已经执行过副作用工具的回合时,
// 失败仍然不能自动整轮重试——历史里的 tool 结果就是证据。
func TestResumeKeepsSideEffectFence(t *testing.T) {
prov := &scriptedProvider{fn: func(int, string, []llm.Message, []llm.ToolDef) (llm.Message, error) {
return llm.Message{}, errors.New("boom")
}}
side := &tools.Tool{Name: "sidetool", SideEffect: true, Kind: tools.KindExec, Run: func(context.Context, string) (tools.Result, error) {
t.Error("resume must not re-run the side-effect tool")
return tools.TextResult(""), nil
}}
a, sess := newTestAgent(t, side)
a.Provider = prov
sess.Append(llm.Message{Role: llm.RoleUser, Content: "go"})
sess.Append(llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "t1", Name: "sidetool"}}})
sess.Append(llm.Message{Role: llm.RoleTool, ToolCallID: "t1", Tool: "sidetool", Content: "ok"})
err := a.Run(context.Background(), "")
if !errors.Is(err, ErrAfterSideEffects) {
t.Fatalf("err = %v, want ErrAfterSideEffects", err)
}
}
// TestSideEffectsDoNotWrapCancellation:用户主动停止必须保持 context.Canceled,
// 不能被副作用标记包成普通错误——界面靠它区分"停止"和"出错"。
func TestSideEffectsDoNotWrapCancellation(t *testing.T) {
calls := 0
prov := &scriptedProvider{fn: func(int, string, []llm.Message, []llm.ToolDef) (llm.Message, error) {
calls++
if calls == 1 {
return llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{
{ID: "s1", Name: "sidetool", Input: "{}"},
}}, nil
}
return llm.Message{}, context.Canceled
}}
side := &tools.Tool{Name: "sidetool", SideEffect: true, Kind: tools.KindExec, Run: func(context.Context, string) (tools.Result, error) {
return tools.TextResult("done"), nil
}}
a, _ := newTestAgent(t, side)
a.Provider = prov
err := a.Run(context.Background(), "go")
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
if errors.Is(err, ErrAfterSideEffects) {
t.Fatalf("cancellation must not be wrapped as a retryable failure: %v", err)
}
}