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
453 lines
16 KiB
Go
453 lines
16 KiB
Go
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
|
||
}
|