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。
This commit is contained in:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
@@ -0,0 +1,540 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user