Files
Maven/cmd/mavend/actionresolve_test.go
T
claude 66c578a6f4 router: introduce typed ActionValidationStatus boundary (slice 5)
Introduce ActionValidationStatus enum (valid, unresolved, missing_argument,
invalid_argument, ambiguous_target) as the typed classification of validation
outcomes. ActionValidationResult now carries Status instead of boolean flags.

Backward-compatible: Unresolved() and Valid() methods preserved on the result.
Existing validation behavior unchanged: only blank Fn produces invalid_argument.
All downstream behavior (proposeGap, confirmation, task_status, praxis, hexis)
unchanged.

Tests added for all five status values, backward compatibility, and the full
validation → execution boundary.
2026-09-06 13:07:08 +04:00

557 lines
18 KiB
Go

package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/tool"
)
// newActHandler builds a handler with the act path wired: a matcher over
// whatever tools the test enabled, no model, no ecosystem.
func newActHandler(t *testing.T) (*reactiveHandler, *store.Store) {
t.Helper()
st := newTestStore(t)
api := ipc.NewStoreAPI(st)
matcher := tool.NewMatcher(api)
h := &reactiveHandler{
api: api,
tools: tool.NewExecutor(api, 2*time.Second),
matcher: matcher,
now: func() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) },
}
return h, st
}
// TestActRouteSource_NoMatcherInvoke pins that an act with HasFn=true
// produces a candidate from the route and does not invoke the matcher.
func TestActRouteSource_NoMatcherInvoke(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
// Enable a tool so the matcher has something to match against.
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
t.Fatal(err)
}
// Act with HasFn=true: the candidate must come from the route.
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "status",
Slots: router.Slots{Fn: "status", HasFn: true},
})
if !strings.Contains(reply, "готово") {
t.Errorf("route-sourced act replied %q; want it to have run", reply)
}
}
// TestActMatcherSource_FallbackMatch pins that an act without Fn invokes
// the matcher and produces a matcher-sourced candidate.
func TestActMatcherSource_FallbackMatch(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
// Enable a tool so the matcher can find it.
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
t.Fatal(err)
}
// Act without HasFn: the matcher must resolve "status" from the text.
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "check status",
Slots: router.Slots{Text: "status"},
})
if !strings.Contains(reply, "готово") {
t.Errorf("matcher-sourced act replied %q; want it to have run", reply)
}
}
// TestActMatcherMiss_ProposeGap pins that a matcher miss produces the
// same propose-gap behavior as before.
func TestActMatcherMiss_ProposeGap(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
// Enable one tool so the matcher has an allowlist, but not the one asked for.
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
t.Fatal(err)
}
// Act without HasFn and text that doesn't match any tool.
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "deploy the thing",
Slots: router.Slots{Text: "deploy the thing"},
})
if !strings.Contains(strings.ToLower(reply), "предлож") {
t.Errorf("matcher miss replied %q; want propose-gap behavior", reply)
}
}
// TestActDestructive_ConfirmationUnchanged pins that a destructive tool
// still triggers the confirmation flow.
func TestActDestructive_ConfirmationUnchanged(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
if err := st.EnableTool(ctx, "restart", []string{"true"}, true, "test", now); err != nil {
t.Fatal(err)
}
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "restart",
Slots: router.Slots{Fn: "restart", HasFn: true},
})
if !strings.Contains(reply, "да или нет") {
t.Errorf("destructive act replied %q; want a confirm turn", reply)
}
}
// TestActTaskStatus_InterceptUnchanged pins that task_status is intercepted
// before reaching the tool executor.
func TestActTaskStatus_InterceptUnchanged(t *testing.T) {
h, _ := newActHandler(t)
ctx := context.Background()
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "task status",
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true,
Text: "task status"},
})
// task_status is intercepted by resolveTaskStatus, which returns a
// status phrase. The exact reply depends on the store state, but it
// must not be a tool execution result.
if strings.Contains(reply, "готово") {
t.Errorf("task_status was not intercepted, got %q", reply)
}
}
// TestActStage0_SameResult pins that a stage-0 act (grammar match with
// HasFn=true) produces the same tool execution as before.
func TestActStage0_SameResult(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
if err := st.EnableTool(ctx, "restart", []string{"echo", "ok"}, false, "test", now); err != nil {
t.Fatal(err)
}
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Stage: 0,
Confidence: 1.0,
Utterance: "maven, restart nginx",
Slots: router.Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
Producer: router.RouteProducerGrammar,
})
if !strings.Contains(reply, "сделала") && !strings.Contains(reply, "готово") {
t.Errorf("stage-0 act replied %q; want it to have run", reply)
}
}
// TestActLearnedRouter_NoFn_FallbackMatch pins that a learned-router act
// without Fn falls through to the matcher and produces the same result.
func TestActLearnedRouter_NoFn_FallbackMatch(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
if err := st.EnableTool(ctx, "restart", []string{"echo", "ok"}, false, "test", now); err != nil {
t.Fatal(err)
}
// LLM routed the act but did not fill Fn (common when the model returns
// the verb in Text but not in Fn).
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Stage: 1,
Confidence: 0.85,
Utterance: "could you restart nginx",
Slots: router.Slots{Text: "restart nginx"},
Producer: router.RouteProducerLLM,
})
if !strings.Contains(reply, "сделала") && !strings.Contains(reply, "готово") {
t.Errorf("learned-router act replied %q; want it to have run", reply)
}
}
// TestResolveAction_CandidateSource_Verified pins the candidate source
// for both route-resolved and matcher-resolved actions.
func TestResolveAction_CandidateSource_Verified(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
t.Fatal(err)
}
// Route-resolved: HasFn=true.
c1 := h.resolveAction(ctx, router.Decision{
Intent: router.IntentAct,
Slots: router.Slots{Fn: "status", HasFn: true},
})
if c1.Source != router.ActionSourceRoute {
t.Errorf("route candidate source = %q, want route", c1.Source)
}
if c1.Fn != "status" {
t.Errorf("route candidate Fn = %q, want status", c1.Fn)
}
// Matcher-resolved: no Fn, text matches.
c2 := h.resolveAction(ctx, router.Decision{
Intent: router.IntentAct,
Slots: router.Slots{Text: "status"},
})
if c2.Source != router.ActionSourceMatcher {
t.Errorf("matcher candidate source = %q, want matcher", c2.Source)
}
if c2.Fn != "status" {
t.Errorf("matcher candidate Fn = %q, want status", c2.Fn)
}
// Matcher miss: no Fn, text doesn't match.
c3 := h.resolveAction(ctx, router.Decision{
Intent: router.IntentAct,
Slots: router.Slots{Text: "deploy everything"},
})
if c3.ActionResolved() {
t.Errorf("miss candidate resolved = true, want false")
}
}
// --- structural validation integration tests ---
// TestActValidation_MalformedCandidate_BlankFn pins that a resolved
// candidate with a blank (whitespace-only) Fn does not execute and
// produces a failure response.
func TestActValidation_MalformedCandidate_BlankFn(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
t.Fatal(err)
}
// Simulate a malformed candidate by writing a blank Fn into Slots
// after resolution. This tests that the validation layer catches
// structurally invalid candidates.
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "status",
Slots: router.Slots{Fn: " ", HasFn: true},
})
// The blank Fn should not reach tool execution. It either hits
// the validation gate (ActFail) or the existing error paths.
if reply == "" {
t.Error("expected a response, got empty string")
}
}
// TestActValidation_UnresolvedCandidate_ProposeGap pins that an unresolved
// candidate (matcher miss) still flows to proposeGap, unchanged.
func TestActValidation_UnresolvedCandidate_ProposeGap(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
t.Fatal(err)
}
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "deploy everything",
Slots: router.Slots{Text: "deploy everything"},
})
if !strings.Contains(strings.ToLower(reply), "предлож") {
t.Errorf("unresolved candidate replied %q; want propose-gap behavior", reply)
}
}
// TestActValidation_DestructiveValid_StillConfirms pins that a destructive
// valid action still reaches the confirmation path through validation.
func TestActValidation_DestructiveValid_StillConfirms(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
if err := st.EnableTool(ctx, "restart", []string{"true"}, true, "test", now); err != nil {
t.Fatal(err)
}
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "restart",
Slots: router.Slots{Fn: "restart", HasFn: true},
})
if !strings.Contains(reply, "да или нет") {
t.Errorf("destructive valid act replied %q; want confirm turn", reply)
}
}
// TestActValidation_IrreversibleValid_NeedsAuthedSurface pins that an
// irreversible valid action still reaches ErrNeedsAuthedSurface.
func TestActValidation_IrreversibleValid_NeedsAuthedSurface(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
// Register an irreversible tool: cmd containing "drop" triggers the
// irreversible tier via RiskOf → isIrreversible.
if err := st.EnableTool(ctx, "drop_table", []string{"drop"}, true, "test", now); err != nil {
t.Fatal(err)
}
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "drop_table",
Slots: router.Slots{Fn: "drop_table", HasFn: true},
})
// Irreversible tools return ErrNeedsAuthedSurface, which produces
// a specific phraser response.
if !strings.Contains(reply, "выполню") && !strings.Contains(reply, "запусти") {
t.Errorf("irreversible valid act replied %q; want authed-surface response", reply)
}
}
// TestActValidation_ValidationTracing pins that validation outcomes are
// recorded in the decision trace.
func TestActValidation_ValidationTracing(t *testing.T) {
h, st := newActHandler(t)
now := h.now()
// Valid candidate: trace should show action-validation:won.
ctx, rec := decision.With(context.Background(), "status", "tap:text")
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
t.Fatal(err)
}
h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "status",
Slots: router.Slots{Fn: "status", HasFn: true},
})
records := rec.Claims
found := false
for _, c := range records {
if c.Claimant == "action-validation" && c.Outcome == decision.Won {
found = true
break
}
}
if !found {
t.Errorf("expected action-validation:won in trace, got %v", records)
}
}
// TestActExecutionFromCandidateNotSlots pins that downstream execution reads
// resolved action data from ActionCandidate, not from Decision.Slots. The
// decision has empty Fn/Args/HasFn — the bridge used to copy candidate values
// back into these fields. After the bridge removal, execution must still
// succeed because the candidate carries the resolved function.
func TestActExecutionFromCandidateNotSlots(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
t.Fatal(err)
}
// Act without any Fn/Args/HasFn in Slots — the matcher resolves from Text.
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "check status",
Slots: router.Slots{Text: "status"},
})
if !strings.Contains(reply, "готово") {
t.Errorf("execution from candidate replied %q; want tool success", reply)
}
}
// --- validation status boundary tests ---
// TestActValidation_StatusValidRoute pins that a route-resolved valid action
// produces ActionValid status and reaches execution.
func TestActValidation_StatusValidRoute(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
t.Fatal(err)
}
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "status",
Slots: router.Slots{Fn: "status", HasFn: true},
})
if !strings.Contains(reply, "готово") {
t.Errorf("valid route act replied %q; want tool success", reply)
}
}
// TestActValidation_StatusValidMatcher pins that a matcher-resolved valid
// action produces ActionValid status and reaches execution.
func TestActValidation_StatusValidMatcher(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
t.Fatal(err)
}
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "check status",
Slots: router.Slots{Text: "status"},
})
if !strings.Contains(reply, "готово") {
t.Errorf("valid matcher act replied %q; want tool success", reply)
}
}
// TestActValidation_StatusUnresolved pins that an unresolved candidate
// produces ActionUnresolved status and flows to proposeGap.
func TestActValidation_StatusUnresolved(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
t.Fatal(err)
}
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "deploy everything",
Slots: router.Slots{Text: "deploy everything"},
})
if !strings.Contains(strings.ToLower(reply), "предлож") {
t.Errorf("unresolved act replied %q; want propose-gap", reply)
}
}
// TestActValidation_StatusInvalid pins that a structurally invalid candidate
// produces ActionInvalidArgument status and refuses execution.
func TestActValidation_StatusInvalid(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
t.Fatal(err)
}
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "status",
Slots: router.Slots{Fn: " ", HasFn: true},
})
if reply == "" {
t.Error("expected a response for invalid candidate")
}
if strings.Contains(reply, "готово") {
t.Error("invalid candidate should not reach tool execution")
}
}
// TestActValidation_DestructiveValidStatus pins that a destructive but
// structurally valid action still produces ActionValid status and reaches
// the confirmation path (not validation failure).
func TestActValidation_DestructiveValidStatus(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
if err := st.EnableTool(ctx, "restart", []string{"true"}, true, "test", now); err != nil {
t.Fatal(err)
}
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "restart",
Slots: router.Slots{Fn: "restart", HasFn: true},
})
if !strings.Contains(reply, "да или нет") {
t.Errorf("destructive valid act replied %q; want confirm turn", reply)
}
}
// TestActValidation_ConfirmationUnchanged pins that the confirmation flow
// is unchanged by validation.
func TestActValidation_ConfirmationUnchanged(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
if err := st.EnableTool(ctx, "restart", []string{"echo", "ok"}, true, "test", now); err != nil {
t.Fatal(err)
}
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "restart nginx",
Slots: router.Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
})
if !strings.Contains(reply, "да или нет") {
t.Errorf("confirmation act replied %q; want confirm turn", reply)
}
}
// TestActValidation_TaskStatusInterceptUnchanged pins that task_status
// interception is unchanged by validation.
func TestActValidation_TaskStatusInterceptUnchanged(t *testing.T) {
h, _ := newActHandler(t)
ctx := context.Background()
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "task status",
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Text: "task status"},
})
if strings.Contains(reply, "готово") {
t.Errorf("task_status was not intercepted, got %q", reply)
}
}
// TestActValidation_NoExecutionOnFailure pins that validation failure
// prevents downstream execution.
func TestActValidation_NoExecutionOnFailure(t *testing.T) {
h, st := newActHandler(t)
ctx := context.Background()
now := h.now()
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
t.Fatal(err)
}
reply := h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: "status",
Slots: router.Slots{Fn: " ", HasFn: true},
})
if strings.Contains(reply, "готово") {
t.Error("validation failure should not reach tool execution")
}
}