85a3397bf4
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>
265 lines
9.5 KiB
Go
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")
|
|
}
|
|
}
|