package main import ( "testing" "time" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/router" ) var contNow = time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) func contSession(intent dialogue.Intent, key string) *dialogue.Session { return &dialogue.Session{ Intent: intent, Slots: dialogue.Slots{Key: key, HasKey: key != "", Text: "какие напоминания на сегодня"}, Timestamp: contNow.Add(-30 * time.Second), TTL: 2 * time.Minute, } } func TestContinuationInheritsTheQuestion(t *testing.T) { prev := contSession(dialogue.IntentQuery, "water") dec, ok := continuationDecision(prev, "а завтра?", contNow) if !ok { t.Fatal("continuationDecision returned false, want a decision") } if dec.Intent != router.IntentQuery { t.Errorf("intent = %q, want query", dec.Intent) } if dec.Slots.Key != "water" || !dec.Slots.HasKey { t.Errorf("key = %q, want water carried over", dec.Slots.Key) } if !dec.Slots.HasTime { t.Fatal("no time slot; the whole point is re-aiming the day") } if got, want := dec.Slots.Time.Format("2006-01-02"), "2026-08-02"; got != want { t.Errorf("time = %s, want %s", got, want) } } func TestContinuationAcceptsABareDate(t *testing.T) { prev := contSession(dialogue.IntentQuery, "water") for _, s := range []string{"завтра?", "вчера", "а вчера?", "и завтра"} { if _, ok := continuationDecision(prev, s, contNow); !ok { t.Errorf("continuationDecision(%q) = false, want true", s) } } } func TestContinuationDeclinesWhatIsNotAnEllipsis(t *testing.T) { prev := contSession(dialogue.IntentQuery, "water") for _, s := range []string{ // No date to re-aim at — an ordinary short utterance, the router's job. "а что там", "а бэкап?", "привет", "", // Content of its own: the verb is not an ellipsis. "напомни завтра позвонить маме", // Too long to be an ellipsis even with a date in it. "а что у меня стоит в календаре на завтра", } { if _, ok := continuationDecision(prev, s, contNow); ok { t.Errorf("continuationDecision(%q) = true, want false", s) } } } func TestContinuationDeclinesUncontinuableIntents(t *testing.T) { // act is the one that matters: inheriting an allowlisted fn from a // two-word utterance would be a way to run a destructive command. // reminder is here because its payload is its Text, and the Text embeds // the day word it was created with — see continuableIntents. for _, in := range []dialogue.Intent{ dialogue.IntentAct, dialogue.IntentFact, dialogue.IntentNote, dialogue.IntentChat, dialogue.IntentReminder, } { if _, ok := continuationDecision(contSession(in, "water"), "а завтра?", contNow); ok { t.Errorf("continuationDecision inherited intent %q, want refusal", in) } } } func TestContinuationDeclinesWithoutALiveSession(t *testing.T) { if _, ok := continuationDecision(nil, "а завтра?", contNow); ok { t.Error("continued with no previous turn") } stale := contSession(dialogue.IntentQuery, "water") stale.Timestamp = contNow.Add(-10 * time.Minute) if _, ok := continuationDecision(stale, "а завтра?", contNow); ok { t.Error("continued an expired session") } } func TestContinuationNeverCarriesAnFn(t *testing.T) { prev := contSession(dialogue.IntentQuery, "water") prev.Slots.Fn, prev.Slots.HasFn = "restart", true dec, ok := continuationDecision(prev, "а завтра?", contNow) if !ok { t.Fatal("want a decision") } if dec.Slots.HasFn || dec.Slots.Fn != "" { t.Fatalf("carried fn %q into a continuation", dec.Slots.Fn) } } // TestContinuationCarriesTheTopic — the ellipsis names the day; what he is // asking ABOUT has to come from the previous turn, or replySystem keyword- // matches "а завтра?" and finds nothing. Caught on the deployed daemon. func TestContinuationCarriesTheTopic(t *testing.T) { prev := contSession(dialogue.IntentSystem, "") prev.Slots.Text = "какой сегодня день" dec, ok := continuationDecision(prev, "а завтра?", contNow) if !ok { t.Fatal("want a decision") } if dec.Slots.Text != "какой сегодня день" { t.Fatalf("Slots.Text = %q, want the previous turn's topic", dec.Slots.Text) } } // TestReplySystemIgnoresAnInheritedTopic — the regression the deployed daemon // showed on 01-08-2026: followUpMerge fills an empty Text from the previous // same-intent turn, so a plain "привет" after "какой сегодня день" arrived at // replySystem carrying the old topic and was answered with the date. Only a // continuation may widen the keyword match. func TestReplySystemIgnoresAnInheritedTopic(t *testing.T) { h := &reactiveHandler{now: func() time.Time { return contNow }} inherited := router.Decision{ Utterance: "привет", Intent: router.IntentSystem, Slots: router.Slots{Text: "какой сегодня день"}, } if got := h.replySystem(nil, inherited); got != "пока не умею отвечать на этот вопрос." { t.Fatalf("replySystem answered %q on an inherited topic", got) } cont := inherited cont.Utterance, cont.Continued = "а завтра?", true if got := h.replySystem(nil, cont); got == "пока не умею отвечать на этот вопрос." { t.Fatalf("replySystem refused a real continuation") } } // TestRememberTurnRefreshesTheTopic — rememberTurn runs after followUpMerge, // which has already inherited a Text from the previous same-intent turn. A // fill-if-empty rule therefore pins the FIRST topic of a run of query turns // and never lets go, so a later "а завтра?" continues a question two turns // old. Seen on the deployed daemon, 01-08-2026. func TestRememberTurnRefreshesTheTopic(t *testing.T) { h := &reactiveHandler{ now: func() time.Time { return contNow }, dialogueSessions: dialogue.NewSessionStore(2 * time.Minute), } h.rememberTurn(nil, router.Decision{ Intent: router.IntentQuery, Utterance: "во сколько у меня встреча", }, contNow) // The second turn arrives with the first turn's Text already merged in. prev := h.dialogueSessions.Get(voiceDialogueID, contNow) h.rememberTurn(prev, router.Decision{ Intent: router.IntentQuery, Utterance: "какие у меня планы", Slots: router.Slots{Text: "во сколько у меня встреча"}, }, contNow) got := h.dialogueSessions.Get(voiceDialogueID, contNow) if got == nil { t.Fatal("no session") } if got.Slots.Text != "какие у меня планы" { t.Fatalf("topic = %q, want the latest turn's", got.Slots.Text) } }