Why: F09 - invalid slot information was dropped at the gate, the router saw only Start, and the command entrance passed untyped text, so invalid-slot recognitions could still reach business execution paths. Evidence: intentgate/route/server tests pass (verify-u6.json); admission regression cases added. Impact: invalid or missing slots never start executable actions; clarification flows unchanged; cmdbus confirmation untouched. Refs: feature/agent-stack-audit-remediation-b1 U6
205 lines
8.6 KiB
Go
205 lines
8.6 KiB
Go
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)
|
|
}
|
|
}
|