Why: A hit intent should start the bound workflow, and a command that shares the same id still has to win. Evidence: go test ./internal/intentgate/ ./internal/runmgr/ ./internal/server/ passed. Recognize keeps slots and the missing-slot question. StartWorkflowRun copies the definition and selects the workflow runner even when mock is on. Impact: Ordinary turns are unchanged. The workflow runner body is still a stub until the executor lands. Refs: feature/agent-harness-p4 Co-authored-by: Cursor <cursoragent@cursor.com>
94 lines
2.9 KiB
Go
94 lines
2.9 KiB
Go
package intentgate
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"testing"
|
||
)
|
||
|
||
func raw(t *testing.T, v any) json.RawMessage {
|
||
t.Helper()
|
||
b, err := json.Marshal(v)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return b
|
||
}
|
||
|
||
// spec 采用 agent-intent overlay 形态(matcher 嵌套)。
|
||
func demoSpec(t *testing.T) json.RawMessage {
|
||
return raw(t, map[string]any{
|
||
"id": "demo.show", "domain": "demo", "version": 1,
|
||
"description": "示例意图",
|
||
"matcher": map[string]any{"includes_any": []string{"示例", "演示"}},
|
||
})
|
||
}
|
||
|
||
func TestGateMatchAndFallback(t *testing.T) {
|
||
g := New()
|
||
if _, id := g.Match(context.Background(), "任意"); id != "" || g.Enabled() {
|
||
t.Error("empty gate should passthrough")
|
||
}
|
||
if err := g.Rebuild(context.Background(), []json.RawMessage{demoSpec(t)}); err != nil {
|
||
t.Fatalf("rebuild: %v", err)
|
||
}
|
||
if !g.Enabled() || g.SpecCount() != 1 {
|
||
t.Fatalf("enabled=%v specs=%d", g.Enabled(), g.SpecCount())
|
||
}
|
||
branch, top := g.Match(context.Background(), "来个示例看看")
|
||
if branch != "matched" || top != "demo.show" {
|
||
t.Errorf("want matched/demo_intent, got %s/%s", branch, top)
|
||
}
|
||
branch, _ = g.Match(context.Background(), "完全无关的一句话")
|
||
if branch != "fallback" {
|
||
t.Errorf("want fallback, got %s", branch)
|
||
}
|
||
}
|
||
|
||
// fail-closed:坏 spec 保留上一快照。
|
||
func TestGateFailClosed(t *testing.T) {
|
||
g := New()
|
||
if err := g.Rebuild(context.Background(), []json.RawMessage{demoSpec(t)}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := g.Rebuild(context.Background(), []json.RawMessage{json.RawMessage(`{"id":`)}); err == nil {
|
||
t.Error("broken spec should error")
|
||
}
|
||
if !g.Enabled() || g.SpecCount() != 1 {
|
||
t.Error("previous snapshot must be kept on failed rebuild")
|
||
}
|
||
// 空重建 = 显式清空(卸载全部插件意图)。
|
||
if err := g.Rebuild(context.Background(), nil); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if g.Enabled() {
|
||
t.Error("empty rebuild should disable gate")
|
||
}
|
||
}
|
||
|
||
func TestRecognizeKeepsSlots(t *testing.T) {
|
||
g := New()
|
||
spec := raw(t, map[string]any{
|
||
"id": "demo.brief", "domain": "demo", "version": 1,
|
||
"matcher": map[string]any{"includes_any": []string{"简报"}},
|
||
"slots": []map[string]any{{
|
||
"key": "topic", "required": true, "pattern": `主题[::]\s*(\S+)`,
|
||
"question": "主题是什么?",
|
||
}},
|
||
})
|
||
if err := g.Rebuild(context.Background(), []json.RawMessage{spec}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
miss := g.Recognize(context.Background(), "写一份简报")
|
||
if miss.Branch != "matched" || miss.ID != "demo.brief" || len(miss.Missing) != 1 || miss.Missing[0].Question != "主题是什么?" {
|
||
t.Fatalf("%+v", miss)
|
||
}
|
||
hit := g.Recognize(context.Background(), "主题:核能 写一份简报")
|
||
if hit.Slots["topic"] != "核能" || len(hit.Missing) != 0 {
|
||
t.Fatalf("%+v", hit)
|
||
}
|
||
if branch, id := g.Match(context.Background(), "写一份简报"); branch != "matched" || id != "demo.brief" {
|
||
t.Fatalf("Match wrapper = %s %s", branch, id)
|
||
}
|
||
}
|