Details (original message): Add the optional intent gate port with the squadintent adapter, CLI wiring, and pin cascade.
141 lines
5.3 KiB
Go
141 lines
5.3 KiB
Go
package squad
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// stubGate 引擎级测试用闸门桩:squad 包测试不得 import agent-intent(结构解耦纪律)。
|
|
type stubGate struct {
|
|
dec *IntentDecision
|
|
err error
|
|
calls int
|
|
lastText string
|
|
}
|
|
|
|
func (g *stubGate) Decide(ctx context.Context, goal Goal, opts RunOptions) (*IntentDecision, error) {
|
|
g.calls++
|
|
g.lastText = goal.Text
|
|
return g.dec, g.err
|
|
}
|
|
|
|
func gateTestBlueprint(key string, match []string, stepID string) Blueprint {
|
|
return Blueprint{
|
|
Key: key, Title: key, Match: match,
|
|
Steps: []StepSpec{{ID: stepID, Role: "researcher", Title: "按模板执行", Targets: []string{"output.md"}}},
|
|
}
|
|
}
|
|
|
|
func newGateTestEngine(t *testing.T, gate IntentGate, bps ...Blueprint) *SquadEngine {
|
|
t.Helper()
|
|
engine, _ := newTestEngine(t, NewRulePlanner(),
|
|
map[string]map[string]string{"01_researcher": {"output.md": goodReport}}, nil)
|
|
for _, bp := range bps {
|
|
if err := engine.Blueprints().Register(bp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
engine.opts.IntentGate = gate // 测试直改;产品侧经 Options 组装
|
|
return engine
|
|
}
|
|
|
|
func runGoal(t *testing.T, e *SquadEngine, text, taskKey string) *RunResult {
|
|
t.Helper()
|
|
res, err := e.Run(context.Background(), Goal{Text: text, HitlMode: HitlAuto}, RunOptions{TaskKey: taskKey})
|
|
if err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
return res
|
|
}
|
|
|
|
// T0:闸门存在但恒不干预时,运行结果与无闸门引擎一致(等价回归)。
|
|
func TestIntentGateNilDecisionKeepsOriginalPath(t *testing.T) {
|
|
bp := gateTestBlueprint("kw_report", []string{"调研报告"}, "b1")
|
|
plain := newGateTestEngine(t, nil, bp)
|
|
gated := newGateTestEngine(t, &stubGate{}, bp)
|
|
for _, text := range []string{"帮我做一份调研报告", "随便写点什么"} {
|
|
a, b := runGoal(t, plain, text, ""), runGoal(t, gated, text, "")
|
|
if a.Status != b.Status {
|
|
t.Fatalf("status differs for %q: %s vs %s", text, a.Status, b.Status)
|
|
}
|
|
if (a.Plan == nil) != (b.Plan == nil) || len(a.Plan.Steps) != len(b.Plan.Steps) {
|
|
t.Fatalf("plan shape differs for %q: %+v vs %+v", text, a.Plan, b.Plan)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 意图定向:目标文本不含蓝图关键词,无闸门走自主规划;有闸门定向到模板步骤。
|
|
func TestIntentGateBlueprintRouting(t *testing.T) {
|
|
bp := gateTestBlueprint("competitor_report", []string{"竞品分析报告"}, "b_intent")
|
|
plain := newGateTestEngine(t, nil, bp)
|
|
gated := newGateTestEngine(t, &stubGate{dec: &IntentDecision{
|
|
BlueprintKey: "competitor_report", IntentID: "demo.report.competitor",
|
|
}}, bp)
|
|
text := "给我做一份竞品情况的分析报告" // 不含关键词"竞品分析报告"
|
|
|
|
plainRes := runGoal(t, plain, text, "")
|
|
if plainRes.Plan == nil || len(plainRes.Plan.Steps) == 0 || plainRes.Plan.Steps[0].ID == "b_intent" {
|
|
t.Fatalf("without gate expected autonomous plan, got %+v", plainRes.Plan)
|
|
}
|
|
|
|
gatedRes := runGoal(t, gated, text, "")
|
|
if gatedRes.Plan == nil || len(gatedRes.Plan.Steps) != 1 || gatedRes.Plan.Steps[0].ID != "b_intent" {
|
|
t.Fatalf("with gate expected blueprint plan, got %+v", gatedRes.Plan)
|
|
}
|
|
}
|
|
|
|
// 守卫拒绝:稳定码 squad.intent_refused,运行标记失败。
|
|
func TestIntentGateRejectFailsWithStableCode(t *testing.T) {
|
|
gated := newGateTestEngine(t, &stubGate{dec: &IntentDecision{
|
|
Reject: &IntentRejection{Reason: "命中守卫意图 guard.config_change", Guidance: "请到管理界面修改"},
|
|
IntentID: "guard.config_change",
|
|
}})
|
|
res := runGoal(t, gated, "帮我把系统网关地址改了", "")
|
|
if res.Status != RunFailed {
|
|
t.Fatalf("expected failed run, got %s", res.Status)
|
|
}
|
|
if !strings.Contains(res.Error, "squad.intent_refused") {
|
|
t.Fatalf("error missing stable code: %s", res.Error)
|
|
}
|
|
}
|
|
|
|
// 意图定向到不存在的蓝图:fail-closed(同未知 backend 先例)。
|
|
func TestIntentGateUnknownBlueprintFailsClosed(t *testing.T) {
|
|
gated := newGateTestEngine(t, &stubGate{dec: &IntentDecision{BlueprintKey: "missing_bp", IntentID: "demo.x"}})
|
|
res := runGoal(t, gated, "随便什么目标", "")
|
|
if res.Status != RunFailed || !strings.Contains(res.Error, "squad.blueprint_not_found") {
|
|
t.Fatalf("expected fail-closed blueprint error, got %+v", res)
|
|
}
|
|
}
|
|
|
|
// 闸门系统错误:fail-closed 成失败运行,不静默放行。
|
|
func TestIntentGateErrorFailsClosed(t *testing.T) {
|
|
gated := newGateTestEngine(t, &stubGate{err: errf("recognizer down")})
|
|
res := runGoal(t, gated, "随便什么目标", "")
|
|
if res.Status != RunFailed || !strings.Contains(res.Error, "recognizer down") {
|
|
t.Fatalf("expected failed run, got %+v", res)
|
|
}
|
|
}
|
|
|
|
// 显式 TaskKey 优先:闸门不被调用。
|
|
func TestIntentGateExplicitTaskKeyBypassesGate(t *testing.T) {
|
|
bpA := gateTestBlueprint("competitor_report", nil, "b_intent")
|
|
bpB := gateTestBlueprint("explicit_bp", nil, "b_explicit")
|
|
gate := &stubGate{dec: &IntentDecision{BlueprintKey: "competitor_report"}}
|
|
gated := newGateTestEngine(t, gate, bpA, bpB)
|
|
res := runGoal(t, gated, "任意目标", "explicit_bp")
|
|
if gate.calls != 0 {
|
|
t.Fatalf("gate must not fire on explicit task key, calls=%d", gate.calls)
|
|
}
|
|
if res.Plan == nil || len(res.Plan.Steps) != 1 || res.Plan.Steps[0].ID != "b_explicit" {
|
|
t.Fatalf("expected explicit blueprint, got %+v", res.Plan)
|
|
}
|
|
}
|
|
|
|
func errf(s string) error { return &gateErr{msg: s} }
|
|
|
|
type gateErr struct{ msg string }
|
|
|
|
func (e *gateErr) Error() string { return e.msg }
|