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。
541 lines
21 KiB
Go
541 lines
21 KiB
Go
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())
|
||
}
|
||
}
|