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") } }