Why: I1 WP2 - one explicit action field outranks the four legacy action keys (Handler, metadata.blueprint, command-id, workflow IntentID) so hosts migrate to a single derivation path. Evidence: go vet clean; go test ./... all ok, including declared action winning the Start command and kind/id validation rejections. Impact: IntentSpec gains Action (command/workflow/blueprint/handler); Candidate carries it through; actionOf prefers it, then blueprint, then Handler, then auto. Legacy keys keep working. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I1 WP2
206 lines
6.6 KiB
Go
206 lines
6.6 KiB
Go
package intent
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
)
|
||
|
||
// buildCommandSnap 组装命令测试用快照:一个带必填槽位的意图与一个无槽位意图。
|
||
func buildCommandSnap(t *testing.T) *Snapshot {
|
||
t.Helper()
|
||
b := NewRegistryBuilder(Options{})
|
||
if err := b.Register(IntentSpec{
|
||
ID: "report.generate", Domain: "report", Version: 1,
|
||
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
|
||
Slots: []SlotSpec{
|
||
{Key: "topic", Required: true, Pattern: `主题是(.+)`, Question: "要查询哪个主题?"},
|
||
},
|
||
Handler: "reportHandler",
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := b.Register(IntentSpec{
|
||
ID: "report.chat", Domain: "report", Version: 1,
|
||
Matcher: MatchSpec{IncludesAny: []string{"闲聊一下"}},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
reg, err := b.Build()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return reg.Snapshot()
|
||
}
|
||
|
||
func recognize(t *testing.T, snap *Snapshot, input string) Result {
|
||
t.Helper()
|
||
res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), input, snap)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return res
|
||
}
|
||
|
||
// TestCommandsMatchedFullSlots:槽位齐全 → 单条 Start,ActionRef 取 Handler。
|
||
func TestCommandsMatchedFullSlots(t *testing.T) {
|
||
snap := buildCommandSnap(t)
|
||
res := recognize(t, snap, "帮我生成报告,主题是电机")
|
||
if res.Branch != BranchMatched {
|
||
t.Fatalf("branch = %s", res.Branch)
|
||
}
|
||
if len(res.Commands) != 1 {
|
||
t.Fatalf("commands = %+v", res.Commands)
|
||
}
|
||
cmd := res.Commands[0]
|
||
if cmd.Kind != CommandStart || cmd.Action.Kind != ActionHandler || cmd.Action.ID != "reportHandler" {
|
||
t.Fatalf("start = %+v", cmd)
|
||
}
|
||
if cmd.Slots["topic"] != "电机" {
|
||
t.Fatalf("slots = %v", cmd.Slots)
|
||
}
|
||
}
|
||
|
||
// TestCommandsMatchedMissingSlot:必填缺失 → Start(带已抽槽)+ Clarify(含建议提问)。
|
||
func TestCommandsMatchedMissingSlot(t *testing.T) {
|
||
snap := buildCommandSnap(t)
|
||
res := recognize(t, snap, "帮我生成报告")
|
||
if len(res.Commands) != 2 {
|
||
t.Fatalf("commands = %+v", res.Commands)
|
||
}
|
||
start, clarify := res.Commands[0], res.Commands[1]
|
||
if start.Kind != CommandStart {
|
||
t.Fatalf("first = %+v", start)
|
||
}
|
||
if clarify.Kind != CommandClarify || len(clarify.Missing) != 1 || clarify.Missing[0] != "topic" {
|
||
t.Fatalf("clarify = %+v", clarify)
|
||
}
|
||
if clarify.Question != "要查询哪个主题?" {
|
||
t.Fatalf("question = %q", clarify.Question)
|
||
}
|
||
}
|
||
|
||
// TestCommandsExpectationSetSlotPlusStart:预期命中 → SetSlot + Start(验收形态)。
|
||
func TestCommandsExpectationSetSlotPlusStart(t *testing.T) {
|
||
snap := buildCommandSnap(t)
|
||
exp := listExpectation{ids: []string{"report.generate"}}
|
||
res, err := NewRecognizer(Options{}, exp, nil).Recognize(context.Background(), "生成报告,主题是电机", snap)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Branch != BranchExpectationHit {
|
||
t.Fatalf("branch = %s", res.Branch)
|
||
}
|
||
var setSlot, start *Command
|
||
for i := range res.Commands {
|
||
switch res.Commands[i].Kind {
|
||
case CommandSetSlot:
|
||
setSlot = &res.Commands[i]
|
||
case CommandStart:
|
||
start = &res.Commands[i]
|
||
}
|
||
}
|
||
if setSlot == nil || start == nil {
|
||
t.Fatalf("commands = %+v", res.Commands)
|
||
}
|
||
if setSlot.Name != "topic" || setSlot.Value != "电机" {
|
||
t.Fatalf("set_slot = %+v", *setSlot)
|
||
}
|
||
if start.Action.Kind != ActionHandler || start.Slots["topic"] != "电机" {
|
||
t.Fatalf("start = %+v", *start)
|
||
}
|
||
}
|
||
|
||
// TestCommandsAmbiguousClarify:标歧 → 单条 Clarify 携带候选 ID。
|
||
func TestCommandsAmbiguousClarify(t *testing.T) {
|
||
b := NewRegistryBuilder(Options{})
|
||
if err := b.Register(IntentSpec{ID: "demo.alpha", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"模糊词"}}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := b.Register(IntentSpec{ID: "demo.beta", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"模糊词"}}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
reg, err := b.Build()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
res := recognize(t, reg.Snapshot(), "模糊词")
|
||
if res.Branch != BranchAmbiguous {
|
||
t.Fatalf("branch = %s", res.Branch)
|
||
}
|
||
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandClarify {
|
||
t.Fatalf("commands = %+v", res.Commands)
|
||
}
|
||
if len(res.Commands[0].Candidates) != 2 {
|
||
t.Fatalf("candidates = %v", res.Commands[0].Candidates)
|
||
}
|
||
}
|
||
|
||
// TestCommandsFallbackEmpty:fallback 不产出命令。
|
||
func TestCommandsFallbackEmpty(t *testing.T) {
|
||
snap := buildCommandSnap(t)
|
||
res := recognize(t, snap, "完全不相关的一句话")
|
||
if res.Branch != BranchFallback || len(res.Commands) != 0 {
|
||
t.Fatalf("fallback commands = %+v", res.Commands)
|
||
}
|
||
}
|
||
|
||
// TestActionRefDerivation:blueprint 键优先于 Handler;都没有回落 auto。
|
||
func TestActionRefDerivation(t *testing.T) {
|
||
if got := actionOf(Candidate{ID: "x", Metadata: map[string]string{"blueprint": "bp-1"}, Handler: "h"}); got.Kind != ActionBlueprint || got.ID != "bp-1" {
|
||
t.Fatalf("blueprint = %+v", got)
|
||
}
|
||
if got := actionOf(Candidate{ID: "x", Handler: "h"}); got.Kind != ActionHandler || got.ID != "h" {
|
||
t.Fatalf("handler = %+v", got)
|
||
}
|
||
if got := actionOf(Candidate{ID: "x"}); got.Kind != ActionAuto || got.ID != "x" {
|
||
t.Fatalf("auto = %+v", got)
|
||
}
|
||
}
|
||
|
||
type listExpectation struct{ ids []string }
|
||
|
||
func (s listExpectation) Pending(ctx context.Context) *PendingExpectation {
|
||
return &PendingExpectation{IntentIDs: s.ids}
|
||
}
|
||
|
||
// TestSpecActionDeclaredWins:spec.action 显式声明在 ActionRef 推导中胜出(WP2)。
|
||
func TestSpecActionDeclaredWins(t *testing.T) {
|
||
b := NewRegistryBuilder(Options{})
|
||
if err := b.Register(IntentSpec{
|
||
ID: "report.explicit", Domain: "report", Version: 1,
|
||
Matcher: MatchSpec{IncludesAny: []string{"显式动作"}},
|
||
Action: &ActionRef{Kind: ActionWorkflow, ID: "wf-report"},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
reg, err := b.Build()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
res := recognize(t, reg.Snapshot(), "显式动作")
|
||
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandStart {
|
||
t.Fatalf("commands = %+v", res.Commands)
|
||
}
|
||
if got := res.Commands[0].Action; got.Kind != ActionWorkflow || got.ID != "wf-report" {
|
||
t.Fatalf("action = %+v", got)
|
||
}
|
||
}
|
||
|
||
// TestSpecActionValidation:action.kind 非法与 id 为空都被拒绝。
|
||
func TestSpecActionValidation(t *testing.T) {
|
||
bad := IntentSpec{
|
||
ID: "report.bad", Domain: "report", Version: 1,
|
||
Matcher: MatchSpec{IncludesAny: []string{"x"}},
|
||
Action: &ActionRef{Kind: "bogus", ID: "y"},
|
||
}
|
||
b := NewRegistryBuilder(Options{})
|
||
if err := b.Register(bad); err == nil {
|
||
t.Fatal("bogus action kind accepted")
|
||
}
|
||
bad.Action = &ActionRef{Kind: ActionCommand, ID: ""}
|
||
b2 := NewRegistryBuilder(Options{})
|
||
if err := b2.Register(bad); err == nil {
|
||
t.Fatal("empty action id accepted")
|
||
}
|
||
}
|