Why: token budget let the current tool batch run, compaction could split a tool pair or race an append, writes were not crash-atomic, and several MCP and session edges only existed as comments. Evidence: GOWORK=off go test -count=1 -race ./runtime/ ./runtime/llm/ ./runtime/session/ ./runtime/tools/ ./runtime/mcp/ ./adapters/native/ ./adapters/intentgate/ passed, including the TestR2* cases for budget, replacement, cancel, compaction, SSE, UTF-8, schema, MCP retry, session terminate, poison reload, and registry reload. Impact: over-budget tool calls are skipped and paired; Stop.Replacement is appended to the session; the intent gate can follow Registry.Reload. Refs: N5, N6, N7, N8, N9, N11, N12, N15, N16, N17, N18, N19, N21, I9 Co-authored-by: Cursor <cursoragent@cursor.com>
198 lines
6.6 KiB
Go
198 lines
6.6 KiB
Go
package intentgate
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"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")
|
|
}
|
|
}
|
|
|
|
func TestR2RegistryReloadIsVisibleWithoutRewrap(t *testing.T) {
|
|
fsys := fstest.MapFS{"intents.json": &fstest.MapFile{Data: []byte(`{"intents":[{"id":"guard.block","domain":"guard","version":1,"matcher":{"includes_any":["旧词"]}}]}`)}}
|
|
reg, err := intent.NewRegistryBuilder(intent.Options{GuardDomains: []string{"guard"}}).BuildFS(context.Background(), fsys, ".")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
next := &stubBackend{name: "native"}
|
|
var hits int
|
|
be := WrapRegistry(next, newRec(), reg, func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
|
|
hits++
|
|
return &exec.Result{Success: false, ErrorReason: "blocked"}, nil, true
|
|
})
|
|
if _, err := be.Execute(context.Background(), exec.Request{Prompt: "请处理新词"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hits != 0 || next.calls != 1 {
|
|
t.Fatalf("before reload hits=%d calls=%d", hits, next.calls)
|
|
}
|
|
fsys["intents.json"].Data = []byte(`{"intents":[{"id":"guard.block","domain":"guard","version":2,"matcher":{"includes_any":["新词"]}}]}`)
|
|
if _, err := reg.Reload(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := be.Execute(context.Background(), exec.Request{Prompt: "请处理新词"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hits != 1 || next.calls != 1 {
|
|
t.Fatalf("after reload hits=%d calls=%d", hits, next.calls)
|
|
}
|
|
}
|