Files
Agent-Squad/squad/wp3_conformance_test.go
T
weichen c88100967d
ci / vet-and-test (pull_request) Successful in 34s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Add review driver, handoff primitive, and group-chat driver (Q2 WP3)
Why: Q2 acceptance 2 requires the squad review topology to be equivalent to
today's backend runReview flow (parallel finders, per-finding verification,
drop unverified); handoff and group_chat complete 11 of 12 builtin topologies.

Evidence: wp3_conformance_test.go mirrors backend review_test.go assertions
(true finder concurrency via release gate, verify calls = 5 unique after
dedupe, only PASS kept, dropped-count line, severity order); handoff records
ownership transfer, target receives filtered history, finalize takes
chain-tail output, unknown target role fails closed; group-chat rotate /
close-marker / moderator conformance with budget stop and fail-closed
unknown nominee. Full suite green with -race.

Impact: review/group_chat drivers lit in the topology registry (task_board
remains fail-closed until WP4); RunState gains Handoffs (persisted);
RunOptions gains GroupChat*/Review* knobs; runOneStep validates handoff
targets and injects handoff context; finalize prefers chain-tail deliverable.
Parse/dedupe/verdict/severity/report formats ported verbatim from backend
internal/runtime/review.go for byte-level report equivalence.

Refs: Q2 plan WP3 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q2.md
2026-10-08 01:11:51 +00:00

453 lines
16 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 squad
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// wp3_conformance_test.go — Q2 WP3 验收:review 拓扑与 backend runReview 等价
// (验收二:并行找问题、逐条验证、丢掉未验证)、handoff 原语(所有权转移 +
// 过滤历史 + finalize 链尾)、group_chat 驱动(轮转/主持人 + 预算停机)。
// ---- review:与 backend review_test.go 同款断言 ----
// reviewScriptBackend finder 目录回放 findings.json(第 3 个进入者放行,钉住
// 真并发);verify_* 目录按 prompt 内容回放判定(与 backend 夹具同文案)。
type reviewScriptBackend struct {
mu sync.Mutex
once sync.Once
entered int
verifyCalls int
release chan struct{}
}
func (b *reviewScriptBackend) Name() string { return "review-script" }
func (b *reviewScriptBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if strings.HasPrefix(key, "verify_") {
b.mu.Lock()
b.verifyCalls++
b.mu.Unlock()
verdict := "看不出对错"
switch {
case strings.Contains(req.Prompt, "无来源"):
verdict = "核对原文确实没有来源。\nVERDICT: PASS"
case strings.Contains(req.Prompt, "错字"):
verdict = "核对后成立。\nVERDICT: PASS"
case strings.Contains(req.Prompt, "过期数据"):
verdict = "数据仍有效。\nVERDICT: FAIL"
}
return writeOne(req.WorkDir, "verdict.md", verdict), nil
}
// finder:三个必须同时在跑(backend review_test.go:16-33 同款门)。
b.mu.Lock()
b.entered++
n := b.entered
b.mu.Unlock()
if n == 3 {
b.once.Do(func() { close(b.release) })
}
select {
case <-b.release:
case <-time.After(time.Second):
return nil, fmt.Errorf("finders did not run together")
}
var findings string
switch key {
case "01_researcher":
findings = `[{"location":"第2段","severity":"high","issue":"无来源","evidence":"显然"},{"location":"第1段","severity":"low","issue":"错字","evidence":"的地"},{"location":"表1","severity":"medium","issue":"过期数据","evidence":"2020"}]`
case "02_writer":
findings = `[{"location":"第2段","severity":"high","issue":"无来源","evidence":"显然"},{"location":"附录","severity":"medium","issue":"数字不一致","evidence":"3 与 4"}]`
default:
findings = `[{"location":"结尾","severity":"low","issue":"没有结论","evidence":"最后一句"}]`
}
return writeOne(req.WorkDir, "findings.json", findings), nil
}
func writeOne(dir, name, content string) *ExecResult {
out := filepath.Join(dir, name)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
_ = os.WriteFile(out, []byte(content), 0o644)
return &ExecResult{Success: true, Patches: map[string][]byte{name: []byte(content)}}
}
func TestTopologyConformance_review(t *testing.T) {
be := &reviewScriptBackend{release: make(chan struct{})}
sink := &collectSink{}
planner := mockScriptPlanner(
StepSpec{ID: "f1", Role: "researcher", Title: "评审一", Targets: []string{"findings.json"}},
StepSpec{ID: "f2", Role: "writer", Title: "评审二", Targets: []string{"findings.json"}},
StepSpec{ID: "f3", Role: "reviewer", Title: "评审三", Targets: []string{"findings.json"}},
)
planner.Agents = []RoleSpec{
{Role: "researcher", Name: "评审一", Backend: "review-script"},
{Role: "writer", Name: "评审二", Backend: "review-script"},
{Role: "reviewer", Name: "评审三", Backend: "review-script"},
{Role: "verifier", Name: "核实", Backend: "review-script"},
}
engine := newWP3Engine(t, planner, be, sink, []RoleSpec{
{Role: "researcher", Name: "评审一", Backend: "review-script"},
{Role: "writer", Name: "评审二", Backend: "review-script"},
{Role: "reviewer", Name: "评审三", Backend: "review-script"},
{Role: "verifier", Name: "核实", Backend: "review-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "评审材料正文"},
RunOptions{Topology: TopologyReview, ReviewTarget: "材料正文"})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
data, err := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
if err != nil {
t.Fatal(err)
}
out := string(data)
// 只留 PASS(fail-closed):无来源/错字保留;FAIL 与无判定丢弃。
if !strings.Contains(out, "无来源") || !strings.Contains(out, "错字") ||
strings.Contains(out, "数字不一致") || strings.Contains(out, "没有结论") ||
strings.Contains(out, "过期数据") {
t.Fatalf("filter = %s", out)
}
// 报告恒带丢弃计数(backend 同格式串)。
if !strings.Contains(out, "已丢弃 3 条未验证") {
t.Fatalf("dropped = %s", out)
}
// 去重后逐条验证:verify 调用数 = unique 数 = 5。
if be.verifyCalls != 5 {
t.Fatalf("verify calls = %d, want 5 after dedupe", be.verifyCalls)
}
// severity 稳定排序:high(无来源) 在 low(错字) 前。
if strings.Index(out, "无来源") > strings.Index(out, "错字") {
t.Fatalf("severity order: %s", out)
}
if sink.countText("review:3 个 finder 并行找问题") != 1 ||
sink.countText("review:去重后 5 条") != 1 {
t.Fatal("缺 review 阶段事件")
}
}
// verify 角色缺失 → fail-closed。
func TestReviewVerifyRoleMissingFailsClosed(t *testing.T) {
be := &reviewScriptBackend{release: make(chan struct{})}
planner := mockScriptPlanner(
StepSpec{ID: "f1", Role: "researcher", Title: "评审", Targets: []string{"findings.json"}},
)
engine := newWP3Engine(t, planner, be, NopSink{}, []RoleSpec{
{Role: "researcher", Name: "评审", Backend: "review-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "评审"},
RunOptions{Topology: TopologyReview})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "verify 角色") {
t.Fatalf("应 fail-closed:%s %q", res.Status, res.Error)
}
}
// ---- handoff 原语 ----
// handoffScriptBackend 记录 prompt 并按目录回放带标记的产出(≥50 字过最小质检)。
type handoffScriptBackend struct {
mu sync.Mutex
prompts map[string][]string
}
func (b *handoffScriptBackend) Name() string { return "handoff-script" }
func (b *handoffScriptBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
b.mu.Lock()
b.prompts[key] = append(b.prompts[key], req.Prompt)
b.mu.Unlock()
pad := "\n\n这是为了满足最小质检长度要求而补充的正文内容,超过五十个字符。"
var content string
switch key {
case "01_researcher":
content = "MARK_RESEARCHER 调研笔记。" + pad
case "02_writer":
content = "MARK_WRITER 终稿。" + pad
default:
content = "MARK_REVIEWER 评审意见。" + pad
}
name := "output.md"
if len(req.Targets) > 0 {
name = req.Targets[0]
}
return writeOne(req.WorkDir, name, content), nil
}
func TestTopologyConformance_handoff(t *testing.T) {
be := &handoffScriptBackend{prompts: map[string][]string{}}
sink := &collectSink{}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"notes.md"}, Handoff: "writer"},
StepSpec{ID: "s2", Role: "writer", Title: "成文", Targets: []string{"output.md"}, Needs: []string{"s1"}},
StepSpec{ID: "s3", Role: "reviewer", Title: "终审", Targets: []string{"output.md"}, Needs: []string{"s2"}},
), be, sink, []RoleSpec{
{Role: "researcher", Name: "调研", Backend: "handoff-script"},
{Role: "writer", Name: "撰写", Backend: "handoff-script"},
{Role: "reviewer", Name: "审核", Backend: "handoff-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "带移交的三步"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
// ① 所有权转移记录持久化。
st, err := engine.loadRun(engine.runDir(res.RunID))
if err != nil {
t.Fatal(err)
}
if len(st.Handoffs) != 1 || st.Handoffs[0].FromRole != "researcher" || st.Handoffs[0].ToRole != "writer" {
t.Fatalf("Handoffs = %+v", st.Handoffs)
}
if !strings.Contains(st.Handoffs[0].Output, "MARK_RESEARCHER") {
t.Fatalf("移交产出快照缺失:%q", st.Handoffs[0].Output)
}
if sink.countText("handoff:researcher → writer") != 1 {
t.Fatal("缺所有权移交事件")
}
// ② 目标收过滤历史:writer 的任务书带移交上下文,reviewer 不带。
var writerPrompt, reviewerPrompt string
for _, p := range be.prompts["02_writer"] {
writerPrompt += p
}
for _, p := range be.prompts["03_reviewer"] {
reviewerPrompt += p
}
if !strings.Contains(writerPrompt, "移交上下文") || !strings.Contains(writerPrompt, "MARK_RESEARCHER") {
t.Fatalf("writer 未收到过滤历史:%q", writerPrompt)
}
if strings.Contains(reviewerPrompt, "移交上下文") {
t.Fatal("非目标角色不应收到移交上下文")
}
// ③ finalize 取链尾输出:交付物 = writer(链尾)产出,而非最后完成的 reviewer。
data, err := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "MARK_WRITER") || strings.Contains(string(data), "MARK_REVIEWER") {
t.Fatalf("交付物应为链尾 writer 产出:%s", data)
}
}
// handoff 目标角色未知 → 步骤启动前 fail-closed。
func TestHandoffUnknownRoleFailsClosed(t *testing.T) {
be := &handoffScriptBackend{prompts: map[string][]string{}}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "调研", Handoff: "ghost"},
), be, NopSink{}, []RoleSpec{
{Role: "researcher", Name: "调研", Backend: "handoff-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "x"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "handoff: 未知目标角色 ghost") {
t.Fatalf("应 fail-closed:%s %q", res.Status, res.Error)
}
}
// ---- group_chat 驱动 ----
// gcScriptBackend 按目录回放 message.md。
type gcScriptBackend struct {
messages map[string]string
}
func (b *gcScriptBackend) Name() string { return "gc-script" }
func (b *gcScriptBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
msg := b.messages[key]
if msg == "" {
msg = "发言:" + key
}
return writeOne(req.WorkDir, "message.md", msg), nil
}
func TestTopologyConformance_groupChatRotateBudget(t *testing.T) {
be := &gcScriptBackend{messages: map[string]string{
"turn_01_researcher": "第一句",
"turn_02_writer": "第二句",
"turn_03_researcher": "第三句",
}}
sink := &collectSink{}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "参与者甲"},
StepSpec{ID: "p2", Role: "writer", Title: "参与者乙"},
), be, sink, []RoleSpec{
{Role: "researcher", Name: "甲", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "群聊"},
RunOptions{Topology: TopologyGroupChat, GroupChatMaxTurns: 3})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
// 预算停机:恰好 3 轮,轮转序 researcher→writer→researcher。
if sink.countText("轮次预算 3 用尽") != 1 {
t.Fatal("缺预算停机事件")
}
data, err := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
if err != nil {
t.Fatal(err)
}
out := string(data)
for _, want := range []string{"1. researcher:第一句", "2. writer:第二句", "3. researcher:第三句"} {
if !strings.Contains(out, want) {
t.Fatalf("转录缺 %q:%s", want, out)
}
}
if strings.Contains(out, "4. ") {
t.Fatalf("超出预算仍在发言:%s", out)
}
// 名册步骤不执行,标记 skipped(非降级原因)。
st, _ := engine.loadRun(engine.runDir(res.RunID))
for _, s := range st.Plan.Workflow.Steps {
if s.ID == "p1" || s.ID == "p2" {
if s.Status != "skipped" || s.Error != gcRosterSkipReason {
t.Fatalf("名册步骤 %s = %s/%q", s.ID, s.Status, s.Error)
}
}
}
}
func TestTopologyConformance_groupChatCloseMarker(t *testing.T) {
be := &gcScriptBackend{messages: map[string]string{
"turn_01_researcher": "我先说",
"turn_02_writer": "同意收口 [close]",
}}
sink := &collectSink{}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "甲"},
StepSpec{ID: "p2", Role: "writer", Title: "乙"},
), be, sink, []RoleSpec{
{Role: "researcher", Name: "甲", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "群聊"},
RunOptions{Topology: TopologyGroupChat, GroupChatMaxTurns: 8})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s", res.Status)
}
if sink.countText("发出收口标记") != 1 {
t.Fatal("缺收口标记事件")
}
st, _ := engine.loadRun(engine.runDir(res.RunID))
if n := countGcSteps(st); n != 2 {
t.Fatalf("收口标记后应停在 2 轮,实际 %d", n)
}
}
func TestTopologyConformance_groupChatModerator(t *testing.T) {
be := &gcScriptBackend{messages: map[string]string{
"turn_01_researcher": `{"next":"writer","reason":"请乙补充"}`,
"turn_02_writer": "补一段分析",
"turn_03_researcher": `{"action":"close","reason":"讨论充分"}`,
}}
sink := &collectSink{}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "主持人"},
StepSpec{ID: "p2", Role: "writer", Title: "乙"},
), be, sink, []RoleSpec{
{Role: "researcher", Name: "主持", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "主持人群聊"}, RunOptions{
Topology: TopologyGroupChat, GroupChatPolicy: "moderator",
GroupChatModerator: "researcher", GroupChatMaxTurns: 8,
})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
if sink.countText("主持人收口") != 1 {
t.Fatal("缺主持人收口事件")
}
data, _ := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
out := string(data)
for _, want := range []string{"1. researcher", "2. writer:补一段分析", "3. researcher"} {
if !strings.Contains(out, want) {
t.Fatalf("转录缺 %q:%s", want, out)
}
}
st, _ := engine.loadRun(engine.runDir(res.RunID))
if n := countGcSteps(st); n != 3 {
t.Fatalf("主持人模式应 3 轮收口,实际 %d", n)
}
}
// 主持人指名不在名册 → fail-closed。
func TestGroupChatModeratorUnknownNextFailsClosed(t *testing.T) {
be := &gcScriptBackend{messages: map[string]string{
"turn_01_researcher": `{"next":"ghost"}`,
}}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "主持人"},
StepSpec{ID: "p2", Role: "writer", Title: "乙"},
), be, NopSink{}, []RoleSpec{
{Role: "researcher", Name: "主持", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "x"}, RunOptions{
Topology: TopologyGroupChat, GroupChatPolicy: "moderator", GroupChatModerator: "researcher",
})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "不在名册内") {
t.Fatalf("应 fail-closed:%s %q", res.Status, res.Error)
}
}
// ---- 夹具 ----
func countGcSteps(st *RunState) int {
n := 0
for _, s := range st.Plan.Workflow.Steps {
if strings.HasPrefix(s.ID, "gc") && s.Status == "completed" {
n++
}
}
return n
}
func newWP3Engine(t *testing.T, planner Planner, be Executor, sink ProgressSink, roles []RoleSpec) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range roles {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance(be.Name(), be)
engine, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: planner, Backends: backends, Sink: sink})
if err != nil {
t.Fatal(err)
}
return engine
}