From f6d7b05161f94bcf530f641dfa31b5b5a7ec8956 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 5 Sep 2026 21:50:50 +0400 Subject: [PATCH] mavend: add action-resolution regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/mavend/actionresolve_test.go | 234 +++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 cmd/mavend/actionresolve_test.go diff --git a/cmd/mavend/actionresolve_test.go b/cmd/mavend/actionresolve_test.go new file mode 100644 index 0000000..e9d5c1c --- /dev/null +++ b/cmd/mavend/actionresolve_test.go @@ -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") + } +}