Pin Agent-Native as a submodule so the module builds without the multi-repo sibling path. Co-authored-by: Cursor <cursoragent@cursor.com>
241 lines
8.4 KiB
Go
241 lines
8.4 KiB
Go
// Package conformance 验证 agent-squad 公开 API 的全链路契约:
|
|
// 角色注册 → 蓝图注册 → 规划 → 执行 → HITL 挂起/回应 → 交付与审计。
|
|
// 全程脚本后端驱动,不依赖模型与外部智能体。
|
|
package conformance
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"agent-native/exec"
|
|
"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 exec.Request) (*exec.Result, 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 &exec.Result{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)
|
|
}
|
|
}
|