Why: N1 WP5/WP6 library side - the gate decision logic, artifact checks, verdict parsing and progress-reminder throttling move from the product into a gates package behind ports, and the native backend gains a contract form so conformance has a real implementation to drive. Evidence: go vet clean; go test ./... 27 packages ok, including the conformance suite (succeeded/descriptor/error-sideeffects) against the real backend. Impact: new runtime/gates package (ClaimGate, CheckArtifacts, ParseVerdict, ProgressReminder, shared caps); adapters/native.Backend gains Contract() returning an exec.ContractBackend wrapper - the legacy facade keeps its signature so squad consumption is untouched. Product texts (fix prompts, verifier prompts) stay injected by hosts. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP5/WP6
134 lines
4.0 KiB
Go
134 lines
4.0 KiB
Go
package gates
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func lookupWith(ids map[string][]byte) ArtifactLookup {
|
||
return func(id string) (string, []byte, bool) {
|
||
c, ok := ids[id]
|
||
return id + ".md", c, ok
|
||
}
|
||
}
|
||
|
||
func TestClaimGateForgedMarkerAlwaysBlocks(t *testing.T) {
|
||
block, why := ClaimGate("结果如下 [调用工具 write_file]", nil, nil)
|
||
if !block || why == "" {
|
||
t.Fatalf("block=%v why=%q", block, why)
|
||
}
|
||
}
|
||
|
||
func TestClaimGateProducedAndExistingDoNotBlock(t *testing.T) {
|
||
produced := []ArtifactRef{{ID: "art_aaaabbbbcccc"}}
|
||
if block, _ := ClaimGate("已生成 art_aaaabbbbcccc 请查收", produced, nil); block {
|
||
t.Fatal("produced reference blocked")
|
||
}
|
||
lookup := lookupWith(map[string][]byte{"art_dddd11112222": []byte("x")})
|
||
if block, _ := ClaimGate("之前的 art_dddd11112222 里有结论", nil, lookup); block {
|
||
t.Fatal("existing artifact reference blocked")
|
||
}
|
||
}
|
||
|
||
func TestClaimGateUnknownArtifactBlocks(t *testing.T) {
|
||
block, why := ClaimGate("见附件 art_000011112222", nil, lookupWith(nil))
|
||
if !block || !strings.Contains(why, "不存在") {
|
||
t.Fatalf("block=%v why=%q", block, why)
|
||
}
|
||
}
|
||
|
||
func TestClaimGateBareClaimWithoutArtifactsBlocks(t *testing.T) {
|
||
for _, body := range []string{
|
||
"已生成报告.md,请查收",
|
||
"文件已生成好了",
|
||
} {
|
||
if block, _ := ClaimGate(body, nil, nil); !block {
|
||
t.Fatalf("claim not blocked: %q", body)
|
||
}
|
||
}
|
||
// 如实说明没有生成则不拦。
|
||
if block, _ := ClaimGate("这一轮没有生成文件", nil, nil); block {
|
||
t.Fatal("honest note blocked")
|
||
}
|
||
}
|
||
|
||
func TestCheckArtifactsReasons(t *testing.T) {
|
||
lookup := func(id string) (string, []byte, bool) {
|
||
switch id {
|
||
case "ok":
|
||
return "ok.md", []byte("# 正常"), true
|
||
case "empty":
|
||
return "empty.md", nil, true
|
||
default:
|
||
return "", nil, false
|
||
}
|
||
}
|
||
scorer := func(name string, content []byte) (bool, string) { return false, "标题缺失" }
|
||
refs := []ArtifactRef{
|
||
{ID: ""},
|
||
{ID: "missing", Name: "缺失.md"},
|
||
{ID: "empty"},
|
||
{ID: "ok"},
|
||
}
|
||
reasons := CheckArtifacts(refs, lookup, scorer)
|
||
joined := strings.Join(reasons, ";")
|
||
for _, want := range []string{"缺少 ID", "缺失.md 不存在", "empty.md 内容为空", "ok.md:标题缺失"} {
|
||
if !strings.Contains(joined, want) {
|
||
t.Fatalf("reasons %q 缺 %q", joined, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCheckArtifactsNilScorerSkipsQuality(t *testing.T) {
|
||
lookup := lookupWith(map[string][]byte{"a": []byte("x")})
|
||
if reasons := CheckArtifacts([]ArtifactRef{{ID: "a"}}, lookup, nil); len(reasons) != 0 {
|
||
t.Fatalf("reasons = %v", reasons)
|
||
}
|
||
}
|
||
|
||
func TestParseVerdictForms(t *testing.T) {
|
||
v, err := ParseVerdict("```json\n{\"passed\":true,\"reason\":\"ok\"}\n```")
|
||
if err != nil || !v.Passed {
|
||
t.Fatalf("v=%+v err=%v", v, err)
|
||
}
|
||
if _, err := ParseVerdict("没有 JSON"); err == nil {
|
||
t.Fatal("garbage accepted")
|
||
}
|
||
if _, err := ParseVerdict("{\"reason\":\"no passed key\"}"); err == nil {
|
||
t.Fatal("missing passed accepted")
|
||
}
|
||
}
|
||
|
||
func TestProgressReminderThrottleAndFormat(t *testing.T) {
|
||
r := &ProgressReminder{}
|
||
now := time.Now()
|
||
first := r.Remind([]string{"写第三章"}, []string{"评审"}, now)
|
||
if len(first) != 1 {
|
||
t.Fatalf("first = %v", first)
|
||
}
|
||
text := first[0]
|
||
for _, want := range []string{"已连续 3 个工具轮", "- 待办:写第三章", "- 计划:评审", "请更新计划或待办"} {
|
||
if !strings.Contains(text, want) {
|
||
t.Fatalf("text 缺 %q:\n%s", want, text)
|
||
}
|
||
}
|
||
// 内容相同且计数不同:key 不同,应继续发出直到封顶。
|
||
if got := r.Remind([]string{"写第三章"}, []string{"评审"}, now); len(got) != 1 {
|
||
t.Fatalf("second = %v", got)
|
||
}
|
||
if got := r.Remind([]string{"写第三章"}, []string{"评审"}, now); len(got) != 1 {
|
||
t.Fatalf("third = %v", got)
|
||
}
|
||
if got := r.Remind([]string{"写第三章"}, []string{"评审"}, now); got != nil {
|
||
t.Fatalf("fourth should be capped: %v", got)
|
||
}
|
||
if r.Sent() != 3 {
|
||
t.Fatalf("sent = %d", r.Sent())
|
||
}
|
||
// 无内容不发。
|
||
if got := (&ProgressReminder{}).Remind(nil, nil, now); got != nil {
|
||
t.Fatalf("empty = %v", got)
|
||
}
|
||
}
|