package main import ( "context" "testing" "time" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/router" ) func TestRememberTurnKeepsIntentIndependentTranscriptInSpeakingOrder(t *testing.T) { now := time.Date(2026, 8, 13, 1, 0, 0, 0, time.UTC) h := &reactiveHandler{ now: func() time.Time { return now }, dialogueSessions: dialogue.NewSessionStore(time.Hour), } ctx := context.Background() turns := []router.Decision{ {Intent: router.IntentFact, Utterance: "я купил новый монитор", Slots: router.Slots{Key: "purchase", Value: "новый монитор", HasKey: true}}, {Intent: router.IntentQuery, Utterance: "а он большой?", Slots: router.Slots{Text: "normalized query"}}, {Intent: router.IntentChat, Utterance: "кажется, я переплатил", Slots: router.Slots{Text: "normalized chat"}}, {Intent: router.IntentQuery, Utterance: "стоит его вернуть?", Slots: router.Slots{Text: "normalized return query"}}, } for i, dec := range turns { prev := h.dialogueSessions.Get(voiceDialogueID, now) h.rememberTurn(ctx, prev, dec, now.Add(time.Duration(i)*time.Second)) } got := h.dialogueSessions.Get(voiceDialogueID, now.Add(4*time.Second)) if got == nil { t.Fatal("no dialogue session") } if got.Utterance != turns[3].Utterance { t.Fatalf("current utterance = %q, want %q", got.Utterance, turns[3].Utterance) } want := []string{turns[0].Utterance, turns[1].Utterance, turns[2].Utterance} if len(got.History) != len(want) { t.Fatalf("history = %+v, want %d prior turns", got.History, len(want)) } for i := range want { if got.History[i].Text != want[i] { t.Errorf("history[%d] = %q, want %q", i, got.History[i].Text, want[i]) } } // actionChat runs after rememberTurn. It must receive only prior turns; // handing over the current turn here would duplicate the model's user input. history := h.chatHistory(ctx) if len(history) != len(want) { t.Fatalf("chat history = %+v, want exactly the prior turns", history) } for _, turn := range history { if turn.Text == got.Utterance { t.Fatalf("current utterance was duplicated into chat history: %+v", history) } } } func TestSessionAsTurnReadsLegacySlotText(t *testing.T) { legacy := &dialogue.Session{ Intent: dialogue.IntentQuery, Slots: dialogue.Slots{Text: "старый сохранённый вопрос"}, } if got := sessionAsTurn(legacy).Text; got != legacy.Slots.Text { t.Fatalf("legacy turn text = %q, want %q", got, legacy.Slots.Text) } } func TestExplicitConversationOpenerKeepsCrossIntentSessionAlive(t *testing.T) { now := time.Date(2026, 8, 13, 1, 0, 0, 0, time.UTC) h := &reactiveHandler{ now: func() time.Time { return now }, dialogueSessions: dialogue.NewSessionStore(2 * time.Minute), } ctx := context.Background() h.rememberTurn(ctx, nil, router.Decision{ Intent: router.IntentFact, Utterance: "давай поболтаем: я купил новый монитор", Slots: router.Slots{Key: "purchase", Value: "новый монитор", HasKey: true}, }, now) later := now.Add(10 * time.Minute) prev := h.dialogueSessions.Get(voiceDialogueID, later) if prev == nil { t.Fatal("explicit conversation expired at the ordinary two-minute TTL") } if !prev.Conversational || prev.TTL != 15*time.Minute { t.Fatalf("conversation state = %+v, want conversational 15m session", prev) } got := followUpMerge(prev, router.Decision{ Intent: router.IntentQuery, Utterance: "а он большой?", }, later) if got.Intent != router.IntentChat { t.Fatalf("anaphoric follow-up intent = %s, want chat", got.Intent) } } func TestConversationOpenerDoesNotMatchAnotherDavaiCommand(t *testing.T) { if opensConversation("давай запишем новый монитор") { t.Fatal("an ordinary cooperative command opened a conversation") } for _, text := range []string{ "давай поговорим: я купил монитор", "давайте пообщаемся", "let's talk: I bought a monitor", } { if !opensConversation(text) { t.Errorf("%q did not open a conversation", text) } } }