31deb7d565
classifyConfirm was a substring test over bare stems, so "погода", "дальше", "надо" and "давление" all read as "да", and "покажи" and "около" read as "ок". resolveConfirm runs before routing, so a question about the weather executed a parked destructive act. Reproduced on the box: with "restart nonexistent-xyz" parked, "какая погода" answered "не получилось выполнить команду". The yes and no answers are now two closed sets in internal/lexicon, matched as whole tokens longest-first, and the WHOLE utterance must be answer words and filler — a leading "давай" does not make "давай посмотрим погоду" an answer. Anything else is confirmUnknown, which now leaves the confirm parked instead of disarming it: an utterance that is not an answer is not a cancellation either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
104 lines
3.8 KiB
Go
104 lines
3.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/tool"
|
|
)
|
|
|
|
// TestClassifyConfirmRejectsSubstrings — V-567. The old matcher tested bare
|
|
// stems with strings.Contains, so every word below answered a question she had
|
|
// asked about something else: "погода", "дальше", "надо" and "давление" carry
|
|
// "да"; "покажи", "около" and "окно" carry "ок". A parked destructive act fired
|
|
// on a question about the weather.
|
|
func TestClassifyConfirmRejectsSubstrings(t *testing.T) {
|
|
for _, text := range []string{
|
|
"погода",
|
|
"какая погода",
|
|
"что дальше",
|
|
"надо ещё",
|
|
"покажи заметки",
|
|
"около окна",
|
|
"давление",
|
|
"давай посмотрим погоду",
|
|
"не забудь купить хлеб",
|
|
"окно открыто",
|
|
"стоит ли брать зонт",
|
|
"",
|
|
} {
|
|
if got := classifyConfirm(text); got != confirmUnknown {
|
|
t.Errorf("classifyConfirm(%q) = %v, want confirmUnknown", text, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestClassifyConfirmAcceptsAnswers keeps every genuine answer the substring
|
|
// matcher accepted, and pins the pair the fix could most easily get wrong:
|
|
// "надо" is not an answer and "не надо" is the opposite of one.
|
|
func TestClassifyConfirmAcceptsAnswers(t *testing.T) {
|
|
yes := []string{"да", "Да!", "ага", "давай", "да, давай", "конечно", "подтверждаю", "ну да", "yes", "yeah", "ok", "okay", "confirm"}
|
|
no := []string{"нет", "Нет.", "не надо", "не нужно", "не сейчас", "отмена", "отмени", "стоп", "нет, отмени", "no", "nope", "cancel", "stop", "don't"}
|
|
|
|
for _, text := range yes {
|
|
if got := classifyConfirm(text); got != confirmYes {
|
|
t.Errorf("classifyConfirm(%q) = %v, want confirmYes", text, got)
|
|
}
|
|
}
|
|
for _, text := range no {
|
|
if got := classifyConfirm(text); got != confirmNo {
|
|
t.Errorf("classifyConfirm(%q) = %v, want confirmNo", text, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUnrelatedTurnLeavesConfirmParked — the whole point of V-567. An utterance
|
|
// that is not an answer must not execute the parked act, must not consume the
|
|
// turn, and must not disarm the confirm either: the answer he has not given yet
|
|
// is still answerable until it expires.
|
|
func TestUnrelatedTurnLeavesConfirmParked(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
api := ipc.NewStoreAPI(st)
|
|
now := time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC)
|
|
h := &reactiveHandler{
|
|
api: api,
|
|
dataStore: st,
|
|
now: func() time.Time { return now },
|
|
tools: tool.NewExecutor(api, time.Second),
|
|
}
|
|
|
|
marker := filepath.Join(t.TempDir(), "destructive-tool-ran")
|
|
if err := st.EnableTool(ctx, "delete_backups", []string{"touch", marker}, true, "test", h.now()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.park("delete_backups", nil, "delete_backups")
|
|
|
|
if reply, handled := h.resolveConfirm(ctx, "какая погода"); handled {
|
|
t.Fatalf("the weather question was consumed as a confirm: %q", reply)
|
|
}
|
|
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
|
t.Fatalf("the parked destructive command ran on an unrelated turn: %v", err)
|
|
}
|
|
if h.pending == nil {
|
|
t.Fatal("the confirm was disarmed by a turn that did not answer it")
|
|
}
|
|
|
|
// It is still answerable, and answering it still runs the act.
|
|
reply, handled := h.resolveConfirm(ctx, "да")
|
|
if !handled || !strings.Contains(reply, "готово") {
|
|
t.Fatalf("the still-parked confirm did not resolve: handled=%v reply=%q", handled, reply)
|
|
}
|
|
if _, err := os.Stat(marker); err != nil {
|
|
t.Fatalf("confirmed destructive command did not run: %v", err)
|
|
}
|
|
if h.pending != nil {
|
|
t.Fatal("the confirm stayed parked after being answered")
|
|
}
|
|
}
|