mavend: add action-resolution regression tests
Eight integration tests pinning the action-resolution boundary: 1. TestActRouteSource_NoMatcherInvoke — HasFn=true, route-sourced 2. TestActMatcherSource_FallbackMatch — no Fn, matcher resolves 3. TestActMatcherMiss_ProposeGap — matcher miss → propose-gap 4. TestActDestructive_ConfirmationUnchanged — destructive → confirm 5. TestActTaskStatus_InterceptUnchanged — task-status intercepted 6. TestActStage0_SameResult — stage-0 act executes same tool 7. TestActLearnedRouter_NoFn_FallbackMatch — LLM no Fn → matcher 8. TestResolveAction_CandidateSource_Verified — verifies all paths All 8 pass. All existing tests pass.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user