Files
weichen 916ee0733f
ci / vet-and-test (pull_request) Successful in 17s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Add the explicit spec.action declaration for action-key unification
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
2026-10-07 07:31:05 +00:00

206 lines
6.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}