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) } }