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>
174 lines
5.2 KiB
Go
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)
|
|
}
|
|
}
|