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.
This commit is contained in:
2026-09-06 13:07:08 +04:00
parent 356766bce1
commit 66c578a6f4
5 changed files with 357 additions and 39 deletions
+169
View File
@@ -385,3 +385,172 @@ func TestActExecutionFromCandidateNotSlots(t *testing.T) {
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")
}
}