package router import ( "testing" ) // TestResolveActionCandidate_RouteSource pins that an act with a resolved // CapabilitySelection produces a candidate from the route, not the matcher. func TestResolveActionCandidate_RouteSource(t *testing.T) { dec := Decision{ Intent: IntentAct, Slots: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true}, CapabilitySelection: CapabilitySelection{ Fn: "restart", Args: []string{"nginx"}, Resolved: true, Method: ActionResolutionExtractorRaw, }, } c := ResolveActionCandidate(dec, nil) if !c.ActionResolved() { t.Fatal("expected resolved candidate") } if c.Fn != "restart" { t.Errorf("Fn = %q, want restart", c.Fn) } if len(c.Args) != 1 || c.Args[0] != "nginx" { t.Errorf("Args = %v, want [nginx]", c.Args) } if c.Source != ActionSourceRoute { t.Errorf("Source = %q, want route", c.Source) } } // TestResolveActionCandidate_MatcherSource pins that an act without Fn // invokes the matcher and produces a candidate from it. func TestResolveActionCandidate_MatcherSource(t *testing.T) { m := DefaultActMatcher{Fns: []string{"restart", "stop"}} dec := Decision{ Intent: IntentAct, Slots: Slots{Text: "restart nginx"}, } c := ResolveActionCandidate(dec, m) if !c.ActionResolved() { t.Fatal("expected resolved candidate") } if c.Fn != "restart" { t.Errorf("Fn = %q, want restart", c.Fn) } if c.Source != ActionSourceMatcher { t.Errorf("Source = %q, want matcher", c.Source) } } // TestResolveActionCandidate_MatcherMiss pins that a matcher miss produces // an unresolved candidate. func TestResolveActionCandidate_MatcherMiss(t *testing.T) { m := DefaultActMatcher{Fns: []string{"restart", "stop"}} dec := Decision{ Intent: IntentAct, Slots: Slots{Text: "deploy the thing"}, } c := ResolveActionCandidate(dec, m) if c.ActionResolved() { t.Fatal("expected unresolved candidate") } if c.Fn != "" { t.Errorf("Fn = %q, want empty", c.Fn) } if c.Source != "" { t.Errorf("Source = %q, want empty", c.Source) } } // TestResolveActionCandidate_NonAct pins that a non-act decision produces // an empty candidate. func TestResolveActionCandidate_NonAct(t *testing.T) { dec := Decision{ Intent: IntentFact, Slots: Slots{Key: "water", Value: "drank", HasKey: true}, } c := ResolveActionCandidate(dec, nil) if c.ActionResolved() { t.Fatal("expected unresolved candidate for non-act") } } // TestResolveActionCandidate_Stage0Match pins that a stage-0 act (which // has a resolved CapabilitySelection) produces a route-sourced candidate. func TestResolveActionCandidate_Stage0Match(t *testing.T) { dec := Decision{ Intent: IntentAct, Stage: 0, Confidence: 1.0, Slots: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true}, Producer: RouteProducerGrammar, CapabilitySelection: CapabilitySelection{ Fn: "restart", Args: []string{"nginx"}, Resolved: true, Method: ActionResolutionGrammarMatcher, InputKind: SelectionDeterministic, Producer: RouteProducerGrammar, Confidence: 1.0, }, } c := ResolveActionCandidate(dec, nil) if !c.ActionResolved() { t.Fatal("expected resolved candidate") } if c.Source != ActionSourceRoute { t.Errorf("Source = %q, want route", c.Source) } if c.Producer != RouteProducerGrammar { t.Errorf("Producer = %q, want grammar", c.Producer) } if c.Confidence != 1.0 { t.Errorf("Confidence = %f, want 1.0", c.Confidence) } } // TestResolveActionCandidate_LearnedRouterNoFn pins that a learned-router // act without Fn falls through to the matcher. func TestResolveActionCandidate_LearnedRouterNoFn(t *testing.T) { m := DefaultActMatcher{Fns: []string{"restart", "stop"}} dec := Decision{ Intent: IntentAct, Stage: 1, Confidence: 0.85, Slots: Slots{Text: "restart the server"}, Producer: RouteProducerLLM, } c := ResolveActionCandidate(dec, m) if !c.ActionResolved() { t.Fatal("expected resolved candidate from matcher fallback") } if c.Fn != "restart" { t.Errorf("Fn = %q, want restart", c.Fn) } if c.Source != ActionSourceMatcher { t.Errorf("Source = %q, want matcher", c.Source) } } // TestResolveActionCandidate_AliasMatch pins that aliases resolve through // the matcher path. func TestResolveActionCandidate_AliasMatch(t *testing.T) { m := DefaultActMatcher{ Fns: []string{"restart"}, Aliases: map[string][]string{"restart": {"перезагрузи"}}, } dec := Decision{ Intent: IntentAct, Slots: Slots{Text: "перезагрузи роутер"}, } c := ResolveActionCandidate(dec, m) if !c.ActionResolved() { t.Fatal("expected resolved candidate from alias match") } if c.Fn != "restart" { t.Errorf("Fn = %q, want restart", c.Fn) } if len(c.Args) != 1 || c.Args[0] != "роутер" { t.Errorf("Args = %v, want [роутер]", c.Args) } } // --- ValidateActionCandidate tests --- // TestValidateActionCandidate_UnresolvedEmptyFn pins that an empty Fn // produces an unresolved result (not invalid). func TestValidateActionCandidate_UnresolvedEmptyFn(t *testing.T) { c := ActionCandidate{} v := ValidateActionCandidate(c) if v.Status != ActionUnresolved { t.Errorf("Status = %q, want unresolved", v.Status) } if v.Valid() { t.Error("unresolved must not be valid") } } // TestValidateActionCandidate_UnresolvedMatcherMiss pins that a matcher-miss // candidate (Fn empty, source empty) is unresolved. func TestValidateActionCandidate_UnresolvedMatcherMiss(t *testing.T) { c := ActionCandidate{ Producer: RouteProducerLLM, Confidence: 0.5, } v := ValidateActionCandidate(c) if v.Status != ActionUnresolved { t.Errorf("Status = %q, want unresolved", v.Status) } } // TestValidateActionCandidate_ValidRoute pins that a route-resolved candidate // with a non-empty Fn is valid. func TestValidateActionCandidate_ValidRoute(t *testing.T) { c := ActionCandidate{ Fn: "restart", Args: []string{"nginx"}, Source: ActionSourceRoute, } v := ValidateActionCandidate(c) if v.Unresolved() { t.Error("expected resolved, not unresolved") } if v.Status != ActionValid { t.Errorf("Status = %q, want valid; issues: %v", v.Status, v.Issues) } } // TestValidateActionCandidate_ValidMatcher pins that a matcher-resolved // candidate with a non-empty Fn is valid. func TestValidateActionCandidate_ValidMatcher(t *testing.T) { c := ActionCandidate{ Fn: "status", Source: ActionSourceMatcher, } v := ValidateActionCandidate(c) if v.Unresolved() { t.Error("expected resolved, not unresolved") } if v.Status != ActionValid { t.Errorf("Status = %q, want valid; issues: %v", v.Status, v.Issues) } } // TestValidateActionCandidate_ValidNoArgs pins that a zero-arg tool is valid. func TestValidateActionCandidate_ValidNoArgs(t *testing.T) { c := ActionCandidate{ Fn: "status", Source: ActionSourceRoute, } v := ValidateActionCandidate(c) if v.Unresolved() { t.Error("expected resolved, not unresolved") } if v.Status != ActionValid { t.Errorf("Status = %q, want valid; issues: %v", v.Status, v.Issues) } } // TestValidateActionCandidate_BlankFn pins that a whitespace-only Fn is // structurally invalid (not unresolved). func TestValidateActionCandidate_BlankFn(t *testing.T) { c := ActionCandidate{ Fn: " ", Source: ActionSourceRoute, } v := ValidateActionCandidate(c) if v.Unresolved() { t.Error("blank Fn should be invalid, not unresolved") } if v.Valid() { t.Error("blank Fn should be invalid") } if v.Status != ActionInvalidArgument { t.Errorf("Status = %q, want invalid_argument", v.Status) } if len(v.Issues) != 1 { t.Fatalf("expected 1 issue, got %d", len(v.Issues)) } if v.Issues[0].Field != FieldFn { t.Errorf("issue field = %q, want fn", v.Issues[0].Field) } } // --- additional typed status tests --- // TestValidateActionCandidate_StatusConstants pins that the five status // constants are distinct and non-empty. func TestValidateActionCandidate_StatusConstants(t *testing.T) { statuses := []ActionValidationStatus{ ActionValid, ActionUnresolved, ActionMissingArgument, ActionInvalidArgument, ActionAmbiguousTarget, } seen := make(map[ActionValidationStatus]bool) for _, s := range statuses { if s == "" { t.Error("status constant is empty") } if seen[s] { t.Errorf("status %q appears twice", s) } seen[s] = true } } // TestValidateActionCandidate_UnresolvedBackwardCompat pins that the // Unresolved() method returns true only for ActionUnresolved status. func TestValidateActionCandidate_UnresolvedBackwardCompat(t *testing.T) { tests := []struct { name string status ActionValidationStatus want bool }{ {"unresolved", ActionUnresolved, true}, {"valid", ActionValid, false}, {"missing_arg", ActionMissingArgument, false}, {"invalid_arg", ActionInvalidArgument, false}, {"ambiguous", ActionAmbiguousTarget, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := ActionValidationResult{Status: tt.status} if got := r.Unresolved(); got != tt.want { t.Errorf("Unresolved() = %v, want %v", got, tt.want) } }) } } // TestValidateActionCandidate_ValidBackwardCompat pins that the Valid() // method returns true only for ActionValid status. func TestValidateActionCandidate_ValidBackwardCompat(t *testing.T) { tests := []struct { name string status ActionValidationStatus want bool }{ {"unresolved", ActionUnresolved, false}, {"valid", ActionValid, true}, {"missing_arg", ActionMissingArgument, false}, {"invalid_arg", ActionInvalidArgument, false}, {"ambiguous", ActionAmbiguousTarget, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := ActionValidationResult{Status: tt.status} if got := r.Valid(); got != tt.want { t.Errorf("Valid() = %v, want %v", got, tt.want) } }) } } // TestValidateActionCandidate_IssuesNilOnValid pins that Valid results have // nil Issues. func TestValidateActionCandidate_IssuesNilOnValid(t *testing.T) { c := ActionCandidate{Fn: "restart", Args: []string{"nginx"}} v := ValidateActionCandidate(c) if v.Issues != nil { t.Errorf("valid result has issues: %v", v.Issues) } } // TestValidateActionCandidate_IssuesNilOnUnresolved pins that Unresolved // results have nil Issues. func TestValidateActionCandidate_IssuesNilOnUnresolved(t *testing.T) { c := ActionCandidate{} v := ValidateActionCandidate(c) if v.Issues != nil { t.Errorf("unresolved result has issues: %v", v.Issues) } } // TestValidateActionCandidate_IssuesPopulatedOnInvalid pins that Invalid // results carry populated Issues. func TestValidateActionCandidate_IssuesPopulatedOnInvalid(t *testing.T) { c := ActionCandidate{Fn: "\t\n"} v := ValidateActionCandidate(c) if len(v.Issues) == 0 { t.Error("invalid result has no issues") } } // --- ResolutionMethod provenance tests --- // TestResolveActionCandidate_GrammarFixed pins that a stage-0 grammar that // hardcodes fn (praxis/task-status) carries grammar_fixed provenance. func TestResolveActionCandidate_GrammarFixed(t *testing.T) { dec := Decision{ Intent: IntentAct, Slots: Slots{ Fn: "resolve_item", HasFn: true, ResolvedBy: ActionResolutionGrammarFixed, }, CapabilitySelection: CapabilitySelection{ Fn: "resolve_item", Resolved: true, Method: ActionResolutionGrammarFixed, InputKind: SelectionDeterministic, }, } c := ResolveActionCandidate(dec, nil) if !c.ActionResolved() { t.Fatal("expected resolved candidate") } if c.ResolvedBy != ActionResolutionGrammarFixed { t.Errorf("ResolvedBy = %q, want grammar_fixed", c.ResolvedBy) } if c.Fn != "resolve_item" { t.Errorf("Fn = %q, want resolve_item", c.Fn) } } // TestResolveActionCandidate_GrammarMatcher pins that a stage-0 grammar that // invokes the ActMatcher (wakeword-act) carries grammar_matcher provenance. func TestResolveActionCandidate_GrammarMatcher(t *testing.T) { dec := Decision{ Intent: IntentAct, Slots: Slots{ Fn: "restart", Args: []string{"nginx"}, HasFn: true, ResolvedBy: ActionResolutionGrammarMatcher, }, CapabilitySelection: CapabilitySelection{ Fn: "restart", Args: []string{"nginx"}, Resolved: true, Method: ActionResolutionGrammarMatcher, InputKind: SelectionDeterministic, }, } c := ResolveActionCandidate(dec, nil) if !c.ActionResolved() { t.Fatal("expected resolved candidate") } if c.ResolvedBy != ActionResolutionGrammarMatcher { t.Errorf("ResolvedBy = %q, want grammar_matcher", c.ResolvedBy) } } // TestResolveActionCandidate_ExtractorRaw pins that a classifier/heads-routed // act whose extractor matched carries extractor_raw provenance. func TestResolveActionCandidate_ExtractorRaw(t *testing.T) { dec := Decision{ Intent: IntentAct, Slots: Slots{ Fn: "restart", HasFn: true, ResolvedBy: ActionResolutionExtractorRaw, }, Producer: RouteProducerClassifier, CapabilitySelection: CapabilitySelection{ Fn: "restart", Resolved: true, Method: ActionResolutionExtractorRaw, InputKind: SelectionDeterministic, Producer: RouteProducerClassifier, }, } c := ResolveActionCandidate(dec, nil) if !c.ActionResolved() { t.Fatal("expected resolved candidate") } if c.ResolvedBy != ActionResolutionExtractorRaw { t.Errorf("ResolvedBy = %q, want extractor_raw", c.ResolvedBy) } if c.Producer != RouteProducerClassifier { t.Errorf("Producer = %q, want classifier", c.Producer) } } // TestResolveActionCandidate_ExtractorLLMText pins that an LLM-routed act // whose function was resolved from the LLM's cleaned text carries // extractor_llm_text provenance. func TestResolveActionCandidate_ExtractorLLMText(t *testing.T) { dec := Decision{ Intent: IntentAct, Slots: Slots{ Fn: "restart", HasFn: true, ResolvedBy: ActionResolutionExtractorLLMText, }, Producer: RouteProducerLLM, CapabilitySelection: CapabilitySelection{ Fn: "restart", Resolved: true, Method: ActionResolutionExtractorLLMText, InputKind: SelectionLLMText, Producer: RouteProducerLLM, }, } c := ResolveActionCandidate(dec, nil) if !c.ActionResolved() { t.Fatal("expected resolved candidate") } if c.ResolvedBy != ActionResolutionExtractorLLMText { t.Errorf("ResolvedBy = %q, want extractor_llm_text", c.ResolvedBy) } if c.Producer != RouteProducerLLM { t.Errorf("Producer = %q, want llm", c.Producer) } } // TestResolveActionCandidate_FallbackMatcher pins that a matcher fallback // carry fallback_matcher provenance. func TestResolveActionCandidate_FallbackMatcher(t *testing.T) { m := DefaultActMatcher{Fns: []string{"restart", "stop"}} dec := Decision{ Intent: IntentAct, Slots: Slots{Text: "restart nginx"}, } c := ResolveActionCandidate(dec, m) if !c.ActionResolved() { t.Fatal("expected resolved candidate") } if c.ResolvedBy != ActionResolutionFallbackMatcher { t.Errorf("ResolvedBy = %q, want fallback_matcher", c.ResolvedBy) } if c.Source != ActionSourceMatcher { t.Errorf("Source = %q, want matcher", c.Source) } } // TestResolveActionCandidate_UnresolvedNoFalseMethod pins that an unresolved // candidate (matcher miss) has empty ResolvedBy. func TestResolveActionCandidate_UnresolvedNoFalseMethod(t *testing.T) { m := DefaultActMatcher{Fns: []string{"restart", "stop"}} dec := Decision{ Intent: IntentAct, Slots: Slots{Text: "deploy the thing"}, } c := ResolveActionCandidate(dec, m) if c.ActionResolved() { t.Fatal("expected unresolved candidate") } if c.ResolvedBy != "" { t.Errorf("ResolvedBy = %q, want empty for unresolved", c.ResolvedBy) } } // TestResolveActionCandidate_PropagatesResolvedBy pins that ResolvedBy // travels from CapabilitySelection through to ActionCandidate for every // route-sourced case. func TestResolveActionCandidate_PropagatesResolvedBy(t *testing.T) { methods := []ActionResolutionMethod{ ActionResolutionGrammarFixed, ActionResolutionGrammarMatcher, ActionResolutionExtractorRaw, ActionResolutionExtractorLLMText, } for _, m := range methods { t.Run(string(m), func(t *testing.T) { dec := Decision{ Intent: IntentAct, Slots: Slots{Fn: "restart", HasFn: true, ResolvedBy: m}, CapabilitySelection: CapabilitySelection{ Fn: "restart", Resolved: true, Method: m, }, } c := ResolveActionCandidate(dec, nil) if c.ResolvedBy != m { t.Errorf("ResolvedBy = %q, want %q", c.ResolvedBy, m) } }) } } // TestResolveActionCandidate_FnArgsIdentical pins that adding ResolvedBy // does not change the selected fn or args for any path. func TestResolveActionCandidate_FnArgsIdentical(t *testing.T) { // Route-sourced. dec := Decision{ Intent: IntentAct, Slots: Slots{ Fn: "restart", Args: []string{"nginx"}, HasFn: true, ResolvedBy: ActionResolutionGrammarMatcher, }, CapabilitySelection: CapabilitySelection{ Fn: "restart", Args: []string{"nginx"}, Resolved: true, Method: ActionResolutionGrammarMatcher, }, } c := ResolveActionCandidate(dec, nil) if c.Fn != "restart" || len(c.Args) != 1 || c.Args[0] != "nginx" { t.Errorf("route fn/args changed: Fn=%q Args=%v", c.Fn, c.Args) } // Matcher-sourced. m := DefaultActMatcher{Fns: []string{"restart"}} dec2 := Decision{ Intent: IntentAct, Slots: Slots{Text: "restart nginx"}, } c2 := ResolveActionCandidate(dec2, m) if c2.Fn != "restart" || len(c2.Args) != 1 || c2.Args[0] != "nginx" { t.Errorf("matcher fn/args changed: Fn=%q Args=%v", c2.Fn, c2.Args) } }