Files
HomeAgent/internal/plugins/seq/exec_test.go
JianFeeeee b1b96b788d test(seq): 三个压力测试 —— 超长序列 / 100 工具并行 / 串行降级
与单元判据的分工:单元判据钉住**语义**(一条路径对不对);压力测试钉住
**规模下的不变量**。沿用仓内既有范式(media/soak_test.go):
testing.Short() 跳过 + 独立 -run 跑。

① 超长序列
   · 解析 10 / 100 / 1000 组(250KB 文本):6.8ms,无硬上限误报
   · 执行 200 组 × 5 工具 = 1000 次调用:2.0ms
     断言:每工具恰好被调 nGroups 次(无遗漏/重复)、结果含**最后一组**
     —— 组间串行在规模下仍成立

② 100 工具组内并行
   · 100 工具全声明并发安全 ⇒ 11ms,完成顺序**确实被打乱**(判据会校验
     这一点,否则它测不到并发)
   · 断言每个槽拿到**自己**的结果(并发下若按完成顺序合并就会错位)

③ 串行降级
   · 50 个工具里**一个**未声明并发安全 ⇒ 整批退回串行,
     完成顺序严格等于声明序(106ms vs 并发的 11ms,降级确实生效)

★ 压力测试第一次跑就抓到一个**真实分层缺陷**:
「含非并发安全工具则整批串行」这条规则**只在上层 runGroup 实现**,
而引擎层 execGroup 只信 g.Parallel 字段 ⇒ 任何人直接调 execGroup
都会拿到不受约束的并发。
已修:降级判据下沉到引擎层,新增 batchCanRun(g, runner),
toolRunner 增加 parallelSafe 方法(生产路径行为不变,只是把判据
放到了它本该在的层)。

过程中压测自身也暴露了两个测试缺陷(都修了):
· fixture 让 100 个工具写同一个标量槽 o,被静态校验正确拦下
  ("组内并行下同名写入是数据竞争")—— 压测不该去撞这条规则;
· ★ e2eRunner.called 是无锁 append,100 工具并发时 -race 报出**真竞态**
  (不是误报)—— 加锁 + 提供 calledSnapshot 供断言。

另:建序列与跑序列原本用了**不同 plugin 实例**(序列存在实例的 store 里,
换实例就读不到自己刚建的),已改为同一实例。

回归:go test -race ./internal/plugins/seq 全绿;go test ./internal/... 全绿。
2026-09-27 14:16:09 +08:00

349 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package seq
import (
"errors"
"strings"
"sync"
"testing"
"time"
)
// 阶段 P2:执行引擎(组内并行 + 具名槽 + 条件求值)。
//
// 三个核心不变量:
// 1. **组边界即屏障**:同组内工具互相不可见(并行 ⇒ 无确定写序);
// 变量只在组屏障处按 tools 顺序**确定性合并**。
// 2. **条件求值失败必须报错**,不得降级成「条件为假」——
// 那会让序列安静地少做一步而模型以为跑完了。
// 3. 结果**按 tools 数组顺序**合并,与完成顺序无关 ⇒ 可复现。
// fakeTool 记录一次工具执行,并返回可配置的文本。
type fakeTool struct {
mu sync.Mutex
calls []string
// results 按工具名给出返回文本;未配置则返回 "ran:<name>"
results map[string]string
// errs 按工具名给出错误
errs map[string]error
// delay 用于制造"完成顺序 ≠ 声明顺序"
delay map[string]int
}
func newFakeTool() *fakeTool {
return &fakeTool{results: map[string]string{}, errs: map[string]error{}, delay: map[string]int{}}
}
func (f *fakeTool) call(name string, _ map[string]interface{}) (string, error) {
if d := f.delay[name]; d > 0 {
sleepMS(d)
}
f.mu.Lock()
f.calls = append(f.calls, name)
f.mu.Unlock()
if e, ok := f.errs[name]; ok {
return "", e
}
if r, ok := f.results[name]; ok {
return r, nil
}
return "ran:" + name, nil
}
// parallelSafe:默认全部视为可并发(工具级压测通过 toolDefs 控制)。
// 本文件用 fakeTool 的用例关注的是**组内顺序/合并**不变式,
// 并发资格由 TestStress_* 单独检验。
func (f *fakeTool) parallelSafe(string) bool { return true }
func (f *fakeTool) called() []string {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]string, len(f.calls))
copy(out, f.calls)
return out
}
// ① 具名槽:工具结果按 `as` 写入对应槽。
func TestExecWritesNamedSlots(t *testing.T) {
ft := newFakeTool()
g := Group{
Name: "g",
In: map[string]string{},
Out: map[string]string{"summary": "string", "load": "string"},
When: "true",
Parallel: false,
Tools: []ToolCall{
{Tool: "uptime", As: "summary"},
{Tool: "top", As: "load"},
},
}
res, err := execGroup(g, map[string]interface{}{}, ft)
if err != nil {
t.Fatalf("execGroup: %v", err)
}
if got, _ := res.Slots["summary"].(string); got != "ran:uptime" {
t.Errorf("summary 槽 = %v", res.Slots["summary"])
}
if got, _ := res.Slots["load"].(string); got != "ran:top" {
t.Errorf("load 槽 = %v", res.Slots["load"])
}
}
// ② ★ 结果合并必须按 tools 数组顺序,与完成顺序无关。
//
// 并行下完成顺序不确定;若按完成顺序合并,同样的输入会产出不同的槽内容,
// 整条序列**不可复现**。这里让后声明的工具先完成(delay 更短)。
func TestExecMergesSlotsInDeclarationOrder(t *testing.T) {
ft := newFakeTool()
ft.delay["first"] = 30 // 先声明但后完成
ft.delay["second"] = 1
ft.results["first"] = "FIRST"
ft.results["second"] = "SECOND"
g := Group{
Name: "g", In: map[string]string{},
Out: map[string]string{"a": "array", "b": "array"},
When: "true", Parallel: true,
Tools: []ToolCall{
{Tool: "first", As: "a"},
{Tool: "second", As: "b"},
},
}
res, err := execGroup(g, map[string]interface{}{}, ft)
if err != nil {
t.Fatalf("execGroup: %v", err)
}
// 各自的槽内容正确
ra, _ := res.Slots["a"].([]interface{})
rb, _ := res.Slots["b"].([]interface{})
if len(ra) != 1 || ra[0] != "FIRST" {
t.Errorf("槽 a = %v", ra)
}
if len(rb) != 1 || rb[0] != "SECOND" {
t.Errorf("槽 b = %v", rb)
}
// 完成顺序确实被打乱(否则本用例测不到并发)
if got := ft.called(); got[0] != "first" || got[1] != "second" {
t.Logf("完成顺序 = %v(未打乱,判据可能测不到并发)", got)
}
}
// ③ array 槽的同名 as:在组屏障按 tools 顺序**确定性追加**。
func TestExecArraySlotAppendsInOrder(t *testing.T) {
ft := newFakeTool()
ft.delay["slow"] = 30
ft.results["a"] = "A"
ft.results["c"] = "C"
// "slow" 用默认返回值即可(ran:slow),它只用来制造"最后完成"
g := Group{
Name: "g", In: map[string]string{},
Out: map[string]string{"xs": "array"},
When: "true", Parallel: true,
Tools: []ToolCall{
{Tool: "a", As: "xs"},
{Tool: "slow", As: "xs"}, // 声明在中间但最后完成
{Tool: "c", As: "xs"},
},
}
res, err := execGroup(g, map[string]interface{}{}, ft)
if err != nil {
t.Fatalf("execGroup: %v", err)
}
xs, _ := res.Slots["xs"].([]interface{})
if len(xs) != 3 {
t.Fatalf("xs 应有 3 项,实际 %d(%v)", len(xs), xs)
}
// 必须按 tools 声明顺序:a, slow, c ⇒ A, B, C
// 期望按 tools 声明顺序:a → slow → c
want := []string{"A", "ran:slow", "C"}
for i, w := range want {
if xs[i] != w {
t.Errorf("xs[%d] = %v,期望 %v(完整 %v)—— 合并顺序依赖了完成顺序", i, xs[i], w, xs)
}
}
}
// ④ 条件为假 ⇒ 整组跳过,**槽不赋值**。
func TestExecSkipsGroupWhenConditionFalse(t *testing.T) {
ft := newFakeTool()
g := Group{
Name: "g", In: map[string]string{"flag": "bool"},
Out: map[string]string{"x": "string"},
When: "false", Parallel: false,
Tools: []ToolCall{{Tool: "uptime", As: "x"}},
}
res, err := execGroup(g, map[string]interface{}{"flag": false}, ft)
if err != nil {
t.Fatalf("execGroup: %v", err)
}
if !res.Skipped {
t.Error("条件为假时应标记 Skipped")
}
if len(ft.called()) != 0 {
t.Errorf("条件为假却执行了工具: %v", ft.called())
}
if _, ok := res.Slots["x"]; ok {
t.Error("条件为假时不应给槽赋值(后续组会读到不存在的值)")
}
}
// ⑤ ★ 条件**求值出错**必须报错,不得降级成「条件为假」。
//
// 这是本阶段最关键的一条:把求值失败降级为跳过 = 序列安静地少做一步,
// 而模型以为跑完了 —— 与「静默吞工具」同族。
func TestExecErrorsOnMalformedCondition(t *testing.T) {
cases := []string{
"$args.", // 空键名
"$args.missing == 1", // 引用了未声明的入参(in 里只有 flag)
"1 ==", // 语法不完整
}
for _, cond := range cases {
t.Run(cond, func(t *testing.T) {
ft := newFakeTool()
g := Group{
Name: "g", In: map[string]string{"flag": "bool"},
Out: map[string]string{"x": "string"},
When: cond, Parallel: false,
Tools: []ToolCall{{Tool: "uptime", As: "x"}},
}
_, err := execGroup(g, map[string]interface{}{"flag": false}, ft)
if err == nil {
t.Fatalf("畸形条件 %q 未被拒绝(被静默当成假了吗)", cond)
}
if !strings.Contains(err.Error(), "条件") {
t.Errorf("错误信息应提到『条件』,实际: %v", err)
}
if len(ft.called()) != 0 {
t.Errorf("条件求值失败却执行了工具: %v", ft.called())
}
})
}
}
// ⑥ 条件为真时正常执行。
func TestExecRunsWhenConditionTrue(t *testing.T) {
ft := newFakeTool()
g := Group{
Name: "g", In: map[string]string{"flag": "bool"},
Out: map[string]string{"x": "string"},
When: "$args.flag == true", Parallel: false,
Tools: []ToolCall{{Tool: "uptime", As: "x"}},
}
res, err := execGroup(g, map[string]interface{}{"flag": true}, ft)
if err != nil {
t.Fatalf("execGroup: %v", err)
}
if res.Skipped {
t.Error("条件为真却跳过了")
}
if got, _ := res.Slots["x"].(string); got != "ran:uptime" {
t.Errorf("x = %v", res.Slots["x"])
}
}
// ⑦ 工具执行失败:on_error=continue 时继续,abort 时整组失败。
func TestExecOnErrorPolicy(t *testing.T) {
boom := errors.New("boom")
t.Run("abort", func(t *testing.T) {
ft := newFakeTool()
ft.errs["bad"] = boom
g := Group{
Name: "g", In: map[string]string{},
Out: map[string]string{"a": "string", "b": "string"},
When: "true", Parallel: false, OnError: "abort",
Tools: []ToolCall{{Tool: "bad", As: "a"}, {Tool: "ok", As: "b"}},
}
if _, err := execGroup(g, map[string]interface{}{}, ft); err == nil {
t.Error("on_error=abort 时失败应使整组失败")
}
})
t.Run("continue", func(t *testing.T) {
ft := newFakeTool()
ft.errs["bad"] = boom
g := Group{
Name: "g", In: map[string]string{},
Out: map[string]string{"a": "string", "b": "string"},
When: "true", Parallel: false, OnError: "continue",
Tools: []ToolCall{{Tool: "bad", As: "a"}, {Tool: "ok", As: "b"}},
}
res, err := execGroup(g, map[string]interface{}{}, ft)
if err != nil {
t.Fatalf("on_error=continue 不应整组失败: %v", err)
}
if got, _ := res.Slots["b"].(string); got != "ran:ok" {
t.Errorf("continue 下后续工具应仍执行,b = %v", res.Slots["b"])
}
// 失败的槽也要有值(错误文本),否则后续组读到缺失
if _, ok := res.Slots["a"]; !ok {
t.Error("continue 下失败的工具也应留下槽(记错误文本)")
}
})
}
// ⑧ 变量插值:`$args.x` 被真实值替换,且**整值引用**保留类型
// (数字仍是数字,不是字符串)。
func TestExecSubstitutesArgs(t *testing.T) {
ft := newFakeTool()
g := Group{
Name: "g",
In: map[string]string{"host": "string", "count": "integer"},
Out: map[string]string{"x": "string"},
When: "true", Parallel: false,
Tools: []ToolCall{{
Tool: "cmd",
Args: map[string]interface{}{
"cmd": "ssh $args.host",
"amount": "$args.count",
"whole": "$args.host",
},
As: "x",
}},
}
// 注意:本例只验证 execTool 前的插值由 call 接口承担,
// 这里通过 fakeTool 捕获实际收到的 args。
capture := &capturingTool{inner: ft}
if _, err := execGroup(g, map[string]interface{}{"host": "node-a", "count": 3}, capture); err != nil {
t.Fatalf("execGroup: %v", err)
}
got := capture.lastArgs()
if got["cmd"] != "ssh node-a" {
t.Errorf("字符串内插值失败: %v", got["cmd"])
}
// 整值引用必须保留原始类型(数字仍是数字)
if n, ok := got["amount"].(int); !ok || n != 3 {
t.Errorf("整值引用应保留 int 类型,实际 %#v", got["amount"])
}
if got["whole"] != "node-a" {
t.Errorf("整值引用失败: %#v", got["whole"])
}
}
// capturingTool 记录最后一次收到的 args。
type capturingTool struct {
inner *fakeTool
mu sync.Mutex
args map[string]interface{}
}
func (c *capturingTool) parallelSafe(string) bool { return true }
func (c *capturingTool) call(name string, args map[string]interface{}) (string, error) {
c.mu.Lock()
c.args = args
c.mu.Unlock()
return c.inner.call(name, args)
}
func (c *capturingTool) lastArgs() map[string]interface{} {
c.mu.Lock()
defer c.mu.Unlock()
return c.args
}
// sleepMS 测试用的短延时(毫秒)。
func sleepMS(ms int) { time.Sleep(time.Duration(ms) * time.Millisecond) }
var _ toolRunner = (*fakeTool)(nil)
var _ toolRunner = (*capturingTool)(nil)