package route import ( "testing" "agent-system-backend/internal/intentgate" core "agent-system-core" ) func startCmd(action intentgate.ActionRef) intentgate.Command { return intentgate.Command{Kind: intentgate.CommandStart, Action: action} } func TestDecideAutoPrefersCommandThenWorkflow(t *testing.T) { in := intentgate.MatchResult{Branch: "matched", ID: "demo.x", Commands: []intentgate.Command{startCmd(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.x"})}} d := Decide(in, Env{HasCommand: func(string) bool { return true }}) if d.Kind != KindCommand || d.CommandID != "demo.x" { t.Fatalf("auto command = %+v", d) } d = Decide(in, Env{ HasCommand: func(string) bool { return false }, WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true }, }) if d.Kind != KindWorkflow || d.Workflow.ID != "wf" { t.Fatalf("auto workflow = %+v", d) } d = Decide(in, Env{}) if d.Kind != KindTurn || !d.StartFound { t.Fatalf("auto turn = %+v", d) } } func TestDecideExplicitKinds(t *testing.T) { mk := func(kind, id string) intentgate.MatchResult { return intentgate.MatchResult{Branch: "matched", ID: "demo.x", Commands: []intentgate.Command{startCmd(intentgate.ActionRef{Kind: kind, ID: id})}} } if d := Decide(mk(intentgate.ActionCommand, "cmd-1"), Env{HasCommand: func(id string) bool { return id == "cmd-1" }}); d.Kind != KindCommand || d.CommandID != "cmd-1" { t.Fatalf("explicit command = %+v", d) } d := Decide(mk(intentgate.ActionCommand, "nope"), Env{ WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true }, }) if d.Kind != KindTurn || d.Reason == "" { t.Fatalf("unresolvable command = %+v", d) } if d := Decide(mk(intentgate.ActionWorkflow, "wf-1"), Env{ WorkflowFor: func(id string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: id}, true }, }); d.Kind != KindWorkflow || d.Workflow.ID != "wf-1" { t.Fatalf("explicit workflow = %+v", d) } if d := Decide(mk(intentgate.ActionBlueprint, "bp-1"), Env{SquadReady: true}); d.Kind != KindSquad || d.Blueprint != "bp-1" { t.Fatalf("blueprint = %+v", d) } if d := Decide(mk(intentgate.ActionBlueprint, "bp-1"), Env{}); d.Kind != KindTurn || d.Reason == "" { t.Fatalf("blueprint without squad = %+v", d) } } func TestDecideNonMatchedAndLegacyFallback(t *testing.T) { if d := Decide(intentgate.MatchResult{Branch: "fallback"}, Env{}); d.Kind != KindTurn { t.Fatalf("fallback = %+v", d) } // matched 但命令列表为空(旧消费方):按 TopID 走 auto,与现网一致。 d := Decide(intentgate.MatchResult{Branch: "matched", ID: "demo.x"}, Env{HasCommand: func(string) bool { return true }}) if d.Kind != KindCommand || d.StartFound { t.Fatalf("legacy auto = %+v", d) } } func TestSlotsFromStartCommand(t *testing.T) { in := intentgate.MatchResult{Branch: "matched", ID: "demo.x", Commands: []intentgate.Command{{ Kind: intentgate.CommandStart, Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.x"}, Slots: map[string]string{"topic": "电机"}, }}} d := Decide(in, Env{HasCommand: func(string) bool { return true }}) if d.Slots["topic"] != "电机" { t.Fatalf("slots = %v", d.Slots) } } // ---- F09:槽位准入——业务动作必须齐全且合法,否则转澄清 ---- func matchedCommand(action intentgate.ActionRef) intentgate.MatchResult { return intentgate.MatchResult{Branch: "matched", ID: action.ID, Commands: []intentgate.Command{startCmd(action)}} } func hasCommandEnv() Env { return Env{HasCommand: func(string) bool { return true }} } func TestMissingSlotsBlockCommandDispatch(t *testing.T) { in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"}) in.Missing = []intentgate.SlotQuestion{{Key: "topic", Question: "主题是什么?"}} d := Decide(in, hasCommandEnv()) if d.Kind != KindTurn || d.CommandID != "" { t.Fatalf("缺必填槽位不得派发命令:%+v", d) } if !d.Clarification.Needed || d.Clarification.Command != "demo.brief" { t.Fatalf("澄清诉求缺失:%+v", d.Clarification) } if d.Clarification.Question != "主题是什么?" || len(d.Clarification.Missing) != 1 { t.Fatalf("澄清提问未透传:%+v", d.Clarification) } if d.Reason == "" || !d.StartFound { t.Fatalf("回落原因或 Start 标记缺失:%+v", d) } } func TestInvalidSlotsBlockCommandWithReason(t *testing.T) { in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.mode"}) in.Invalid = []intentgate.SlotDefect{{Key: "mode", Reason: "value not in enum", Question: "要哪种模式?", Invalid: true}} d := Decide(in, hasCommandEnv()) if d.Kind != KindTurn || d.CommandID != "" { t.Fatalf("非法槽位不得派发命令:%+v", d) } if len(d.Clarification.Invalid) != 1 || d.Clarification.Invalid[0].Reason != "value not in enum" { t.Fatalf("非法原因未透传:%+v", d.Clarification) } if d.Clarification.Question != "要哪种模式?" { t.Fatalf("澄清提问未透传:%+v", d.Clarification) } } func TestCompleteSlotsDispatchCommand(t *testing.T) { in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"}) in.Slots = map[string]string{"topic": "核能"} in.Risk, in.RequiresConfirm = "high", true d := Decide(in, hasCommandEnv()) if d.Kind != KindCommand || d.CommandID != "demo.brief" { t.Fatalf("完整合法槽位应派发:%+v", d) } if d.Risk != "high" || !d.RequiresConfirm || d.Clarification.Needed { t.Fatalf("风险元数据未透传或误报澄清:%+v", d) } } // 工作流是「边收集边执行」的草稿:缺失清单照常启动,由流程自身追问。 func TestWorkflowDraftStartsWhileGatheringSlots(t *testing.T) { in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"}) in.Missing = []intentgate.SlotQuestion{{Key: "topic", Question: "主题是什么?"}} d := Decide(in, Env{ HasCommand: func(string) bool { return false }, WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true }, }) if d.Kind != KindWorkflow || d.Workflow.ID != "wf" { t.Fatalf("工作流草稿应照常启动:%+v", d) } if len(d.Missing) != 1 || d.Clarification.Needed { t.Fatalf("缺失清单应随决策透出:%+v", d) } } // 显式的取消/改槽命令不得被静默当成已支持:回落普通回合,如实记录。 func TestUnsupportedLibraryCommandsAreNotClaimed(t *testing.T) { in := intentgate.MatchResult{Branch: "matched", ID: "demo.brief", Commands: []intentgate.Command{ {Kind: intentgate.CommandStart, Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"}}, {Kind: intentgate.CommandCancel, Target: "demo.brief"}, }} d := Decide(in, hasCommandEnv()) if d.Kind != KindTurn || d.CommandID != "" { t.Fatalf("未消费的库层命令不得派发业务动作:%+v", d) } if len(d.Unsupported) != 1 || d.Unsupported[0] != string(intentgate.CommandCancel) { t.Fatalf("未消费命令种类未如实记录:%+v", d.Unsupported) } if d.Reason == "" { t.Fatalf("回落原因缺失:%+v", d) } // 只有改槽命令时同样回落,且不得声称支持。 only := intentgate.MatchResult{Branch: "matched", ID: "demo.brief", Commands: []intentgate.Command{ {Kind: intentgate.CommandSetSlot, Name: "topic", Value: "核能"}, }} got := Decide(only, hasCommandEnv()) if got.Kind != KindTurn || got.CommandID != "" || len(got.Unsupported) != 1 { t.Fatalf("set_slot 不得被当成可派发:%+v", got) } } // 澄清提问优先用 spec 声明,缺失时回落库层 clarify 命令,最后按键名兜底。 func TestClarificationQuestionSources(t *testing.T) { in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.mode"}) in.Missing = []intentgate.SlotQuestion{{Key: "mode"}} in.Commands = append(in.Commands, intentgate.Command{Kind: intentgate.CommandClarify, Question: "要哪种模式?"}) if q := Decide(in, hasCommandEnv()).Clarification.Question; q != "要哪种模式?" { t.Fatalf("库层澄清提问未用上:%q", q) } in.Commands = in.Commands[:1] if q := Decide(in, hasCommandEnv()).Clarification.Question; q != "请补充:mode" { t.Fatalf("兜底提问:%q", q) } } // 旧消费方(命令列表为空)仍按 TopID 走 auto,且必须槽位完整。 func TestLegacyAutoStillNeedsCompleteSlots(t *testing.T) { in := intentgate.MatchResult{Branch: "matched", ID: "demo.x", Missing: []intentgate.SlotQuestion{{Key: "topic", Question: "主题是什么?"}}} d := Decide(in, hasCommandEnv()) if d.Kind != KindTurn || d.CommandID != "" || !d.Clarification.Needed { t.Fatalf("旧路径也必须过槽位准入:%+v", d) } }