Why: the squad core has to compile without agent-native or agent-intent. Ports stay in the core; adapters and the CLI own the concrete clients. Evidence: GOWORK=off go test -count=1 ./squad/ ./store/ ./textextract/ ./schema/ passed, and the intent, sqlite, native, and cmd modules passed their own go test. Impact: core go.mod keeps error-code-system only. Nested gitlinks stay. Callers register the native backend through agent-squad/adapters/native. Refs: D1 Co-authored-by: Cursor <cursoragent@cursor.com>
132 lines
5.1 KiB
Go
132 lines
5.1 KiB
Go
package squadintent
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"agent-squad/squad"
|
|
)
|
|
|
|
// integrationScriptBackend 脚本回放后端(与 squad 测试同构,走 agent-native exec 端口)。
|
|
type integrationScriptBackend struct{ content string }
|
|
|
|
func (b *integrationScriptBackend) Name() string { return "script" }
|
|
|
|
func (b *integrationScriptBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
|
patches := map[string][]byte{}
|
|
for _, t := range req.Targets {
|
|
out := filepath.Join(req.WorkDir, t)
|
|
_ = os.MkdirAll(filepath.Dir(out), 0o755)
|
|
if err := os.WriteFile(out, []byte(b.content), 0o644); err != nil {
|
|
return nil, err
|
|
}
|
|
patches[t] = []byte(b.content)
|
|
}
|
|
return &squad.ExecResult{Success: true, Patches: patches, Logs: "script"}, nil
|
|
}
|
|
|
|
const intentReport = "# 竞品分析报告\n\n## 概述\n\n由意图定向的蓝图模板生成的交付物,长度满足最小质检要求。\n\n## 结论\n\n符合预期。\n"
|
|
|
|
// TestEngineEndToEndWithAdapterGate 全链路:agent-intent 识别 → 闸门决策 →
|
|
// 引擎蓝图定向 → 规划 → 脚本后端执行(agent-native exec 端口)→ 质检完成。
|
|
// 目标文本不含蓝图关键词,证明路由变化来自意图而非关键词匹配。
|
|
func TestEngineEndToEndWithAdapterGate(t *testing.T) {
|
|
dir := t.TempDir()
|
|
reg := squad.NewRoleRegistry()
|
|
if err := reg.Register(squad.RoleSpec{Role: "researcher", Name: "调研", Backend: "script"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bps := squad.NewBlueprintRegistry()
|
|
if err := bps.Register(squad.Blueprint{
|
|
Key: "competitor_report", Title: "竞品分析报告", Match: []string{"竞品分析报告"},
|
|
Steps: []squad.StepSpec{{ID: "b1", Role: "researcher", Title: "调研并成文", Targets: []string{"output.md"}}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
backends := squad.NewBackendResolver()
|
|
backends.RegisterInstance("script", &integrationScriptBackend{content: intentReport})
|
|
|
|
o := gateOptions()
|
|
o.FS = overlayFS()
|
|
o.Roots = []string{"."}
|
|
gate, err := New(context.Background(), o)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
engine, err := squad.NewEngine(squad.Options{
|
|
DataDir: dir, Registry: reg, Blueprints: bps, Planner: squad.NewRulePlanner(),
|
|
Backends: backends, IntentGate: gate,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res, err := engine.Run(context.Background(),
|
|
squad.Goal{Text: "给我做一份竞品情况的分析报告", HitlMode: squad.HitlAuto}, squad.RunOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Status != squad.RunCompleted {
|
|
t.Fatalf("expected completed run, got %s (err=%s plan=%+v)", res.Status, res.Error, res.Plan)
|
|
}
|
|
if res.Plan == nil || len(res.Plan.Steps) != 1 || res.Plan.Steps[0].ID != "b1" {
|
|
t.Fatalf("expected blueprint-driven plan, got %+v", res.Plan)
|
|
}
|
|
if data, err := os.ReadFile(filepath.Join(res.WorkDir, "output.md")); err != nil || !strings.Contains(string(data), "竞品分析报告") {
|
|
t.Fatalf("deliverable not produced: %v", err)
|
|
}
|
|
|
|
// 守卫输入走同一引擎:拒绝且不产生工作区执行。
|
|
res2, err := engine.Run(context.Background(),
|
|
squad.Goal{Text: "帮我把系统网关地址改一下", HitlMode: squad.HitlAuto}, squad.RunOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res2.Status != squad.RunFailed || !strings.Contains(res2.Error, "squad.intent_refused") {
|
|
t.Fatalf("expected guard refusal, got %+v", res2)
|
|
}
|
|
}
|
|
|
|
// T0:同屏对照——同一引擎配置换成 nil 闸门,守卫文本不再被拒绝、自由目标不再定向。
|
|
func TestEngineWithoutGateKeepsOriginalBehavior(t *testing.T) {
|
|
dir := t.TempDir()
|
|
reg := squad.NewRoleRegistry()
|
|
if err := reg.Register(squad.RoleSpec{Role: "researcher", Name: "调研", Backend: "script"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bps := squad.NewBlueprintRegistry()
|
|
if err := bps.Register(squad.Blueprint{
|
|
Key: "competitor_report", Title: "竞品分析报告", Match: []string{"竞品分析报告"},
|
|
Steps: []squad.StepSpec{{ID: "b1", Role: "researcher", Title: "调研并成文", Targets: []string{"output.md"}}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
backends := squad.NewBackendResolver()
|
|
backends.RegisterInstance("script", &integrationScriptBackend{content: intentReport})
|
|
engine, err := squad.NewEngine(squad.Options{
|
|
DataDir: dir, Registry: reg, Blueprints: bps, Planner: squad.NewRulePlanner(), Backends: backends,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 无闸门:守卫文本不再被拒绝(正常自主规划路径)。
|
|
res, err := engine.Run(context.Background(),
|
|
squad.Goal{Text: "帮我把系统网关地址改一下", HitlMode: squad.HitlAuto}, squad.RunOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Status == squad.RunFailed && strings.Contains(res.Error, "squad.intent_refused") {
|
|
t.Fatalf("nil gate must not refuse: %+v", res)
|
|
}
|
|
// 无闸门:不含关键词的目标不定向模板。
|
|
res2, _ := engine.Run(context.Background(),
|
|
squad.Goal{Text: "给我做一份竞品情况的分析报告", HitlMode: squad.HitlAuto}, squad.RunOptions{})
|
|
if res2.Plan != nil && len(res2.Plan.Steps) == 1 && res2.Plan.Steps[0].ID == "b1" {
|
|
t.Fatalf("nil gate must not route to blueprint: %+v", res2.Plan)
|
|
}
|
|
}
|