Files
HomeAgent/internal/plugins/seq/exec_test.go
JianFeeeee 7532af7e9b feat(seq): 执行引擎 —— 组内并行 + 具名槽 + 条件求值(插件线 P2)
三个不变量(各有判据钉住):

1. **组内并行、组间串行**。parallel=true 时各工具并发执行。

2. ★ **合并按声明顺序**,不按完成顺序。
   并行下完成顺序不确定;若按完成顺序合并,同样的输入产出不同的结果,
   整条序列**不可复现**。做法:各工具把结果写进 `results[i]`(按索引),
   组屏障处按 tools 数组顺序一次性合并。
   顺序合并顺带解决了并发写 map —— **执行期完全不写共享 map**。

3. ★ **条件求值失败必须报错**,不得降级成"条件为假"。
   把求值失败当作跳过 = 序列安静地少做一步,而模型以为跑完了
   —— 与「静默吞工具」同族(那正是 P1 判据里刚堵上的同类问题)。

条件求值(设计文档 §5 的 L1+L2,不引表达式引擎):
· true/false、$args.key 裸引用(真值)
· == / != / > / < / >= / <= 、contains
· 字面量支持 "str" / 'str' / true / false / 数字 / 裸文本
· 布尔与字符串宽松比较(true == "true"),对齐 utils.getBool 的既有约定

变量插值两种形态(缺一不可):
· **整值引用** "$args.count" ⇒ 替换为**原始值并保留类型**
  (数字仍是数字;否则模型收到字符串 "3")
· **文本内插值** "ssh $args.host" ⇒ 在字符串内替换
· 标量渲染:对象/数组用**紧凑 JSON**,绝不用 fmt.Sprintf("%v")
  (那会产出 `map[k:v]` 这种模型读不懂的 Go 语法)

on_error:abort(默认)/ continue。失败时也留槽(记错误文本)——
否则后续组读到的是"缺失",而"缺失"与"值为空"在下游难以区分。

判据(exec_test.go,8 条):
· 具名槽写入正确
· ★ 结果按声明顺序合并(用 delay 让完成顺序**确实**打乱)
· array 槽同名 as 按声明顺序确定性追加
· 条件为假 ⇒ 整组跳过、槽**不赋值**、零工具被执行
· ★ 条件畸形(空键 / 引用未声明入参 / 语法不完整)⇒ 报错且**不执行任何工具**
· 条件为真 ⇒ 正常执行
· on_error 的 abort / continue 两种语义
· 插值:文本内替换 + 整值引用保留类型

过程中三次自伤:
1. ★ **toolRunner 接口第一版写成 call(name)**,不收 args ⇒ 插值判据成了
   摆设(永远"通过")。改为 call(name, args) 后插值才真正可观察。
2. toolRunner / compactJSON 定义在了 _test.go 里,exec.go 引用不到 ⇒
   build 失败。toolRunner 是**引擎的依赖契约**,必须在非测试文件。
3. fixture 里给 "slow" 配了不存在的返回值,误以为它该返回 "B" ——
   是我没配就断言,不是实现错。

变异验证:把合并改为"按完成顺序 append"⇒ array 槽顺序判据 FAIL,
报错直指 `[C A ran:slow]` vs 期望 `[A ran:slow C]`。
(第一版变异用了一个 no-op 的 sort.SliceStable,等于什么都没测,
 已改成真正模拟完成序的实现。)
`-race` 全绿;回归 internal/agent/... internal/plugins/... 全绿。

顺带修正设计文档:两处 tools 示例原写成 `{tool:cmd_run,...}`,
**不是合法 JSON**(P1 判据实测会解析失败)。已改为合法 JSON 并加注
「键要带引号,这是实现时判据跑出来的真实缺陷,不是假想」。
2026-09-27 12:29:56 +08:00

342 lines
10 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
}
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) 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)