package agent import ( "context" "fmt" "os" "path/filepath" "strings" "testing" "time" "git.gopher.ink/mirrors2/AutoZero/llm" "git.gopher.ink/mirrors2/AutoZero/tools" ) // TestMain 做两件全包级别的兜底: // 1. 把 HOME 指向临时目录——这些用例走完整的 agent.Run,而 Run 的收尾会往 // ~/.autogo/retro 追加复盘记录。不钉住的话,测试数据就混进用户真实的进化账里 // (AskAuto 的分布正是靠那条账看的)。 // 2. 把模型调用的重试预算压到毫秒级——产品默认是"一次调用最多重试 10 分钟", // 任何一条"provider 始终失败"的用例都会老老实实等满它(第一次跑就把包挂到超时)。 // 要验预算本身的行为,用例自己设 a.StreamRetryBudget 或 noWait(t) 之外的量。 func TestMain(m *testing.M) { home, err := os.MkdirTemp("", "autogo-agent-test-home") if err != nil { panic(err) } _ = os.Setenv("HOME", home) f0, c0, b0 := streamRetryFirst, streamRetryCeil, streamRetryBudget streamRetryFirst, streamRetryCeil, streamRetryBudget = time.Millisecond, 2*time.Millisecond, 50*time.Millisecond code := m.Run() streamRetryFirst, streamRetryCeil, streamRetryBudget = f0, c0, b0 _ = os.RemoveAll(home) os.Exit(code) } // 两题各两个选项、都带推荐项——ask_user 的合法形状。 const askValidJSON = `{"questions":[` + `{"header":"画质","question":"下载用哪种画质?","options":[` + `{"label":"原画","description":"约 4GB/集"},{"label":"1080p","description":"约 2GB/集"}],"recommended":1},` + `{"header":"并发","question":"开几路并发?","options":[` + `{"label":"4"},{"label":"16"}],"recommended":0}]}` func askTester(t *testing.T, mutate func(*Agent)) (*Agent, *tools.Tool) { t.Helper() a, _ := newTestAgent(t) a.AskGraceSeconds = 30 a.AskTimeoutSeconds = 120 a.AskAutoContinue = true if mutate != nil { mutate(a) } tl := a.AskUserTool() if err := a.Tools.Register(tl); err != nil { t.Fatal(err) } return a, tl } func askCall(t *testing.T, tl *tools.Tool, input string) string { t.Helper() res, err := tl.Run(context.Background(), input) if err != nil { t.Fatalf("ask_user 调用失败:%v", err) } return res.Text } // assertToolPairing 检查每个 tool_use 都紧跟着同 ID 的 tool_result(Anthropic 的 // 硬约束:缺一个配对,这条会话此后每次请求都 400,问什么都不回)。 func assertToolPairing(t *testing.T, msgs []llm.Message) { t.Helper() answered := map[string]bool{} for _, m := range msgs { if m.Role == llm.RoleTool { answered[m.ToolCallID] = true } } for _, m := range msgs { for _, tc := range m.ToolCalls { if !answered[tc.ID] { t.Errorf("调用 %s(%s) 没有得到 tool_result 配对", tc.ID, tc.Name) } } } } // TestValidateAskShape:题目数、选项数、空标签、重复标签、推荐项越界都要当场拒掉。 // 报错是**给模型看**的(它会改参数重发一次),所以每条都要说清楚怎么改。 func TestValidateAskShape(t *testing.T) { opt := func(n int) []AskOption { var out []AskOption for i := 0; i < n; i++ { out = append(out, AskOption{Label: fmt.Sprintf("选项%d", i+1)}) } return out } q := func(header string, opts []AskOption, rec int) AskQuestion { return AskQuestion{Header: header, Question: "怎么办?", Options: opts, Recommended: rec} } cases := []struct { name string qs []AskQuestion want string // 空串表示应当通过 }{ {"没有问题", nil, "needs 1-4 questions, got 0"}, {"五道题", []AskQuestion{q("a", opt(2), 0), q("b", opt(2), 0), q("c", opt(2), 0), q("d", opt(2), 0), q("e", opt(2), 0)}, "needs 1-4 questions, got 5"}, {"只有一个选项", []AskQuestion{q("a", opt(1), 0)}, "needs 2-4 options, got 1"}, {"五个选项", []AskQuestion{q("a", opt(5), 0)}, "needs 2-4 options, got 5"}, {"空标签", []AskQuestion{{Header: "a", Question: "怎么办?", Options: []AskOption{{Label: ""}, {Label: "b"}}, Recommended: 0}}, "empty label"}, {"重复标签", []AskQuestion{{Header: "a", Question: "怎么办?", Options: []AskOption{{Label: "b"}, {Label: " b "}}, Recommended: 0}}, "duplicate option"}, {"空 header", []AskQuestion{q(" ", opt(2), 0)}, "empty header"}, {"没有问题文本", []AskQuestion{{Header: "a", Options: opt(2), Recommended: 0}}, "has empty text"}, {"推荐项越界", []AskQuestion{q("a", opt(2), 2)}, "valid range is -1"}, {"推荐项 -2", []AskQuestion{q("a", opt(2), -2)}, "valid range is -1"}, {"没有推荐项(-1)合法", []AskQuestion{q("a", opt(2), -1)}, ""}, {"合法形状", []AskQuestion{q("a", opt(2), 1), q("b", opt(3), 0)}, ""}, } for _, c := range cases { _, err := validateAsk(c.qs) switch { case c.want == "" && err != nil: t.Errorf("%s:应当通过,却报 %v", c.name, err) case c.want != "" && err == nil: t.Errorf("%s:应当被拒(含 %q),却通过了", c.name, c.want) case c.want != "" && !strings.Contains(err.Error(), c.want): t.Errorf("%s:报错 %q 没说明 %q", c.name, err.Error(), c.want) } } // header 超长被截而不是被拒:这是给人看的标签,为它丢掉一次提问不值。 req, err := validateAsk([]AskQuestion{q("这是一个很长很长很长很长的标签", opt(2), 0)}) if err != nil { t.Fatal(err) } if n := len([]rune(req.Questions[0].Header)); n != maxAskHeaderRunes { t.Errorf("header 截断后 %d 字,want %d", n, maxAskHeaderRunes) } } // TestAskUnattendedAdoptsRecommendation:Opt.Ask==nil + AskDegradeUnattended // (headless / 定时任务)→ 自动采纳推荐项,并且**必须在结果里讲明白这是自动选的**: // 模型会把它写进回复,用户事后看到"按 1080p 下了,可改"比看到任务原地卡住有用。 func TestAskUnattendedAdoptsRecommendation(t *testing.T) { a, tl := askTester(t, func(x *Agent) { x.AskDegradation = AskDegradeUnattended }) text := askCall(t, tl, askValidJSON) for _, want := range []string{"no one to ask", "1080p", "4"} { if !strings.Contains(strings.ToLower(text), want) { t.Errorf("结果没提到 %q:\n%s", want, text) } } if asked, auto := a.askCounts(); asked != 1 || auto != 1 { t.Errorf("提问账 asked/auto = %d/%d,want 1/1", asked, auto) } // 无人值守不该把问题留成"本轮结尾"——那等于让任务原地停下。 if got := a.takePendingAsk(); got != "" { t.Errorf("unattended 却设了收尾问题:%q", got) } // auto_continue=false 管的是"交互式界面超时后要不要替人决定",无人值守不受它 // 影响:没人会回答,收尾等于任务停摆。 b, _ := newTestAgent(t) b.AskDegradation = AskDegradeUnattended b.AskAutoContinue = false if text := renderAskAnswer(askReqValid(t), b.requestAsk(askReqValid(t))); !strings.Contains(text, "1080p") { t.Errorf("无人值守却因为没有 auto_continue 而没有采纳推荐项:\n%s", text) } } func askReqValid(t *testing.T) AskRequest { t.Helper() req, err := validateAsk([]AskQuestion{ {Header: "画质", Question: "下载用哪种画质?", Options: []AskOption{{Label: "原画"}, {Label: "1080p"}}, Recommended: 1}, }) if err != nil { t.Fatal(err) } return req } // TestAskUnattendedWithoutRecommendation:模型显式说"没有好的默认项"时, // 不能随便挑一个充数——只能让模型自己判断并说明假设。 func TestAskUnattendedWithoutRecommendation(t *testing.T) { a, _ := newTestAgent(t) a.AskDegradation = AskDegradeUnattended req, err := validateAsk([]AskQuestion{ {Header: "架构", Question: "用哪个方案?", Options: []AskOption{{Label: "A"}, {Label: "B"}}, Recommended: -1}, }) if err != nil { t.Fatal(err) } text := renderAskAnswer(req, a.settleAsk(req, a.degradeAsk(req))) if !strings.Contains(text, "no option was marked as recommended") { t.Errorf("该说清没有推荐项:%s", text) } if !strings.Contains(text, "(no choice)") { t.Errorf("该题应显示没选:%s", text) } if _, auto := a.askCounts(); auto != 1 { t.Errorf("没被记成「没人回答却继续了」:%d", auto) } } // TestAskDefaultDegradesToDeferred:默认(零值)退化方式是 deferred 而不是 unattended。 // 这条决定了"忘了接线的入口"会不会替用户做决定——不会,它只会把问题问出来。 func TestAskDefaultDegradesToDeferred(t *testing.T) { a, _ := newTestAgent(t) // AskDegradation 零值 tl := a.AskUserTool() res, err := tl.Run(context.Background(), askValidJSON) if err != nil { t.Fatal(err) } if !strings.Contains(res.Text, "delivered to the user as this turn's reply") { t.Errorf("默认该走 deferred:%s", res.Text) } if text := a.takePendingAsk(); !strings.Contains(text, "需要你定一下") || !strings.Contains(text, "1) 原画") { t.Errorf("收尾问题没渲染:%q", text) } } // TestAskDisabledAfterTwoDegradedAsks:连续两次没人可答之后停用。 // 护栏必须在代码里:无人值守的长任务里一个爱提问的模型能连着问十几次, // 每次自动采纳——那和没有这个工具一样贵,还多算了十几次往返。 func TestAskDisabledAfterTwoDegradedAsks(t *testing.T) { a, _ := newTestAgent(t) a.AskDegradation = AskDegradeUnattended tl := a.AskUserTool() if _, err := tl.Run(context.Background(), askValidJSON); err != nil { t.Fatal(err) } if _, err := tl.Run(context.Background(), askValidJSON); err != nil { t.Fatal(err) } if !a.askBlocked() { t.Fatal("连续两次没人可答之后就该停用(第三次是浪费)") } res, err := tl.Run(context.Background(), askValidJSON) if err != nil { t.Fatal(err) } if res.Text != askBlockedNotice { t.Errorf("第三次该收到停用说明,得到:%s", res.Text) } if asked, _ := a.askCounts(); asked != 2 { t.Errorf("停用后还在提问:asked=%d,want 2", asked) } // 新回合(下一次 Run)恢复提问能力:护栏管的是"这一次任务",不是永久。 a.resetAskTurn() if a.askBlocked() { t.Error("resetAskTurn 之后仍被停用") } } // TestAskTimedOutAutoContinues:宿主报回"到点了",agent 补推荐项(而不是让每个 // 界面各自实现一遍到点怎么办)。AskAutoContinue=false 时改为 deferred。 func TestAskTimedOutAutoContinues(t *testing.T) { a, _ := newTestAgent(t) a.AskAutoContinue = true a.AskTimeoutSeconds = 120 a.Opt.Ask = func(AskRequest) AskAnswer { return AskAnswer{Mode: TimedOut} } req, err := validateAsk([]AskQuestion{ {Header: "画质", Question: "用哪种?", Options: []AskOption{{Label: "原画"}, {Label: "1080p"}}, Recommended: 1}, }) if err != nil { t.Fatal(err) } // 渲染用的是 prepareAsk 返回的那一份:时钟必须在里面,否则"等了多久"写不出来。 req = a.prepareAsk(req) text := renderAskAnswer(req, a.requestAsk(req)) if !strings.Contains(text, "No answer within 120s") || !strings.Contains(text, "1080p") { t.Errorf("超时该自动采纳推荐项并说明等了多久:%s", text) } if _, auto := a.askCounts(); auto != 1 { t.Errorf("自动采纳没记账:%d", auto) } b, _ := newTestAgent(t) b.AskAutoContinue = false b.Opt.Ask = func(AskRequest) AskAnswer { return AskAnswer{Mode: TimedOut} } if got := b.requestAsk(req).Mode; got != Deferred { t.Errorf("auto_continue=false 时超时该收尾等人,得到 %q", got) } if text := b.takePendingAsk(); !strings.Contains(text, "需要你定一下") { t.Errorf("收尾问题没渲染:%q", text) } } // TestAskDeclinedResetsStreak:declined 来自一个在场的人(他选择不答), // 所以它不该累积成"这个环境没人可问",也不该替他决定任何事。 func TestAskDeclinedResetsStreak(t *testing.T) { mode := TimedOut a, _ := newTestAgent(t) a.AskAutoContinue = true a.AskTimeoutSeconds = 120 a.Opt.Ask = func(AskRequest) AskAnswer { return AskAnswer{Mode: mode} } req, err := validateAsk([]AskQuestion{ {Header: "画质", Question: "用哪种?", Options: []AskOption{{Label: "原画"}, {Label: "1080p"}}, Recommended: 1}, }) if err != nil { t.Fatal(err) } a.requestAsk(req) // 1 次超时:退化计数 1 mode = Declined text := renderAskAnswer(req, a.requestAsk(req)) if !strings.Contains(text, "closed the question") { t.Errorf("declined 的文案不对:%s", text) } // declined 绝不自动采纳推荐项——人就在屏幕上,替他决定是越权。 if strings.Contains(text, "1080p") { t.Errorf("declined 却把推荐项当成答案:%s", text) } if a.askBlocked() { t.Fatal("declined 之后仍被算作无人应答") } mode = TimedOut if got := a.requestAsk(req).Mode; got != TimedOut { t.Errorf("连击清零后仍不能问:得到 %q", got) } } // TestAskHostAnswered:宿主答了就是答了,自由文本与备注原样回给模型。 func TestAskHostAnswered(t *testing.T) { a, _ := newTestAgent(t) a.Opt.Ask = func(AskRequest) AskAnswer { return AskAnswer{ Mode: Answered, Selected: [][]string{{"自己下"}, {"16"}}, Notes: []string{"原画太大了,先试试别的"}, } } req, err := validateAsk([]AskQuestion{ {Header: "画质", Question: "用哪种?", Options: []AskOption{{Label: "原画"}, {Label: "1080p"}}, Recommended: 1}, {Header: "并发", Question: "几路?", Options: []AskOption{{Label: "4"}, {Label: "16"}}, Recommended: 0}, }) if err != nil { t.Fatal(err) } text := renderAskAnswer(req, a.requestAsk(req)) for _, want := range []string{"The user answered:", "自己下", "16", "原画太大了", "[并发]"} { if !strings.Contains(text, want) { t.Errorf("答案里缺 %q:\n%s", want, text) } } if a.askBlocked() { t.Error("真有人回答却被停用") } } // TestAskSkipsApprovalGate:审批策略 always 会问遍所有工具,包括 ask_user 自己 // ——那就成了「为了问一个问题,先要求批准问这个问题」。KindAsk 必须在门之前短路。 func TestAskSkipsApprovalGate(t *testing.T) { a, _ := newTestAgent(t) a.ApprovalPolicy = "always" a.Mode = ModeAsk a.AskDegradation = AskDegradeUnattended approvalCalls := 0 a.Opt.Approval = func(ApprovalRequest) ApprovalVerdict { approvalCalls++ return ApprovalDeny } if err := a.Tools.Register(a.AskUserTool()); err != nil { t.Fatal(err) } res, err := a.execOne(context.Background(), llm.ToolCall{ ID: "c1", Name: "ask_user", Input: askValidJSON, }) if err != nil { t.Fatalf("ask_user 被门控拦下了:%v", err) } if approvalCalls != 0 { t.Errorf("问了审批门 %d 次,want 0", approvalCalls) } if !strings.Contains(res.Text, "1080p") { t.Errorf("结果不对:%s", res.Text) } } // TestAskDeferredEndsTurn:退化成 deferred 时,问题必须**真的**成为本轮结尾。 // 不能只靠 tool_result 里写"请转达给用户"——模型未必听话,更常见的是它自己 // 猜个方向继续跑,而那正是这个功能要省掉的浪费。 func TestAskDeferredEndsTurn(t *testing.T) { a, _ := newTestAgent(t) a.AskTimeoutSeconds = 60 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: "ask1", Name: "ask_user", Input: askValidJSON}, }}, nil } return llm.Message{Role: llm.RoleAssistant, Content: "我不该被调用"}, nil }} a.Provider = prov if err := a.Tools.Register(a.AskUserTool()); err != nil { t.Fatal(err) } if err := a.Run(context.Background(), "把剧集下下来"); err != nil { t.Fatalf("deferred 收尾是正常结束,不是错误:%v", err) } if prov.calls != 1 { t.Errorf("模型被调了 %d 次,want 1(问题一发出就该结束回合)", prov.calls) } msgs := a.Session.Messages last := msgs[len(msgs)-1] if last.Role != llm.RoleAssistant || !strings.Contains(last.Content, "需要你定一下") { t.Fatalf("最后一条应是那段问题,得到 %s/%q", last.Role, last.Content) } if len(last.ToolCalls) != 0 { t.Error("合成的收尾消息不能再带 tool_use——配对就断了") } // tool_use / tool_result 必须逐条配对(Anthropic 硬要求,缺一则整条会话每次请求 400)。 assertToolPairing(t, msgs) } // TestAskAnsweredDoesNotEndTurn:有人答了(unattended 也算答了)就必须继续往下跑, // 否则"提问"变成了"终止",长任务再也做不完。 func TestAskAnsweredDoesNotEndTurn(t *testing.T) { a, _ := newTestAgent(t) a.AskDegradation = AskDegradeUnattended prov := &scriptedProvider{fn: func(call int, _ string, _ []llm.Message, _ []llm.ToolDef) (llm.Message, error) { switch call { case 1: return llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{ {ID: "ask1", Name: "ask_user", Input: askValidJSON}, }}, nil default: return llm.Message{Role: llm.RoleAssistant, Content: "按 1080p 下完了"}, nil } }} a.Provider = prov if err := a.Tools.Register(a.AskUserTool()); err != nil { t.Fatal(err) } if err := a.Run(context.Background(), "把剧集下下来"); err != nil { t.Fatal(err) } if prov.calls != 2 { t.Errorf("模型被调了 %d 次,want 2(答完继续干活)", prov.calls) } if got := a.Session.Messages[len(a.Session.Messages)-1].Content; got != "按 1080p 下完了" { t.Errorf("最后一条 = %q", got) } } // TestAskClockPhases:宽限期不显秒 → 可见倒计时 → 到点;用户一动就永久停止。 func TestAskClockPhases(t *testing.T) { c := NewAskClock(30*time.Second, 120*time.Second) for _, at := range []struct { after time.Duration phase AskPhase }{ {1 * time.Second, AskPhaseHidden}, {31 * time.Second, AskPhaseCountdown}, {119 * time.Second, AskPhaseCountdown}, {121 * time.Second, AskPhaseExpired}, } { if got := c.Phase(c.started.Add(at.after)); got != at.phase { t.Errorf("+%v 阶段 = %s,want %s", at.after, got, at.phase) } } c.Pause() c.Pause() // 幂等:第一次有效操作之后就再也不自动结束 if got := c.Phase(c.started.Add(time.Hour)); got != AskPhaseWaiting { t.Errorf("暂停后阶段 = %s,want waiting", got) } if left := c.Remaining(c.started.Add(time.Second)); left != 0 { t.Errorf("暂停后仍报剩余 %v", left) } // timeout<=0 = 永不自动结束:任何时刻都是 waiting(宿主不该显示倒计时)。 n := NewAskClock(0, 0) if got := n.Phase(time.Now().Add(24 * time.Hour)); got != AskPhaseWaiting { t.Errorf("永不超时却进入阶段 %s", got) } } // TestSubAgentHasNoAskTool:子 agent 没有到人的路由。那张表里若留着父级绑定的 // ask_user 闭包,子 agent 会经父级通道去问人、还把父级的回合收尾标志设上。 // ZCode 能做是因为它专门实现了"路由回父会话",那是另一期工程。 func TestSubAgentHasNoAskTool(t *testing.T) { a, _ := newTestAgent(t) a.MaxDepth = 1 a.Opt.ApproveAll = true var childHasAsk, topHasAsk bool prov := &scriptedProvider{fn: func(call int, _ string, _ []llm.Message, defs []llm.ToolDef) (llm.Message, error) { switch call { case 1: // 顶层:注册了 ask_user topHasAsk = hasName(defs, "ask_user") return llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{ {ID: "a1", Name: "agent", Input: `{"prompt":"子任务"}`}, }}, nil case 2: // 子 agent childHasAsk = hasName(defs, "ask_user") return llm.Message{Role: llm.RoleAssistant, Content: "CHILD-FINAL"}, nil default: return llm.Message{Role: llm.RoleAssistant, Content: "TOP-FINAL"}, nil } }} a.Provider = prov if err := a.Tools.Register(a.AskUserTool()); err != nil { t.Fatal(err) } if err := a.Tools.Register(a.SubAgentTool()); err != nil { t.Fatal(err) } if err := a.Run(context.Background(), "派个子任务"); err != nil { t.Fatal(err) } if !topHasAsk { t.Error("顶层没有 ask_user?") } if childHasAsk { t.Error("子 agent 拿到了 ask_user(会经父级通道问人并设父级收尾)") } } // TestAskRequestCarriesIdentity:宿主需要 ID/会话号来配对与路由,还需要时钟来显示 // 倒计时——这三样都必须由 agent 填好,否则每个宿主自己拼,早晚拼出两种行为。 func TestAskRequestCarriesIdentity(t *testing.T) { a, _ := newTestAgent(t) a.Session.Path = filepath.Join(t.TempDir(), "sess-42.jsonl") a.AskGraceSeconds = 5 a.AskTimeoutSeconds = 45 var got AskRequest a.Opt.Ask = func(req AskRequest) AskAnswer { got = req return AskAnswer{Mode: Declined} } req, err := validateAsk([]AskQuestion{{Header: "h", Question: "q?", Options: []AskOption{{Label: "a"}, {Label: "b"}}, Recommended: 0}}) if err != nil { t.Fatal(err) } a.requestAsk(req) if got.ID == "" { t.Error("没有询问 ID:宿主无法把答案配回来") } if got.SessionID != "sess-42" { t.Errorf("会话号 = %q,want sess-42", got.SessionID) } if got.Clock.Grace() != 5*time.Second || got.Clock.Timeout() != 45*time.Second { t.Errorf("时钟 = %v/%v,want 5s/45s", got.Clock.Grace(), got.Clock.Timeout()) } }