Files
Agent-Squad/cmd/agent-squad/conformance/conformance_test.go
T
weichenandCursor a182d7662b Move cross-library squad code into adapter modules
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>
2026-10-03 13:40:58 +00:00

240 lines
8.4 KiB
Go

// Package conformance 验证 agent-squad 公开 API 的全链路契约:
// 角色注册 → 蓝图注册 → 规划 → 执行 → HITL 挂起/回应 → 交付与审计。
// 全程脚本后端驱动,不依赖模型与外部智能体。
package conformance
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"agent-squad/squad"
"agent-squad/store"
)
// replayBackend 按"子目录名→target→内容"回放产出。
type replayBackend struct{ script map[string]map[string]string }
func (b *replayBackend) Name() string { return "replay" }
func (b *replayBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
files := b.script[key]
patches := map[string][]byte{}
for _, t := range req.Targets {
content := "\n"
if c, ok := files[t]; ok {
content = c
}
out := filepath.Join(req.WorkDir, t)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
if err := os.WriteFile(out, []byte(content), 0o644); err != nil {
return nil, err
}
patches[t] = []byte(content)
}
return &squad.ExecResult{Success: true, Patches: patches, Logs: "replay"}, nil
}
const deliverable = "# 契约验收交付物\n\n## 概述\n\n本文件由回放后端生成,用于验证 agent-squad 全链路契约:注册、规划、执行、HITL、质检与审计。\n\n## 结论\n\n全链路行为符合契约。\n"
// TestFullChainWithHitl 是底座的核心验收:
// 空表起步 → 注册角色/蓝图 → 蓝图命中规划 → 阻塞缺口挂起 → 人工回应 →
// 完成 → 工作区布局/黑板/清单/事件全部可验证。
func TestFullChainWithHitl(t *testing.T) {
dir := t.TempDir()
// 1. 空注册表:底座不预置任何角色。
fstore, err := store.New(dir)
if err != nil {
t.Fatal(err)
}
reg := squad.NewRoleRegistry()
if err := fstore.LoadRoles(reg); err != nil {
t.Fatal(err)
}
if len(reg.List()) != 0 {
t.Fatal("registry must start empty")
}
bps := squad.NewBlueprintRegistry()
if err := fstore.LoadBlueprints(bps); err != nil {
t.Fatal(err)
}
// 2. 注册角色(backend=replay;角色文件落盘 roles.d/)。
for _, spec := range []squad.RoleSpec{
{Role: "researcher", Name: "调研专员", Backend: "replay", Keywords: []string{"调研"},
Responsibilities: []string{"事实梳理"}},
{Role: "writer", Name: "撰写专员", Backend: "replay", Keywords: []string{"撰写"}},
} {
if err := reg.Register(spec); err != nil {
t.Fatal(err)
}
}
if _, err := os.Stat(filepath.Join(dir, "roles.d", "researcher.json")); err != nil {
t.Fatalf("role file not persisted: %v", err)
}
// 3. 注册蓝图(落盘 blueprints.d/)。
bp := squad.Blueprint{
Key: "research_report", Title: "调研报告", Match: []string{"调研", "报告"},
Steps: []squad.StepSpec{
{ID: "b1", Role: "researcher", Title: "调研并回填缺口", Targets: []string{"notes.md", "fields.json", "gaps.json"}},
{ID: "b2", Role: "writer", Title: "汇总成文", Targets: []string{"output.md"}},
},
Template: "# {{goal}}\n\n## 概述\n\n## 结论\n",
}
if err := bps.Register(bp); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "blueprints.d", "research_report.json")); err != nil {
t.Fatalf("blueprint file not persisted: %v", err)
}
// 4. 组装引擎(脚本回放后端)。
script := map[string]map[string]string{
"01_researcher": {
"notes.md": "# 调研笔记\n- 竞品三款\n- 法规二类",
"fields.json": `{"market":"CN"}`,
"gaps.json": `[{"item":"缺少预算区间","blocking":true}]`,
},
"02_writer": {"output.md": deliverable},
}
backends := squad.NewBackendResolver()
backends.RegisterInstance("replay", &replayBackend{script: script})
sink := squad.NewFanoutSink(4096)
engine, err := squad.NewEngine(squad.Options{
DataDir: dir, Registry: reg, Blueprints: bps, Planner: squad.NewRulePlanner(),
Backends: backends, Sink: sink,
})
if err != nil {
t.Fatal(err)
}
// 5. 发起运行:蓝图命中 → 执行 → 阻塞缺口挂起。
res, err := engine.Run(context.Background(), squad.Goal{Text: "做一份竞品调研报告"}, squad.RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != squad.RunWaitingHuman || res.Pending == nil || res.Pending.Kind != squad.HitlGap {
t.Fatalf("want gap hold, got status=%s pending=%v", res.Status, res.Pending)
}
if res.Plan.TaskKey != "research_report" || res.Plan.PlanningMode != squad.PlanModeBlueprint {
t.Fatalf("blueprint not used: %+v", res.Plan)
}
// 6. 人工回应缺口 → 运行完成。
res2, err := engine.Respond(context.Background(), res.RunID, squad.RespondDecision{
RequestID: res.Pending.ID, Choice: "resolved", Answer: "预算区间 20-30 万",
})
if err != nil {
t.Fatal(err)
}
if res2.Status != squad.RunCompleted {
t.Fatalf("want completed, got %s err=%s", res2.Status, res2.Error)
}
// 7. 工作区契约:布局、黑板、清单、模板渲染。
for _, f := range []string{
"workspace.json", "squad.json", "workflow.json", "blackboard.json",
"template.md", "output.md", "manifest.json",
"context/goal.md", "context/index.json",
"tasks/01_researcher/TASK.md", "tasks/01_researcher/AGENTS.md",
"tasks/02_writer/TASK.md",
} {
if _, err := os.Stat(filepath.Join(res2.WorkDir, f)); err != nil {
t.Errorf("workspace contract: missing %s (%v)", f, err)
}
}
tmpl, _ := os.ReadFile(filepath.Join(res2.WorkDir, "template.md"))
if !strings.Contains(string(tmpl), "调研报告") {
t.Errorf("template not rendered with goal: %s", tmpl)
}
bb, _ := engine.Blackboard(res.RunID)
if bb.Fields["market"] != "CN" {
t.Errorf("fields merge broken: %+v", bb.Fields)
}
if len(bb.Facts) == 0 || !strings.Contains(bb.Facts[0].Claim, "20-30 万") {
t.Errorf("human answer not a fact: %+v", bb.Facts)
}
man, _ := os.ReadFile(filepath.Join(res2.WorkDir, "manifest.json"))
var manifest squad.Manifest
if err := json.Unmarshal(man, &manifest); err != nil {
t.Fatal(err)
}
if !manifest.QualityReport.Passed || manifest.Deliverable != "output.md" || len(manifest.Files) < 5 {
t.Errorf("manifest contract broken: %+v files=%d", manifest.QualityReport, len(manifest.Files))
}
// 8. 事件契约:关键事件齐备且有序。
events, _ := sink.Snapshot()
order := []string{}
for _, ev := range events {
switch ev.Type {
case squad.EvRunStarted, squad.EvStepStarted, squad.EvStepCompleted,
squad.EvHumanInputRequired, squad.EvHumanResponded, squad.EvRunCompleted:
order = append(order, ev.Type)
}
}
joined := strings.Join(order, ",")
for _, want := range []string{
squad.EvRunStarted, squad.EvStepStarted, squad.EvHumanInputRequired,
squad.EvHumanResponded, squad.EvRunCompleted,
} {
if !strings.Contains(joined, want) {
t.Errorf("event %s missing in %s", want, joined)
}
}
if strings.Index(joined, string(squad.EvHumanInputRequired)) > strings.Index(joined, string(squad.EvRunCompleted)) {
t.Errorf("event order wrong: %s", joined)
}
// 9. Inspect/ListRuns 可发现运行。
insp, err := engine.Inspect(res.RunID)
if err != nil || insp.Status != squad.RunCompleted {
t.Fatalf("inspect: %v %+v", err, insp)
}
runs, _ := engine.ListRuns()
if len(runs) != 1 {
t.Fatalf("list runs want 1, got %d", len(runs))
}
// 10. 重启恢复:新引擎实例(同数据目录、空注册表)能读出终态。
engine2, err := squad.NewEngine(squad.Options{DataDir: dir, Registry: squad.NewRoleRegistry(),
Backends: squad.NewBackendResolver(), Sink: squad.NopSink{}})
if err != nil {
t.Fatal(err)
}
insp2, err := engine2.Inspect(res.RunID)
if err != nil || insp2.Status != squad.RunCompleted {
t.Fatalf("restart inspect: %v %+v", err, insp2)
}
}
// TestUnknownBackendFailsClosed 未知智能体名必须失败关闭,不静默落 stub。
func TestUnknownBackendFailsClosed(t *testing.T) {
dir := t.TempDir()
reg := squad.NewRoleRegistry()
_ = reg.Register(squad.RoleSpec{Role: "solo", Backend: "no-such-agent"})
engine, err := squad.NewEngine(squad.Options{
DataDir: dir, Registry: reg,
Planner: &squad.MockPlanner{Script: []squad.StepSpec{
{ID: "s1", Role: "solo", Title: "单步", Targets: []string{"output.md"}},
}},
Backends: squad.NewBackendResolver(), Sink: squad.NopSink{},
})
if err != nil {
t.Fatal(err)
}
res, err := engine.Run(context.Background(), squad.Goal{Text: "g"}, squad.RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != squad.RunFailed || !strings.Contains(res.Error, "no-such-agent") {
t.Fatalf("unknown backend must fail closed: %+v", res)
}
}