package main import ( "context" "os" "path/filepath" "strings" "testing" "time" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/phraser/eval" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" "github.com/kami/maven/internal/tool" "github.com/kami/maven/internal/voice" ) // newClarifyHandler builds a handler with the clarify path wired and no model: // stub date parser, the real fact parser, and a matcher over whatever tools the // test enabled. `now` is fixed so TTL behaviour is testable. func newClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) { t.Helper() st := newTestStore(t) api := ipc.NewStoreAPI(st) now := time.Date(2026, 7, 31, 9, 0, 0, 0, time.UTC) matcher := tool.NewMatcher(api) h := &reactiveHandler{ api: api, dataStore: st, tools: tool.NewExecutor(api, 2*time.Second), matcher: matcher, replier: voice.NewStubReplier(), now: func() time.Time { return now }, dialogueSessions: dialogue.NewSessionStore(2 * time.Minute), clarifyStore: dialogue.NewClarifyStore(clarifyTTL), extractor: router.Extractor{ Time: router.StubDateTimeParser{}, Acts: matcher, Facts: router.DefaultFactParser{}, }, } return h, st, &now } func clarifyDec(intent router.Intent, slots router.Slots, utterance string) router.Decision { return router.Decision{Utterance: utterance, Stage: 3, Intent: intent, Slots: slots, Clarify: true} } // TestClarifyQuestionForMissingSlot pins which question goes with which gap, and // which intents get no question at all. func TestClarifyQuestionForMissingSlot(t *testing.T) { cases := []struct { name string dec router.Decision want string asked bool }{ {"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Сейчас 09:00. Когда?", true}, {"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true}, {"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true}, // A time with nothing to say at that time is still half a reminder, so // the subject is what she asks about — not silence. {"reminder that has a time but no subject", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "О чём напомнить?", true}, // A bare hour is half of a day away from being an answer, and she asks // which half rather than picking one (V-579). {"reminder whose hour could be either half of the day", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "Сейчас 09:00. Это утра или вечера?", true}, {"reminder that has all three", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни завтра в 15:00 позвонить маме"), "", false}, // The owner's own two, confirmed 2026-08-06: an unambiguous time and a // relative one are both complete and are never asked about. {"an interval names the instant by itself", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни через час позвонить маме"), "", false}, {"half an hour is an interval too", clarifyDec(router.IntentReminder, router.Slots{Text: "выключить духовку", HasTime: true}, "напомни через полчаса выключить духовку"), "", false}, {"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false}, {"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false}, } h, _, _ := newClarifyHandler(t) for _, tc := range cases { _, got, asked := h.clarifyQuestion(tc.dec) if asked != tc.asked || got != tc.want { t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, got, asked, tc.want, tc.asked) } } } // TestClarifyReminderCompletesOnAnswer is the whole point of the feature: she // asks for the missing time and the answer creates the reminder. func TestClarifyReminderCompletesOnAnswer(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")) if !asked || question != "Сейчас 09:00. Когда?" { t.Fatalf("expected the time question, got %q asked=%v", question, asked) } // The answer names the day as well as the hour. A reminder commits on what, // what time and what day, and a dayless hour is asked about (V-579). reply, handled := h.resolveClarifyAnswer(ctx, "сегодня в 11:00") if !handled { t.Fatal("the answer to an open question must be consumed as an answer") } if reply == clarifyGaveUp { t.Fatalf("a good answer must not drop the request: %q", reply) } reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)) if err != nil || len(reminders) != 1 { t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err) } if !strings.Contains(reminders[0].Payload, "маме") { t.Fatalf("the reminder lost the original request: %q", reminders[0].Payload) } if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { t.Fatal("the question must be cleared once answered") } } // TestClarifyFactCompletesOnAnswer — the fact path, where the answer carries // both the key and the value. func TestClarifyFactCompletesOnAnswer(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) if _, asked := h.askClarify(ctx, clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked { t.Fatal("a fact with no key should be asked about") } if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp { t.Fatalf("answer should complete the fact, handled=%v reply=%q", handled, reply) } if fact, err := st.LatestFact(ctx, "water"); err != nil || fact.Key != "water" { t.Fatalf("clarified fact was not written: fact=%+v err=%v", fact, err) } } // TestClarifyAnswerAfterTTLIsANewRequest — a late answer is not an answer. func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) { ctx := context.Background() h, st, now := newClarifyHandler(t) if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { t.Fatal("expected a question") } *now = now.Add(clarifyTTL + time.Second) if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); handled { t.Fatalf("an answer past the TTL must fall through to normal routing, got %q", reply) } if reminders, err := st.DueReminders(ctx, now.Add(48*time.Hour)); err != nil || len(reminders) != 0 { t.Fatalf("expired question must not create anything: reminders=%v err=%v", reminders, err) } } // TestClarifyAsksThreeTimesThenSaysSo — three questions are allowed, the fourth // is not, and running out is SPOKEN. Silence would read as "handled". func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { t.Fatal("expected a first question") } // Two more unclear answers ⇒ two more questions (3 asks in total). for i := 2; i <= 3; i++ { reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю") if !handled { t.Fatalf("answer %d must be consumed as an answer", i) } // The wording changes with the attempt (Vikunja #457): repeating a // question he already failed to answer is the worst way to ask it. want, _ := whenQuestion(whenNoHour, i, h.now(), "") if reply != want { t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply) } if first, _ := whenQuestion(whenNoHour, 1, h.now(), ""); reply == first { t.Fatalf("attempt %d repeated the first wording: %q", i, reply) } if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil { t.Fatalf("attempt %d must leave the question armed", i) } } reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю") if !handled || reply != clarifyGaveUp { t.Fatalf("the fourth try must give up out loud, handled=%v reply=%q", handled, reply) } if reply == "" || strings.Contains(reply, "?") { t.Fatalf("giving up must be spoken and must not be another question: %q", reply) } if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { t.Fatal("a given-up request must leave no armed question") } if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 { t.Fatalf("a given-up request must not create anything: reminders=%v err=%v", reminders, err) } } // TestClarifyMaxAttemptsIsConfigurable — one question when the config says one. func TestClarifyMaxAttemptsIsConfigurable(t *testing.T) { ctx := context.Background() h, _, _ := newClarifyHandler(t) h.clarifyMaxAttempts = 1 if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { t.Fatal("expected a question") } if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp { t.Fatalf("with max 1 she must give up at once, handled=%v reply=%q", handled, reply) } } // TestClarifyRestatedAnswerWins — «в 11:00», then «нет, в 15:00». The second // value is the one that lands. func TestClarifyRestatedAnswerWins(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked { t.Fatal("expected a question") } // First answer parses, but re-park it by hand as if she had asked again: // what matters here is that Answer prefers the newer value over the parked // one, which is the case the daemon hits on a re-ask. q := h.clarifyStore.Get(voiceDialogueID, h.now()) if q == nil { t.Fatal("expected an armed question") } first := h.extractor.Extract(ctx, router.IntentReminder, "сегодня в 11:00", h.now()) q.Slots = q.Answer("сегодня в 11:00", toDialogueSlots(first)) q.WhenText = "сегодня в 11:00" if reply, handled := h.resolveClarifyAnswer(ctx, "нет, сегодня в 15:00"); !handled || reply == clarifyGaveUp { t.Fatalf("the restated answer should complete the request, handled=%v reply=%q", handled, reply) } reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)) if err != nil || len(reminders) != 1 { t.Fatalf("expected one reminder: %v err=%v", reminders, err) } want := h.extractor.Extract(ctx, router.IntentReminder, "в 15:00", h.now()) if !reminders[0].FireTs.Equal(want.Time) { t.Fatalf("reminder at %v, want the restated %v", reminders[0].FireTs, want.Time) } } // TestActOffAllowlistIsStillRefused — naming a capability is not being granted // one. Since Vikunja #556 an unresolved act no longer parks a question, so this // goes through applyAction, which is the only way an act runs. func TestActOffAllowlistIsStillRefused(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) marker := filepath.Join(t.TempDir(), "not-allowed-ran") if err := st.EnableTool(ctx, "uptime", []string{"true"}, false, "test", h.now()); err != nil { t.Fatal(err) } reply := h.applyAction(ctx, router.Decision{ Utterance: "rm " + marker, Intent: router.IntentAct, Slots: router.Slots{Fn: "rm " + marker, HasFn: true}, }) if strings.Contains(reply, "готово") { t.Fatalf("an act that is not on the allowlist must not report success: %q", reply) } if _, err := os.Stat(marker); !os.IsNotExist(err) { t.Fatalf("a clarified act off the allowlist ran anyway: %v", err) } if tools, err := st.ListTools(ctx, "enabled"); err != nil || len(tools) != 1 { t.Fatalf("an act must not enable a tool: tools=%+v err=%v", tools, err) } } // TestDestructiveActStillNeedsConfirm — the confirm gate stands on the act path. func TestDestructiveActStillNeedsConfirm(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) marker := filepath.Join(t.TempDir(), "destructive-ran") if err := st.EnableTool(ctx, "delete_backups", []string{"touch", marker}, true, "test", h.now()); err != nil { t.Fatal(err) } reply := h.applyAction(ctx, router.Decision{ Utterance: "delete_backups", Intent: router.IntentAct, Slots: router.Slots{Fn: "delete_backups", HasFn: true}, }) if !strings.Contains(reply, "да") || h.pending == nil { t.Fatalf("a destructive act must park a confirm: reply=%q pending=%+v", reply, h.pending) } if _, err := os.Stat(marker); !os.IsNotExist(err) { t.Fatalf("a destructive act ran before confirmation: %v", err) } } // TestNoQuestionWhenNothingIsMissing — noise keeps the canned reply, so she // never invents a question for nothing. func TestNoQuestionWhenNothingIsMissing(t *testing.T) { h, _, _ := newClarifyHandler(t) for _, dec := range []router.Decision{ clarifyDec(router.IntentChat, router.Slots{Text: "эм"}, "эм"), clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"), clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."), } { if question, asked := h.askClarify(context.Background(), dec); asked { t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question) } } if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { t.Fatal("noise must not park a question") } } // TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the // TTL: she must say the old request is gone AND still answer the new words. func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) { ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "")) h, _, now := newClarifyHandler(t) emb := router.NewHashEmbedder(1024) h.recall.embedder = emb h.router = buildRouter(emb, h.matcher, 0.55, nil) if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { t.Fatal("expected a question") } *now = now.Add(clarifyTTL + time.Second) reply := h.handleText(ctx, "", "как дела") if !isClarifyExpired(reply) { t.Fatalf("expired question must be announced first, got %q", reply) } if trimClarifyExpired(reply) == "" { t.Fatalf("the new words must still be answered, got only the notice: %q", reply) } if h.clarifyStore.Get(textDialogueID, h.now()) != nil { t.Fatal("the expired question must be gone") } // The notice is said once, not on every later utterance. if reply := h.handleText(ctx, "", "как дела"); isClarifyExpired(reply) { t.Fatalf("notice repeated on a later turn: %q", reply) } } // TestNoPendingQuestionFallsThrough — with nothing parked, an utterance routes // normally. func TestNoPendingQuestionFallsThrough(t *testing.T) { h, _, _ := newClarifyHandler(t) if reply, handled := h.resolveClarifyAnswer(context.Background(), "напомни в 11:00"); handled { t.Fatalf("no open question ⇒ must not be treated as an answer, got %q", reply) } } // TestClarifyAsksAboutTheSecondGapToo — "напомни" with neither a subject nor a // time. She asks about the subject, he gives it, and the request is still not // complete. The old code handed applyAction a reminder with no time, which // answered with a parse error for a question she never asked. func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни")) if !asked || question != "О чём напомнить?" { t.Fatalf("expected the subject question, got %q asked=%v", question, asked) } reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме") if !handled { t.Fatal("the answer must be consumed as an answer") } // Second gap, second attempt, so it is the second wording of the time // question — the attempt budget is shared between the two paths. want, _ := whenQuestion(whenNoHour, 2, h.now(), "") if reply != want { t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply) } q := h.clarifyStore.Get(voiceDialogueID, h.now()) if q == nil { t.Fatal("the second gap must leave a question armed") } if q.Slots.Text == "" { t.Fatalf("the re-parked question lost the answered subject: %+v", q.Slots) } if reply, handled := h.resolveClarifyAnswer(ctx, "сегодня в 11:00"); !handled || reply == clarifyGaveUp { t.Fatalf("the time answer must complete the reminder, handled=%v reply=%q", handled, reply) } reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)) if err != nil || len(reminders) != 1 { t.Fatalf("expected one reminder: %v err=%v", reminders, err) } if !strings.Contains(reminders[0].Payload, "маме") { t.Fatalf("the reminder lost the subject: %q", reminders[0].Payload) } } // TestClarifySecondGapRespectsTheAttemptCap — the second gap spends a question // out of the same budget, so it cannot turn a capped exchange into an endless // one. With one attempt allowed she acts on what she has instead of asking. func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) { ctx := context.Background() h, _, _ := newClarifyHandler(t) h.clarifyMaxAttempts = 1 if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked { t.Fatal("expected the subject question") } reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме") if !handled { t.Fatal("the answer must be consumed") } if reply == "Когда?" { t.Fatal("out of attempts she must not ask a second question") } if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { t.Fatal("no question may stay armed past the cap") } } // TestClarifyProseHoldsThePersona — these lines are hand-written Russian that // the phrasing eval never sees, because they never go through the phraser. They // carry feminine self-reference ("ждала", "отпустила") and address him with a // plain imperative, and they are exactly the kind of string someone later edits // reaching for a synonym. Run the eval's own persona checks over them here. func TestClarifyProseHoldsThePersona(t *testing.T) { // Only the persona checks. Length and on-topic do not apply: these are not // nudges, they have no rule to be on topic about, and the expiry lines are // deliberately longer than a nudge ceiling. want := map[string]bool{ eval.CheckFeminine: true, eval.CheckHisGender: true, eval.CheckAddress: true, eval.CheckCringe: true, } lines := append([]string{clarifyGaveUp, clarifyCancelled, clarifyDropped}, clarifyExpiredVariants...) lines = append(lines, clarifyMissedVariants...) for _, variants := range clarifyQuestionVariants { lines = append(lines, variants...) } for _, line := range lines { for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") { // The apology clause of the cringe check is scoped to nudges: it // exists because apologising for a greenlit nudge undermines it. // These lines are the opposite case. She did not understand him, or // she let his request go, and "прости" there is ordinary speech // rather than grovelling. Every other cringe rule still applies: // pet names, emoji, exclamations, fake concern, praise. // checkCringe returns the first break it finds, so this skip also // hides a later one in the same line. Kept narrow on purpose: it // only fires on a leading "apology (…)" detail. if r.Name == eval.CheckCringe && strings.HasPrefix(r.Detail, "apology") { continue } if want[r.Name] && !r.Pass { t.Errorf("%q fails %s: %s", line, r.Name, r.Detail) } } } } // TestExpiryNoticeSurvivesAConfirmTurn — she asks a question, he walks off, the // question expires, he comes back and answers a confirm that is still parked. // The confirm turn used to return before the notice was even computed, so he // answered the confirm and never heard that the older request was let go. func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) { ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "")) h, _, now := newClarifyHandler(t) if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { t.Fatal("expected a question") } // A confirm parked with a longer life than the question, so only the // question is stale when he speaks. h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)} *now = now.Add(clarifyTTL + time.Second) reply := h.handleText(ctx, "", "нет") if !isClarifyExpired(reply) { t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply) } if trimClarifyExpired(reply) == "" { t.Fatalf("the confirm answer must survive the notice, got only the notice: %q", reply) } if h.pending != nil { t.Fatal("the confirm must still have been consumed") } if h.clarifyStore.Get(textDialogueID, h.now()) != nil { t.Fatal("the expired question must be gone") } } // The other half of the subject question: his answer must fill the empty slot, // not replace the request. Slots.Text used to be the whole raw utterance for // every intent, so the branch that fills a text slot could only ever overwrite // (Vikunja #383). Here the parked request holds the hour and the answer holds // what to say at it, and the reminder that lands has both. func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) at := h.now().Add(2 * time.Hour) question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Time: at, HasTime: true}, "напомни сегодня в 11 утра")) if !asked || question != "О чём напомнить?" { t.Fatalf("expected the subject question, got %q asked=%v", question, asked) } reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме") if !handled { t.Fatal("the answer to an open question must be consumed as an answer") } if reply == clarifyGaveUp { t.Fatalf("a good answer must not drop the request: %q", reply) } reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)) if err != nil || len(reminders) != 1 { t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err) } if !strings.Contains(reminders[0].Payload, "маме") { t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload) } // The hour is the fire time, not a word in the body: the body is what she // says at the hour, and the time expression is stripped out of it // (Vikunja #469). Clobbering the parked request would show up here as a // reminder that fires at some other time than the one he asked for. if got := reminders[0].FireTs.UTC(); !got.Equal(at.UTC()) { t.Fatalf("the answer clobbered the original request: fires at %v, want %v", got, at.UTC()) } } // TestClarifyIsPerConversation — the parked question belongs to the reach that // was asked. Before this the clarify store had one global key, so a question // asked in the web chat and never answered captured the next utterance from // telegram, or from the mic, and answered it against a request the speaker had // never made (Vikunja #466). func TestClarifyIsPerConversation(t *testing.T) { h, _, _ := newClarifyHandler(t) web := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web")) telegram := withDialogueID(context.Background(), dialogueIDFor(sourceText, "telegram:42")) if _, asked := h.askClarify(web, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { t.Fatal("expected a question on the web conversation") } if _, handled := h.resolveClarifyAnswer(telegram, "в 11:00"); handled { t.Fatal("a question asked on the web must not eat a telegram utterance") } if _, handled := h.resolveClarifyAnswer(voiceCtx(), "в 11:00"); handled { t.Fatal("a question asked on the web must not eat what he says at the mic") } if reply, handled := h.resolveClarifyAnswer(web, "в 11:00"); !handled || reply == clarifyGaveUp { t.Fatalf("the asker's own answer must land, handled=%v reply=%q", handled, reply) } } // voiceCtx — the mic's conversation, which carries no id of its own. func voiceCtx() context.Context { return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, "")) } // TestARestartExpiresTheParkedQuestion pins the Vikunja #385 decision: the // question dies with the process, and she does not claim to have let it go — // the words that follow are routed as a fresh request. Restarting is modelled // the way the daemon does it, by building a second handler over the same store. func TestARestartExpiresTheParkedQuestion(t *testing.T) { h, _, _ := newClarifyHandler(t) ctx := voiceCtx() if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { t.Fatal("expected a question before the restart") } restarted, _, _ := newClarifyHandler(t) if _, handled := restarted.resolveClarifyAnswer(ctx, "в 11:00"); handled { t.Fatal("a question parked before the restart must not eat the next utterance") } if notice := restarted.clarifyExpiredNotice(ctx); notice != "" { t.Fatalf("notice = %q, want silence: nothing survived to expire", notice) } } // TestClarifyStepsAsideForItsOwnRequest — Vikunja #554. An act she could not // fulfil parked "Что сделать?", and the three turns after it were scored as // answers to that question: a world question, then "как дела", then the give-up // line. None of them was ever an answer. func TestClarifyStepsAsideForItsOwnRequest(t *testing.T) { ctx := context.Background() h, _, _ := newClarifyHandler(t) // A reminder, not the act this bug was found on: since Vikunja #556 an act // no longer parks anything, so it can no longer eat the turn after it. if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked { t.Fatal("a reminder with no time should be asked about") } if reply, handled := h.resolveClarifyAnswer(ctx, "кто изобрёл телефон"); handled { t.Fatalf("a world question must route as itself, got %q", reply) } // Not eating the turn is `handled == false` above, and that is the whole of // #554. Since V-561 the question also SURVIVES it: a side query suspends the // flow rather than ending it, so the reminder is still there and still on the // attempt it was parked with. q := h.clarifyStore.Get(voiceDialogueID, h.now()) if q == nil { t.Fatal("a side query must suspend the parked question, not drop it") } if q.Attempts != 1 { t.Errorf("a turn that was never an answer spent an attempt: %d, want 1", q.Attempts) } } // TestClarifyStillRetriesOnAnAnswerThatMissed — the other half of #554, and the // reason the test above is narrow. A bare noun answers nothing either, but it // carries no request of its own, so she asks again as before. func TestClarifyStillRetriesOnAnAnswerThatMissed(t *testing.T) { ctx := context.Background() h, _, _ := newClarifyHandler(t) if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked { t.Fatal("expected the time question") } reply, handled := h.resolveClarifyAnswer(ctx, "ага") if !handled || reply == "" { t.Fatalf("a missed answer must still be re-asked, handled=%v reply=%q", handled, reply) } if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil { t.Error("the question must survive a missed answer") } } // TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands — the guard runs only // where nothing was filled. "во сколько?" is question-shaped and is also how a // time gets said back, so an answer that closes the gap wins whatever its shape. func TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked { t.Fatal("expected the time question") } if reply, handled := h.resolveClarifyAnswer(ctx, "а что если сегодня в 11:00"); !handled || reply == clarifyGaveUp { t.Fatalf("an answer that fills the gap must land, handled=%v reply=%q", handled, reply) } if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 { t.Fatalf("reminder was not created: reminders=%v err=%v", reminders, err) } } // TestUnresolvedActSaysItDoesNotKnowTheCommand — Vikunja #556. "Что сделать?" // has no answer he can give, so an act that matched no capability is refused in // one line and nothing is parked. It does not recite what she can do instead. func TestUnresolvedActSaysItDoesNotKnowTheCommand(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) // Enabled tools change nothing here: this act matched none of them. if err := st.EnableTool(ctx, "uptime", []string{"true"}, false, "test", h.now()); err != nil { t.Fatal(err) } reply, spoken := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "выключи свет"}, "выключи свет")) if !spoken || reply != actNotRecognized { t.Fatalf("reply = %q spoken=%v, want %q", reply, spoken, actNotRecognized) } if strings.Contains(reply, "uptime") { t.Errorf("reply = %q, want no list of capabilities he did not ask about", reply) } if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { t.Error("nothing to ask about, so nothing may be parked") } } // newRoutingClarifyHandler wires the real cascade (hash embedder, no model) onto // the clarify handler, so a test can drive handleText end to end and see which // gate claimed the turn. func newRoutingClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store) { t.Helper() h, st, _ := newClarifyHandler(t) h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil) h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()} return h, st } // TestIncompleteReminderAsksInsteadOfFailing — Vikunja #557. "напомни позвонить" // is routed confidently and is still half a request. It used to reach applyAction, // fail on the missing time and park nothing, so the "в семь вечера" that followed // was routed as a world question and web-searched. func TestIncompleteReminderAsksInsteadOfFailing(t *testing.T) { ctx := context.Background() h, st := newRoutingClarifyHandler(t) reply := h.handleText(ctx, "web", "напомни позвонить маме") want, _ := whenQuestion(whenNoHour, 1, h.now(), "") if reply != want { t.Fatalf("reply = %q, want the time question %q", reply, want) } if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) == nil { t.Fatal("the request must be parked, or the answer has nowhere to land") } if reply := h.handleText(ctx, "web", "сегодня в семь вечера"); strings.Contains(reply, "нашла") { t.Fatalf("the answer to her own question must not be looked up: %q", reply) } if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 { t.Fatalf("the answer did not complete the reminder: reminders=%v err=%v", reminders, err) } } // TestBareCaptureVerbAsksWhatToRecord — the other half of #557. A bare "запиши" // went to the resident model as chat, which agreed to a wording change nobody // asked for. It is a fact with no key, and that gap has a question. func TestBareCaptureVerbAsksWhatToRecord(t *testing.T) { ctx := context.Background() h, _ := newRoutingClarifyHandler(t) reply := h.handleText(ctx, "web", "запиши") want, _ := clarifyQuestionFor(dialogue.SlotKey, 1) if reply != want { t.Fatalf("reply = %q, want %q", reply, want) } if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) == nil { t.Fatal("the request must be parked so the next utterance completes it") } } // TestACompleteTurnStillDoesNotAsk — the gate reads a missing slot, not any // slot, so a request she can act on must never turn into a question. Checked on // the decision rather than through the cascade: what is at stake is the gate's // condition, and driving it through the hash embedder would measure routing. func TestACompleteTurnStillDoesNotAsk(t *testing.T) { ctx := context.Background() h, _, _ := newClarifyHandler(t) complete := []router.Decision{ {Intent: router.IntentReminder, Slots: router.Slots{Text: "позвонить маме", HasTime: true}, Utterance: "напомни завтра в 11 утра позвонить маме"}, {Intent: router.IntentFact, Slots: router.Slots{Key: "water", Value: "выпил", HasKey: true}, Utterance: "я выпил воды"}, {Intent: router.IntentNote, Slots: router.Slots{Text: "купить хлеб"}, Utterance: "запиши купить хлеб"}, {Intent: router.IntentQuery, Slots: router.Slots{Text: "что у меня сегодня"}, Utterance: "что у меня сегодня"}, } for _, dec := range complete { if gaps := missingFor(dec); len(gaps) > 0 { t.Errorf("%q reads as incomplete: %v", dec.Utterance, gaps) } if reply, asked := h.askClarify(ctx, dec); asked { t.Errorf("%q was answered with a question: %q", dec.Utterance, reply) } } }