Why: F07/F08 - registry and catalog accessors shared caller-mutable nested state, and the generation fallback skipped full slot validation, risk metadata and mis-keyed handler slot tables. Evidence: intent package tests pass incl. new isolation/generated cases (verify-u5.json). Impact: no exported signature change; rule and generated paths now share authoritative validation. Refs: feature/agent-stack-audit-remediation-b1 U5
382 lines
13 KiB
Go
382 lines
13 KiB
Go
package intent
|
||
|
||
import (
|
||
"context"
|
||
"math"
|
||
"testing"
|
||
)
|
||
|
||
// TestU5RegisterMutationDoesNotAffectSnapshot:登记后改原 spec 不影响 SpecByID/Catalog。
|
||
func TestU5RegisterMutationDoesNotAffectSnapshot(t *testing.T) {
|
||
spec := IntentSpec{
|
||
ID: "demo.iso.reg", Domain: "demo", Version: 1,
|
||
Matcher: MatchSpec{IncludesAny: []string{"隔离登记"}},
|
||
Risk: RiskHigh,
|
||
RequiresConfirm: true,
|
||
Handler: "iso.handler",
|
||
Examples: []string{"示例甲"},
|
||
Action: &ActionRef{Kind: ActionWorkflow, ID: "wf-iso"},
|
||
Slots: []SlotSpec{{
|
||
Key: "env", Pattern: `env[: ]*(\w+)`, Group: 1,
|
||
Enum: []string{"prod", "dev"}, Normalize: []string{"trim"},
|
||
}},
|
||
Metadata: map[string]string{"guidance": "keep", "token": "secret"},
|
||
}
|
||
b := NewRegistryBuilder(Options{ProtectedDomains: []string{"demo"}, GuardDomains: []string{"guard"}})
|
||
if err := b.Register(spec); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 登记后篡改原对象与嵌套可变字段。
|
||
spec.Risk = RiskLow
|
||
spec.RequiresConfirm = false
|
||
spec.Handler = "tampered"
|
||
spec.Examples[0] = "被改"
|
||
spec.Action.ID = "wf-tampered"
|
||
spec.Slots[0].Enum[0] = "evil"
|
||
spec.Metadata["guidance"] = "mutated"
|
||
spec.Matcher.IncludesAny[0] = "被改词"
|
||
|
||
reg, err := b.Build()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
snap := reg.Snapshot()
|
||
got, ok := snap.SpecByID("demo.iso.reg")
|
||
if !ok {
|
||
t.Fatal("missing spec")
|
||
}
|
||
if got.Risk != RiskHigh || !got.RequiresConfirm || got.Handler != "iso.handler" {
|
||
t.Fatalf("register isolation broken: %+v", got)
|
||
}
|
||
if got.Examples[0] != "示例甲" || got.Action == nil || got.Action.ID != "wf-iso" {
|
||
t.Fatalf("nested isolation broken: examples=%v action=%+v", got.Examples, got.Action)
|
||
}
|
||
if got.Slots[0].Enum[0] != "prod" || got.Metadata["guidance"] != "keep" {
|
||
t.Fatalf("slot/meta isolation broken: %+v", got)
|
||
}
|
||
cat := snap.Catalog()
|
||
if len(cat) != 1 || cat[0].Handler != "iso.handler" || cat[0].Slots[0].Enum[0] != "prod" {
|
||
t.Fatalf("catalog isolation after register mutate: %+v", cat)
|
||
}
|
||
opts := reg.Options()
|
||
opts.ProtectedDomains[0] = "hacked"
|
||
opts2 := reg.Options()
|
||
if opts2.ProtectedDomains[0] != "demo" {
|
||
t.Fatalf("Options isolation broken: %v", opts2.ProtectedDomains)
|
||
}
|
||
}
|
||
|
||
// TestU5ReturnedSpecMutationDoesNotAffectRegistry:改返回值不影响快照。
|
||
func TestU5ReturnedSpecMutationDoesNotAffectRegistry(t *testing.T) {
|
||
b := NewRegistryBuilder(Options{})
|
||
if err := b.Register(IntentSpec{
|
||
ID: "demo.iso.ret", Domain: "demo", Version: 1,
|
||
Matcher: MatchSpec{IncludesAny: []string{"隔离返回"}, Patterns: []string{`隔离返回`}},
|
||
Examples: []string{"例"},
|
||
Action: &ActionRef{Kind: ActionCommand, ID: "cmd.iso"},
|
||
Slots: []SlotSpec{{Key: "x", Pattern: `(x)`, Enum: []string{"a", "b"}}},
|
||
Metadata: map[string]string{"guidance": "g"},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
reg, err := b.Build()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
snap := reg.Snapshot()
|
||
got, _ := snap.SpecByID("demo.iso.ret")
|
||
got.Examples[0] = "mut"
|
||
got.Action.ID = "cmd.mut"
|
||
got.Slots[0].Enum[0] = "z"
|
||
got.Metadata["guidance"] = "mut"
|
||
got.Matcher.Patterns[0] = "mut"
|
||
|
||
again, _ := snap.SpecByID("demo.iso.ret")
|
||
if again.Examples[0] != "例" || again.Action.ID != "cmd.iso" || again.Slots[0].Enum[0] != "a" {
|
||
t.Fatalf("SpecByID leak: %+v", again)
|
||
}
|
||
if again.Metadata["guidance"] != "g" || again.Matcher.Patterns[0] != `隔离返回` {
|
||
t.Fatalf("SpecByID matcher/meta leak: %+v", again)
|
||
}
|
||
|
||
cat := snap.Catalog()
|
||
cat[0].Slots[0].Enum[0] = "z"
|
||
cat[0].Metadata["guidance"] = "mut"
|
||
cat2 := snap.Catalog()
|
||
if cat2[0].Slots[0].Enum[0] != "a" || cat2[0].Metadata["guidance"] != "g" {
|
||
t.Fatalf("Catalog leak: %+v", cat2)
|
||
}
|
||
}
|
||
|
||
// TestU5GeneratedMatchesRuleValidationAndMetadata:同 action+slots 时生成路径与规则路径校验/元数据一致。
|
||
func TestU5GeneratedMatchesRuleValidationAndMetadata(t *testing.T) {
|
||
spec := IntentSpec{
|
||
ID: "report.generate", Domain: "report", Version: 1,
|
||
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
|
||
Slots: []SlotSpec{{
|
||
Key: "topic", Required: true, Pattern: `主题是(.+)`, Group: 1,
|
||
Enum: []string{"电机", "泵"}, Normalize: []string{"trim"},
|
||
}},
|
||
Risk: RiskMedium,
|
||
RequiresConfirm: true,
|
||
Handler: "reportHandler",
|
||
Action: &ActionRef{Kind: ActionWorkflow, ID: "wf-report"},
|
||
Metadata: map[string]string{"guidance": "ask topic", "blueprint": "bp"},
|
||
}
|
||
b := NewRegistryBuilder(Options{})
|
||
if err := b.Register(spec); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
reg, err := b.Build()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
snap := reg.Snapshot()
|
||
rec := NewRecognizer(Options{}, nil, nil)
|
||
rule, err := rec.Recognize(context.Background(), "生成报告 主题是电机", snap)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rule.Branch != BranchMatched || rule.Top == nil {
|
||
t.Fatalf("rule: %+v", rule)
|
||
}
|
||
|
||
gen := stubGenerator{cmds: []Command{
|
||
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "wf-report"},
|
||
Slots: map[string]string{"topic": "电机"}},
|
||
}}
|
||
genRec := NewRecognizer(Options{Generator: gen}, nil, nil)
|
||
out, err := genRec.Recognize(context.Background(), "完全不相关的一句话", snap)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if out.Branch != BranchMatched || out.Top == nil {
|
||
t.Fatalf("generated: %+v", out)
|
||
}
|
||
if out.Top.ID != rule.Top.ID || out.Top.Risk != rule.Top.Risk || out.Top.RequiresConfirm != rule.Top.RequiresConfirm {
|
||
t.Fatalf("metadata mismatch rule=%+v gen=%+v", rule.Top, out.Top)
|
||
}
|
||
if out.Top.Handler != rule.Top.Handler {
|
||
t.Fatalf("handler mismatch rule=%q gen=%q", rule.Top.Handler, out.Top.Handler)
|
||
}
|
||
if out.Top.Action == nil || rule.Top.Action == nil || out.Top.Action.Kind != rule.Top.Action.Kind || out.Top.Action.ID != rule.Top.Action.ID {
|
||
t.Fatalf("action mismatch rule=%+v gen=%+v", rule.Top.Action, out.Top.Action)
|
||
}
|
||
if out.Top.Slots["topic"] != rule.Top.Slots["topic"] {
|
||
t.Fatalf("slots mismatch rule=%v gen=%v", rule.Top.Slots, out.Top.Slots)
|
||
}
|
||
if len(out.Top.MissingRequiredSlots) != 0 || len(out.Top.InvalidSlots) != 0 {
|
||
t.Fatalf("unexpected missing/invalid: %+v", out.Top)
|
||
}
|
||
}
|
||
|
||
// TestU5GeneratedSlotValidationStable:缺必填/非法枚举/未知键/重复 handler 在生成路径稳定处理。
|
||
func TestU5GeneratedSlotValidationStable(t *testing.T) {
|
||
mk := func(t *testing.T, specs ...IntentSpec) *Snapshot {
|
||
t.Helper()
|
||
b := NewRegistryBuilder(Options{})
|
||
if err := b.Register(specs...); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
reg, err := b.Build()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return reg.Snapshot()
|
||
}
|
||
base := IntentSpec{
|
||
ID: "report.generate", Domain: "report", Version: 1,
|
||
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
|
||
Slots: []SlotSpec{{
|
||
Key: "topic", Required: true, Pattern: `主题是(.+)`,
|
||
Enum: []string{"电机", "泵"},
|
||
}},
|
||
Risk: RiskHigh, RequiresConfirm: true, Handler: "reportHandler",
|
||
Action: &ActionRef{Kind: ActionCommand, ID: "cmd.report"},
|
||
}
|
||
|
||
t.Run("missing_required", func(t *testing.T) {
|
||
snap := mk(t, base)
|
||
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
|
||
{Kind: CommandStart, Action: ActionRef{Kind: ActionCommand, ID: "cmd.report"}, Slots: map[string]string{}},
|
||
}}}, nil, nil)
|
||
res, err := rec.Recognize(context.Background(), "无关输入", snap)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Branch != BranchMatched || res.Top == nil {
|
||
t.Fatalf("want matched with missing: %+v", res)
|
||
}
|
||
if len(res.Top.MissingRequiredSlots) != 1 || res.Top.MissingRequiredSlots[0] != "topic" {
|
||
t.Fatalf("missing: %+v", res.Top.MissingRequiredSlots)
|
||
}
|
||
if res.Top.Risk != RiskHigh || !res.Top.RequiresConfirm {
|
||
t.Fatalf("metadata lost: %+v", res.Top)
|
||
}
|
||
})
|
||
|
||
t.Run("invalid_enum", func(t *testing.T) {
|
||
snap := mk(t, base)
|
||
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
|
||
{Kind: CommandStart, Action: ActionRef{Kind: ActionHandler, ID: "reportHandler"},
|
||
Slots: map[string]string{"topic": "涡轮"}},
|
||
}}}, nil, nil)
|
||
res, err := rec.Recognize(context.Background(), "无关输入", snap)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Branch != BranchMatched || res.Top == nil {
|
||
t.Fatalf("want matched with invalid: %+v", res)
|
||
}
|
||
if res.Top.InvalidSlots["topic"] == "" {
|
||
t.Fatalf("invalid slots: %+v", res.Top.InvalidSlots)
|
||
}
|
||
if res.Top.Slots["topic"] != "" {
|
||
t.Fatalf("invalid value must not fill slots: %+v", res.Top.Slots)
|
||
}
|
||
})
|
||
|
||
t.Run("unknown_slot", func(t *testing.T) {
|
||
snap := mk(t, base)
|
||
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
|
||
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "report.generate"},
|
||
Slots: map[string]string{"bogus": "v"}},
|
||
}}}, nil, nil)
|
||
res, err := rec.Recognize(context.Background(), "无关输入", snap)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Branch != BranchFallback || res.Top != nil {
|
||
t.Fatalf("unknown slot must fallback: %+v", res)
|
||
}
|
||
})
|
||
|
||
t.Run("duplicate_handler", func(t *testing.T) {
|
||
a := base
|
||
a.ID = "report.a"
|
||
a.Handler = "shared"
|
||
bSpec := base
|
||
bSpec.ID = "report.b"
|
||
bSpec.Handler = "shared"
|
||
bSpec.Action = &ActionRef{Kind: ActionCommand, ID: "cmd.b"}
|
||
snap := mk(t, a, bSpec)
|
||
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
|
||
{Kind: CommandStart, Action: ActionRef{Kind: ActionHandler, ID: "shared"},
|
||
Slots: map[string]string{"topic": "电机"}},
|
||
}}}, nil, nil)
|
||
res, err := rec.Recognize(context.Background(), "无关输入", snap)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Branch != BranchFallback || res.Top != nil {
|
||
t.Fatalf("ambiguous handler must fallback: %+v", res)
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestU5GeneratedActionAliasRoutesToSpec:Action 指向不同 command ID 时仍解析到权威 spec。
|
||
func TestU5GeneratedActionAliasRoutesToSpec(t *testing.T) {
|
||
b := NewRegistryBuilder(Options{})
|
||
if err := b.Register(IntentSpec{
|
||
ID: "report.generate", Domain: "report", Version: 1,
|
||
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
|
||
Slots: []SlotSpec{{Key: "topic", Pattern: `(.+)`}},
|
||
Action: &ActionRef{Kind: ActionCommand, ID: "cmd.other-id"},
|
||
Handler: "h",
|
||
Risk: RiskLow,
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
reg, err := b.Build()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{
|
||
{Kind: CommandStart, Action: ActionRef{Kind: ActionCommand, ID: "cmd.other-id"},
|
||
Slots: map[string]string{"topic": "x"}},
|
||
}}}, nil, nil)
|
||
res, err := rec.Recognize(context.Background(), "无关输入", reg.Snapshot())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "report.generate" {
|
||
t.Fatalf("alias resolve: %+v", res)
|
||
}
|
||
if res.Top.Action == nil || res.Top.Action.ID != "cmd.other-id" {
|
||
t.Fatalf("action metadata: %+v", res.Top.Action)
|
||
}
|
||
}
|
||
|
||
// TestU5ClassifierAndSemanticRejectBadScores:非有限/越界分数不得变成 matched。
|
||
func TestU5ClassifierAndSemanticRejectBadScores(t *testing.T) {
|
||
snap := ambiguousFixture(t, []string{"查运行进度"}, []string{"闲聊问候"})
|
||
|
||
t.Run("classifier_inf", func(t *testing.T) {
|
||
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: func(cands []Candidate) []Candidate {
|
||
out := append([]Candidate(nil), cands...)
|
||
out[0].Confidence = math.Inf(1)
|
||
out[1].Confidence = 0.1
|
||
return out
|
||
}})
|
||
res, err := rec.Recognize(context.Background(), "模糊词", snap)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Branch != BranchAmbiguous || res.Top != nil {
|
||
t.Fatalf("inf must stay ambiguous: %+v", res)
|
||
}
|
||
})
|
||
|
||
t.Run("classifier_in_place_mutation", func(t *testing.T) {
|
||
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: func(cands []Candidate) []Candidate {
|
||
cands[0].Handler = "tampered"
|
||
cands[0].Confidence = 0.99
|
||
cands[1].Confidence = 0.1
|
||
return cands
|
||
}})
|
||
res, err := rec.Recognize(context.Background(), "模糊词", snap)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Branch != BranchMatched || res.Top == nil {
|
||
t.Fatalf("expected matched: %+v", res)
|
||
}
|
||
if res.Top.Handler == "tampered" {
|
||
t.Fatalf("classifier in-place mutation leaked into top: %+v", res.Top)
|
||
}
|
||
})
|
||
|
||
t.Run("semantic_nan", func(t *testing.T) {
|
||
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": math.NaN()}}}, nil, nil)
|
||
res, err := rec.Recognize(context.Background(), "模糊词", snap)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Branch != BranchAmbiguous || res.Top != nil {
|
||
t.Fatalf("nan semantic must stay ambiguous: %+v", res)
|
||
}
|
||
})
|
||
|
||
t.Run("semantic_above_one", func(t *testing.T) {
|
||
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": 1.5}}}, nil, nil)
|
||
res, err := rec.Recognize(context.Background(), "模糊词", snap)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Branch != BranchAmbiguous || res.Top != nil {
|
||
t.Fatalf("out-of-range semantic must stay ambiguous: %+v", res)
|
||
}
|
||
})
|
||
|
||
t.Run("semantic_inf_not_matched", func(t *testing.T) {
|
||
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": math.Inf(1)}}}, nil, nil)
|
||
res, err := rec.Recognize(context.Background(), "模糊词", snap)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Branch != BranchAmbiguous || res.Top != nil {
|
||
t.Fatalf("inf semantic must stay ambiguous: %+v", res)
|
||
}
|
||
})
|
||
}
|