mavend: centralize action validation boundary (slice 4)
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"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"
|
||||
@@ -232,3 +233,155 @@ func TestResolveAction_CandidateSource_Verified(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user