Files
Maven/cmd/mavend/entityname_test.go
claude 85a3397bf4 Cancel a reminder by voice, and honour a refusal (V-719)
reminder_cancel.go is a stateful pre-route resolver ahead of a parked
clarification and the statistical cascade. It accepts only an addressed
command-position imperative plus the reminder or alarm noun, so questions,
reported speech, past-tense reports and prohibitions establish no mutation
authority. Subject terms keep negation and quantity, and a parsed time
passes the same resolved-hour gate as capture.

One match cancels through the typed IPC method. Several are stored as
session candidates in the spoken order, capped at five, and only a whole
affirmative ordinal consumes that list: re-querying on the follow-up would
let a state change move the ordinal underneath him. No match, an unread
time, a spent ordinal and an ambiguous delivery result are all explicit
no-ops.

command_prohibition.go is the first mutation boundary in a turn. A direct
prohibition clears the three confirmation slots under their shared mutex,
so a later bare "да" cannot revive authority he has just revoked. A parked
clarify question is not authority and survives, suspended and repeated.
refusesCommand is the same belt at the executor entry points, checked
against the original utterance so a model rewriting Slots.Text cannot get
around it.

The rung is named in preRouteLadder, so /trace records whether it won or
declined on every surface.

--no-verify: master is the working branch this session by the owner's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:19:25 +04:00

265 lines
9.5 KiB
Go

package main
import (
"context"
"net/http"
"strings"
"sync"
"testing"
"github.com/kami/maven/internal/router"
)
// TestEntityReferences pins when his own words win over the model's, and that
// every name he said goes over rather than one of them being picked.
func TestEntityReferences(t *testing.T) {
for _, tc := range []struct {
name string
utterance string
text string
want []string
}{
{
name: "the model transliterated the name",
utterance: "перезапусти muzick indexer",
text: "перезагрузить музик индексер",
want: []string{"muzick indexer"},
},
{
name: "it kept the name, so nothing to repair",
utterance: "перезапусти muzick indexer",
text: "перезагрузить muzick indexer",
want: []string{"перезагрузить muzick indexer"},
},
{
name: "an all-Russian entity name is not a rewrite",
utterance: "перезапусти домашний сервер",
text: "перезагрузить домашний сервер",
want: []string{"перезагрузить домашний сервер"},
},
{
name: "an English turn never enters the recovery",
utterance: "restart muzick indexer",
text: "restart muzick indexer",
want: []string{"restart muzick indexer"},
},
{
name: "both names go over, in the order he said them",
utterance: "а перезапусти-ка nginx на muzick-indexer, пожалуйста",
text: "перезагрузить нгинкс",
want: []string{"nginx", "muzick-indexer"},
},
{
name: "one stray letter is not a name",
utterance: "перезапусти сервер a",
text: "перезагрузить сервер",
want: []string{"перезагрузить сервер"},
},
{
name: "the same name twice is one question",
utterance: "перезапусти nginx, ну правда, nginx",
text: "перезагрузить нгинкс",
want: []string{"nginx"},
},
} {
t.Run(tc.name, func(t *testing.T) {
dec := router.Decision{Utterance: tc.utterance, Slots: router.Slots{Text: tc.text}}
got := entityReferences(dec)
if len(got) != len(tc.want) {
t.Fatalf("entityReferences = %q, want %q", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("entityReferences = %q, want %q", got, tc.want)
}
}
})
}
}
// TestNexusIsAskedForTheNameHeSaid — the defect end to end (Vikunja #476): the
// router hands over a transliterated Text, and Nexus must still be asked about
// the service that exists.
func TestNexusIsAskedForTheNameHeSaid(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
dec := router.Decision{
Utterance: "перезапусти muzick indexer",
Intent: router.IntentAct,
Slots: router.Slots{Text: "перезагрузить музик индексер", Fn: "restart", HasFn: true},
}
h.handleHexisAct(ctx, dec)
reqs := nexus.Requests()
if len(reqs) == 0 {
t.Fatal("nexus was never asked")
}
body := string(reqs[0].Body)
if !strings.Contains(body, "muzick indexer") {
t.Fatalf("nexus resolve body = %s, want the name he said", body)
}
}
// TestAnEntityActReachesHexisInsteadOfAsking — the second half of #476. The
// stage-3 gate thins an act with no allowlisted fn, and that question used to
// be the whole turn, so the Hexis path was unreachable from voice or chat.
func TestAnEntityActReachesHexisInsteadOfAsking(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
dec := router.Decision{
Utterance: "перезапусти muzick indexer",
Intent: router.IntentAct,
Stage: 3,
Clarify: true,
Slots: router.Slots{Text: "restart status muzick indexer"},
}
reply := h.hexisBeforeClarify(ctx, dec)
if reply == "" {
t.Fatal("a resolvable entity act must reach hexis rather than fall through to the question")
}
if hexis.Count("", "/api/v1") == 0 {
t.Fatal("hexis was never contacted")
}
}
// TestClarifyStillAsksWithoutHexis — the narrowing. No ecosystem, no change:
// she asks exactly what she asked before.
func TestClarifyStillAsksWithoutHexis(t *testing.T) {
h, _, _ := newClarifyHandler(t)
dec := router.Decision{
Utterance: "перезапусти muzick indexer",
Intent: router.IntentAct,
Stage: 3,
Clarify: true,
Slots: router.Slots{Text: "перезагрузить музик индексер"},
}
if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" {
t.Fatalf("no hexis must mean no reply, got %q", reply)
}
if _, asked := h.askClarify(voiceCtx(), dec); !asked {
t.Fatal("she must still ask what to do")
}
}
// A verb is not an entity. Before the reach gate, an exact local matcher hit
// with no arguments still sent the raw verb to Nexus and could discover a
// similarly named entity through Hexis. The local tool lane may handle or
// reject it, but the ecosystem must not be consulted without a target.
func TestBareMatchedActNeverReachesNexus(t *testing.T) {
nexus := newFakeNexus(t, fixtureNexusResolved("ent_power", "Power", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h, _, _ := newClarifyHandler(t)
h.ecosystem = ecoHandler(t, nexus, nil, hexis).ecosystem
reply := h.actionAct(context.Background(), router.Decision{
Utterance: "выключи",
Intent: router.IntentAct,
Slots: router.Slots{Fn: "выключи", HasFn: true, Text: "выключи"},
})
if len(nexus.Requests()) != 0 {
t.Fatalf("bare verb reached Nexus: %+v", nexus.Requests())
}
if reply == "" {
t.Fatal("bare act disappeared instead of staying in Maven's local lane")
}
}
func TestUnresolvedActNeverReachesNexusBeforeClarify(t *testing.T) {
nexus := newFakeNexus(t, fixtureNexusResolved("ent_it", "It", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
dec := router.Decision{
Utterance: "сделай это",
Intent: router.IntentAct,
Stage: 3,
Clarify: true,
Slots: router.Slots{Text: "сделай это"},
}
if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" {
t.Fatalf("unresolved act was answered by Hexis: %q", reply)
}
if len(nexus.Requests()) != 0 {
t.Fatalf("unresolved act reached Nexus: %+v", nexus.Requests())
}
}
// nexusInOrder serves one resolve answer per call, in order, so a test can say
// what Nexus knows about the first name and what it knows about the second. The
// last body repeats once the list runs out.
func nexusInOrder(t *testing.T, bodies ...string) *fakeServer {
t.Helper()
var mu sync.Mutex
n := 0
return newFakeServer(t, map[string]http.HandlerFunc{
"POST /api/v1/resolve": func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
body := bodies[min(n, len(bodies)-1)]
n++
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
},
})
}
// TestTwoResolvedNamesAsk — «перезапусти nginx на muzick-indexer» names a
// service and the host it runs on. Both are real, and which one he meant is not
// in the utterance, so she asks. Picking one by length was the old behaviour and
// length is not evidence (Vikunja #524).
func TestTwoResolvedNamesAsk(t *testing.T) {
ctx := context.Background()
nexus := nexusInOrder(t,
fixtureNexusResolved("ent_nginx", "nginx", "service"),
fixtureNexusResolved("ent_host", "Muzick indexer", "device"),
)
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
dec := router.Decision{
Utterance: "перезапусти nginx на muzick-indexer",
Intent: router.IntentAct,
Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
}
reply := h.handleHexisAct(ctx, dec)
if !strings.Contains(reply, "nginx") || !strings.Contains(reply, "Muzick indexer") {
t.Fatalf("reply = %q, want both names she found", reply)
}
if hexis.Count("POST", "/api/v1/execute") != 0 {
t.Fatal("she must not execute against a target she is still asking about")
}
}
// TestTheNameNexusKnowsWins — the other half. Two names go over and only one is
// an entity, so there is nothing to ask about and the act runs.
func TestTheNameNexusKnowsWins(t *testing.T) {
ctx := context.Background()
nexus := nexusInOrder(t,
fixtureNexusNotFound(),
fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"),
)
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
dec := router.Decision{
Utterance: "перезапусти nginx на muzick-indexer",
Intent: router.IntentAct,
Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
}
reply := h.handleHexisAct(ctx, dec)
if reply == "" {
t.Fatal("the resolvable name must carry the act")
}
if len(nexus.Requests()) != 2 {
t.Fatalf("nexus asked %d times, want both names", len(nexus.Requests()))
}
if hexis.Count("POST", "/api/v1/execute") == 0 {
t.Fatal("hexis was never asked to run it")
}
}