Files
Agent-Native/adapters/intentgate/intentgate_test.go
T
weichen 5e489ab2cb Add the optional intentgate decorator over agent-intent with pass-through and fail-closed tests
Details (original message):
Add the optional intentgate decorator over agent-intent with pass-through and fail-closed tests.
2026-09-29 15:58:35 +00:00

167 lines
5.3 KiB
Go

package intentgate
import (
"context"
"errors"
"reflect"
"testing"
"agent-intent/intent"
"agent-native/exec"
)
type stubBackend struct {
name string
calls int
}
func (s *stubBackend) Name() string { return s.name }
func (s *stubBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
s.calls++
return &exec.Result{Success: true, Logs: "stub:" + req.Prompt}, nil
}
func emptySnapshot(t *testing.T) *intent.Snapshot {
t.Helper()
reg, err := intent.NewRegistryBuilder(intent.Options{}).Build()
if err != nil {
t.Fatal(err)
}
return reg.Snapshot()
}
// 带一个守卫 spec 与一个可路由 spec 的快照。
func loadedSnapshot(t *testing.T) *intent.Snapshot {
t.Helper()
b := intent.NewRegistryBuilder(intent.Options{
GuardDomains: []string{"guard"},
ProtectedDomains: []string{"guard"},
})
if err := b.Register(intent.IntentSpec{
ID: "guard.config_change", Domain: "guard", Version: 1,
Matcher: intent.MatchSpec{IncludesAny: []string{"系统配置", "网关"}},
Risk: intent.RiskHigh,
Handler: "refuse.and_guide",
Metadata: map[string]string{
"guidance": "系统配置请在管理界面修改",
},
}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
return reg.Snapshot()
}
func newRec() intent.Recognizer { return intent.NewRecognizer(intent.Options{}, nil, nil) }
func TestZeroSpecSnapshotPassthroughIdentical(t *testing.T) {
// T0 行为解耦:零意图快照 + Wrap 后,与裸后端行为逐字节一致。
bare := &stubBackend{name: "native"}
gatedBe := Wrap(&stubBackend{name: "native"}, newRec(), emptySnapshot(t),
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
t.Fatal("handler must not fire on empty snapshot")
return nil, nil, false
})
req := exec.Request{Prompt: "帮我改一下系统网关地址"}
want, werr := bare.Execute(context.Background(), req)
got, gerr := gatedBe.Execute(context.Background(), req)
if werr != nil || gerr != nil {
t.Fatalf("execute errors: %v %v", werr, gerr)
}
if !reflect.DeepEqual(want, got) {
t.Fatalf("results differ: %+v vs %+v", want, got)
}
if gatedBe.(*gated).next.(*stubBackend).calls != 1 {
t.Fatal("next backend not invoked exactly once")
}
}
func TestGuardHitInterceptsWithoutModel(t *testing.T) {
next := &stubBackend{name: "native"}
be := Wrap(next, newRec(), loadedSnapshot(t),
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
if res.Branch != intent.BranchGuardHit || res.Top == nil || res.Top.ID != "guard.config_change" {
t.Fatalf("unexpected result: %+v", res)
}
// 宿主解释守卫意图:拒绝 + 引导(合成结果语义由宿主定义)。
return &exec.Result{Success: false, ErrorReason: "guard refused: " + res.Top.Metadata["guidance"]}, nil, true
})
out, err := be.Execute(context.Background(), exec.Request{Prompt: "帮我改一下系统网关地址"})
if err != nil {
t.Fatal(err)
}
if out.Success || out.ErrorReason == "" {
t.Fatalf("expected guard refusal, got %+v", out)
}
if next.calls != 0 {
t.Fatalf("model backend must not be called on guard hit, got %d calls", next.calls)
}
}
func TestFallbackPassesThroughToBackend(t *testing.T) {
next := &stubBackend{name: "native"}
be := Wrap(next, newRec(), loadedSnapshot(t),
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
t.Fatal("handler must not fire on fallback")
return nil, nil, false
})
out, err := be.Execute(context.Background(), exec.Request{Prompt: "写一份周报"})
if err != nil || out == nil || !out.Success {
t.Fatalf("fallback must reach backend: %+v %v", out, err)
}
if next.calls != 1 {
t.Fatalf("backend calls = %d", next.calls)
}
}
func TestHandlerDeclineFallsBackToBackend(t *testing.T) {
next := &stubBackend{name: "native"}
be := Wrap(next, newRec(), loadedSnapshot(t),
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
return nil, nil, false // 宿主放弃处理
})
out, err := be.Execute(context.Background(), exec.Request{Prompt: "帮我改一下系统网关地址"})
if err != nil || out == nil || !out.Success {
t.Fatalf("declined handling must pass through: %+v %v", out, err)
}
if next.calls != 1 {
t.Fatalf("backend calls = %d", next.calls)
}
}
func TestNilComponentsReturnNext(t *testing.T) {
next := &stubBackend{name: "native"}
if got := Wrap(next, nil, nil, nil); got != exec.Backend(next) {
t.Fatal("nil components must return next unchanged")
}
}
func TestRecognizeErrorFailsClosed(t *testing.T) {
next := &stubBackend{name: "native"}
b := intent.NewRegistryBuilder(intent.Options{MaxInputLen: 8})
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
be := Wrap(next, newRec(), reg.Snapshot(),
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
t.Fatal("handler must not fire on recognize error")
return nil, nil, false
})
_, err = be.Execute(context.Background(), exec.Request{Prompt: "这一段输入明显超过八个字节"})
if err == nil {
t.Fatal("expected fail-closed error")
}
var ie *intent.Error
if !errors.As(err, &ie) {
t.Fatalf("expected *intent.Error, got %T", err)
}
if next.calls != 0 {
t.Fatal("backend must not run on recognize error")
}
}