Files
Agent-Squad/adapters/intent/gate_test.go
T
weichenandCursor a182d7662b Move cross-library squad code into adapter modules
Why: the squad core has to compile without agent-native or agent-intent. Ports stay in the core; adapters and the CLI own the concrete clients.
Evidence: GOWORK=off go test -count=1 ./squad/ ./store/ ./textextract/ ./schema/ passed, and the intent, sqlite, native, and cmd modules passed their own go test.
Impact: core go.mod keeps error-code-system only. Nested gitlinks stay. Callers register the native backend through agent-squad/adapters/native.
Refs: D1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:40:58 +00:00

174 lines
5.2 KiB
Go

package squadintent
import (
"context"
"errors"
"testing"
"testing/fstest"
"agent-intent/intent"
"agent-squad/squad"
)
func gateOptions() Options {
return Options{
IntentOptions: intent.Options{
GuardDomains: []string{"guard"},
ProtectedDomains: []string{"guard"},
},
GuardSpecs: []intent.IntentSpec{{
ID: "guard.config_change", Domain: "guard", Version: 1,
Matcher: intent.MatchSpec{IncludesAny: []string{"系统配置", "网关", "改密码"}},
Risk: intent.RiskHigh,
Metadata: map[string]string{
"guidance": "系统配置请到管理界面修改,或联系管理员",
},
}},
}
}
// overlay:一条可路由意图(metadata.blueprint 定向)+ 一对歧义意图。
func overlayFS() fstest.MapFS {
return fstest.MapFS{"intents.json": &fstest.MapFile{Data: []byte(`{
"intents": [
{"id": "demo.report.competitor", "domain": "demo", "version": 1,
"description": "竞品分析报告",
"matcher": {"includes_any": ["竞品", "对标分析"]},
"confidence": {"base": 0.8, "threshold": 0.7},
"handler": "report.competitor",
"metadata": {"blueprint": "competitor_report"}},
{"id": "demo.greet.hello", "domain": "demo", "version": 1, "matcher": {"includes_any": ["你好"]}},
{"id": "demo.greet.hi", "domain": "demo", "version": 1, "matcher": {"includes_any": ["你好"]}}
]
}`)}}
}
func newGate(t *testing.T) *Gate {
t.Helper()
g, err := New(context.Background(), func() Options {
o := gateOptions()
o.FS = overlayFS()
o.Roots = []string{"."}
return o
}())
if err != nil {
t.Fatal(err)
}
return g
}
func decide(t *testing.T, g *Gate, text string, taskKey string) (*squad.IntentDecision, error) {
t.Helper()
return g.Decide(context.Background(), squad.Goal{Text: text}, squad.RunOptions{TaskKey: taskKey})
}
func TestDecideGuardHitRejectsWithGuidance(t *testing.T) {
g := newGate(t)
dec, err := decide(t, g, "帮我把系统网关地址改了", "")
if err != nil {
t.Fatal(err)
}
if dec == nil || dec.Reject == nil {
t.Fatalf("expected rejection, got %+v", dec)
}
if dec.IntentID != "guard.config_change" {
t.Fatalf("intent id: %s", dec.IntentID)
}
if dec.Reject.Guidance == "" {
t.Fatal("guidance missing")
}
}
func TestDecideMatchedRoutesToBlueprint(t *testing.T) {
g := newGate(t)
dec, err := decide(t, g, "给我做一份竞品情况的分析报告", "")
if err != nil {
t.Fatal(err)
}
if dec == nil || dec.BlueprintKey != "competitor_report" {
t.Fatalf("expected blueprint routing, got %+v", dec)
}
if dec.IntentID != "demo.report.competitor" || dec.Handler != "report.competitor" {
t.Fatalf("metadata not carried: %+v", dec)
}
}
func TestDecideAmbiguousAndFallbackPassThrough(t *testing.T) {
g := newGate(t)
for _, tc := range []struct {
name string
input string
}{
{"ambiguous", "你好呀"},
{"fallback", "帮我写一份周报"},
} {
t.Run(tc.name, func(t *testing.T) {
dec, err := decide(t, g, tc.input, "")
if err != nil || dec != nil {
t.Fatalf("expected nil decision (passthrough), got %+v %v", dec, err)
}
})
}
}
func TestDecideExplicitTaskKeyWins(t *testing.T) {
g := newGate(t)
dec, err := decide(t, g, "帮我把系统网关地址改了", "explicit_bp")
if err != nil || dec != nil {
t.Fatalf("explicit task key must bypass gate, got %+v %v", dec, err)
}
}
func TestNewWithoutSourcesReturnsNilGate(t *testing.T) {
g, err := New(context.Background(), Options{IntentOptions: intent.Options{}})
if err != nil {
t.Fatal(err)
}
if g != nil {
t.Fatal("expected nil gate without any intent source")
}
// nil 闸门 Decide 恒为 nil(引擎行为与原实现一致)。
dec, err := g.Decide(context.Background(), squad.Goal{Text: "任意"}, squad.RunOptions{})
if err != nil || dec != nil {
t.Fatalf("nil gate decide: %+v %v", dec, err)
}
}
func TestNewWithEmptyOverlayActsAsDisabled(t *testing.T) {
// T0 第二条路径:提供了来源但总内容为零(无内置守卫 + 空 overlay)→ Decide 恒 nil。
// 注意:若宿主提供了 GuardSpecs,内置守卫始终生效——内置内容是独立于 overlay 的来源。
fsys := fstest.MapFS{"intents.json": &fstest.MapFile{Data: []byte(`{"intents": []}`)}}
g, err := New(context.Background(), Options{
IntentOptions: intent.Options{GuardDomains: []string{"guard"}},
FS: fsys,
Roots: []string{"."},
})
if err != nil {
t.Fatal(err)
}
if g == nil {
t.Fatal("gate expected (source provided)")
}
for _, input := range []string{"帮我把系统网关地址改了", "竞品分析", "你好"} {
dec, err := decide(t, g, input, "")
if err != nil || dec != nil {
t.Fatalf("empty content must never intervene: %q → %+v %v", input, dec, err)
}
}
}
func TestNewFailsClosedOnBadConfig(t *testing.T) {
fsys := fstest.MapFS{"intents.json": &fstest.MapFile{Data: []byte(`{"intents": [{"id": "demo.x", "domain": "demo", "version": 1, "matcher": {}}]}`)}}
o := gateOptions()
o.FS = fsys
o.Roots = []string{"."}
_, err := New(context.Background(), o)
if err == nil {
t.Fatal("expected fail-closed error on invalid config")
}
var ie *intent.Error
if !errors.As(err, &ie) || ie.Code != intent.InvalidSpec {
t.Fatalf("expected intent.invalid_spec, got %v", err)
}
}