package agent import ( "context" "fmt" "strings" "sync" "testing" "git.gopher.ink/mirrors2/AutoZero/llm" "git.gopher.ink/mirrors2/AutoZero/tools" ) // 重复调用检测的测试。要护住的语义:"同名同参"才算重复,且成功空转也要能发现 // ([STUCK] 只数失败,那一半已有测试覆盖)。 func TestToolSignatureNormalizesJSON(t *testing.T) { // 键序与空白不同、内容相同 → 同一个签名(模型两次生成同一调用经常这样)。 a := toolSignature("bash", `{"command":"ls -la","cwd":"/tmp"}`) b := toolSignature("bash", "{ \"cwd\": \"/tmp\",\n \"command\": \"ls -la\" }") if a != b { t.Errorf("同参不同写法被判成两次调用:\n%q\n%q", a, b) } if a == toolSignature("bash", `{"command":"ls -l"}`) { t.Error("换了参数却算同一次调用") } if a == toolSignature("read", `{"command":"ls -la","cwd":"/tmp"}`) { t.Error("换了工具名却算同一次调用") } // 非法 JSON 退回原文比对:宁可漏判,不能把两次不同调用误判成重复。 if toolSignature("x", "not-json") == toolSignature("x", "other") { t.Error("非 JSON 入参被归一成同一个签名") } } func TestNoteToolRepeatFiresOncePerThresholdAndCaps(t *testing.T) { a, _ := newTestAgent(t) for i := 0; i < 2; i++ { if n := a.noteToolRepeat("read", `{"path":"a.go"}`); n != 0 { t.Fatalf("第 %d 次同名调用不该提醒", i+1) } } if n := a.noteToolRepeat("read", `{"path":"a.go"}`); n != 3 { t.Errorf("第 3 次应提醒(得到 %d)", n) } if n := a.noteToolRepeat("read", `{"path":"a.go"}`); n != 4 { t.Errorf("第 4 次应继续提醒(得到 %d)", n) } // 每回合提醒总量封顶:模型无视两次就不会突然开始听,第三条起只是占 token。 // 上面 read 已经用掉 2 条(第 3、4 次各一条),所以 grep 的第 3 次还能提醒一次, // 之后就再也发不出了。 if n := a.noteToolRepeat("grep", `{"pattern":"x"}`); n != 0 { t.Errorf("grep 第 1 次就被提醒:%d", n) } a.noteToolRepeat("grep", `{"pattern":"x"}`) if n := a.noteToolRepeat("grep", `{"pattern":"x"}`); n != 3 { t.Errorf("第 3 条提醒(额度内)没发出:%d", n) } a.noteToolRepeat("ls", `{}`) a.noteToolRepeat("ls", `{}`) if n := a.noteToolRepeat("ls", `{}`); n != 0 { t.Errorf("提醒数已达上限还在注入:%d", n) } a.resetAnomalyCounters() if n := a.noteToolRepeat("read", `{"path":"a.go"}`); n != 0 { t.Errorf("清零后第一次调用就被提醒:%d", n) } } // TestRepeatNoteInToolResult 是端到端的一半:模型真的连着三次调同一个工具时, // 第三次的**工具结果**里要带上那句话(提醒挂在结果上,不另塞一条假用户消息)。 func TestRepeatNoteInToolResult(t *testing.T) { a, sess := newTestAgent(t) calls := 0 reg := tools.NewRegistry() if err := reg.Register(&tools.Tool{ Name: "peek", Parameters: map[string]any{"type": "object"}, Run: func(context.Context, string) (tools.Result, error) { calls++ return tools.TextResult("同一份内容"), nil }, }); err != nil { t.Fatal(err) } a.Tools = reg prov := &scriptedProvider{fn: func(call int, _ string, _ []llm.Message, _ []llm.ToolDef) (llm.Message, error) { if call <= 3 { return llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{ {ID: fmt.Sprintf("c%d", call), Name: "peek", Input: `{"path":"a.go"}`}, }}, nil } return llm.Message{Role: llm.RoleAssistant, Content: "收尾"}, nil }} a.Provider = prov if err := a.Run(context.Background(), "看看"); err != nil { t.Fatal(err) } var notes int for _, m := range sess.Messages { if m.Role == llm.RoleTool { if strings.Contains(m.Content, "[REPEAT]") { notes++ if !strings.Contains(m.Content, "同一份内容") { t.Error("提醒把工具原本的输出挤掉了") } } } } if notes != 1 { t.Errorf("[REPEAT] 出现 %d 次,want 1(第 3 次调用时一次)", notes) } } // TestRepeatNoteSurvivesConcurrency 是 -race 护栏:并发批次里多个 goroutine 同时 // 记签名,计数与提示不能被数据竞争吃掉(failStreak 当年就是为此加的锁)。 func TestRepeatNoteSurvivesConcurrency(t *testing.T) { a, _ := newTestAgent(t) var wg sync.WaitGroup for i := 0; i < 20; i++ { wg.Add(1) go func() { defer wg.Done() a.noteToolRepeat("read", `{"path":"x"}`) }() } wg.Wait() if got := a.repeatCounts["read\x00{\"path\":\"x\"}"]; got != 20 { t.Errorf("并发计数丢了:%d,want 20", got) } if a.anomalyNotes > anomalyNoteMax { t.Errorf("提醒条数越过上限:%d > %d", a.anomalyNotes, anomalyNoteMax) } }