diff --git a/internal/router/actioncandidate_test.go b/internal/router/actioncandidate_test.go index 3847312..103d8f6 100644 --- a/internal/router/actioncandidate_test.go +++ b/internal/router/actioncandidate_test.go @@ -352,3 +352,181 @@ func TestValidateActionCandidate_IssuesPopulatedOnInvalid(t *testing.T) { 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, + }, + } + 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, + }, + } + 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, + } + 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, + } + 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 Slots 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}, + } + 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, + }, + } + 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) + } +}