package main import ( "context" "strings" "testing" "time" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/router" ) // TestOwnContentSeparatesAValueFromAQuestion pins the test the whole role // classifier rests on: after the frame, the numbers and the closed time sets // come out, does anything of his own remain? A hedged time leaves nothing. A // question about the weather leaves the weather. func TestOwnContentSeparatesAValueFromAQuestion(t *testing.T) { cases := []struct { text string own bool }{ {"в 11:00", false}, {"в семь вечера", false}, {"нет, в 15:00", false}, {"а что если в 11:00", false}, {"на 9", false}, {"а, да, прости — на 9", false}, {"завтра", false}, {"в половине восьмого", false}, {"какая сейчас погода в Риме?", true}, {"кто изобрёл телефон", true}, {"напомни в 11:00", true}, {"позвонить маме", true}, {"запиши что я пил воду", true}, } for _, tc := range cases { if got := len(ownContent(tc.text)) > 0; got != tc.own { t.Errorf("ownContent(%q) = %v, want own content = %v", tc.text, ownContent(tc.text), tc.own) } } } // TestCancelIsTheWholeUtterance — a call-off calls the request off, and a // sentence that merely contains the word does not. func TestCancelIsTheWholeUtterance(t *testing.T) { for _, yes := range []string{"отмена", "забудь", "неважно", "проехали", "cancel", "ой, отмена"} { if !isCancel(yes) { t.Errorf("isCancel(%q) = false, want true", yes) } } for _, no := range []string{"забудь купить молоко", "в 11:00", "позвонить маме", ""} { if isCancel(no) { t.Errorf("isCancel(%q) = true, want false", no) } } } // TestTurnRoleReadsTheRoutedDecision — the inversion itself. The same utterance // gets a different role depending on what the router made of it, which is the // evidence the old guard never had. func TestTurnRoleReadsTheRoutedDecision(t *testing.T) { q := &dialogue.PendingQuestion{ Intent: dialogue.Intent(router.IntentReminder), Missing: []dialogue.Slot{dialogue.SlotTime}, } dec := func(in router.Intent, s router.Slots) router.Decision { return router.Decision{Intent: in, Slots: s} } cases := []struct { name string text string routed router.Decision ok bool answer dialogue.Slots want turnRole }{ { // The measured defect. The extractor finds "сейчас" and would have // closed the gap with it; the route says this is a question of its // own, and the question wins. name: "a world question mid-flow is a side query", text: "какая сейчас погода в Риме?", routed: dec(router.IntentQuery, router.Slots{Text: "какая сейчас погода в Риме?"}), ok: true, answer: dialogue.Slots{HasTime: true, Time: time.Now()}, want: roleSideQuery, }, { name: "a hedged time is an answer even routed as a query", text: "а что если в 11:00", routed: dec(router.IntentQuery, router.Slots{Text: "а что если в 11:00"}), ok: true, answer: dialogue.Slots{HasTime: true, Time: time.Now()}, want: roleAnswer, }, { name: "a fresh reminder is a new request", text: "напомни завтра позвонить маме", routed: dec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}), ok: true, want: roleNewRequest, }, { name: "a capture is a new request", text: "запиши что я пил воду", routed: dec(router.IntentFact, router.Slots{Key: "water", HasKey: true}), ok: true, want: roleNewRequest, }, { name: "an act that resolved to a capability is a new request", text: "выключи свет в спальне", routed: dec(router.IntentAct, router.Slots{Fn: "light_off", HasFn: true}), ok: true, want: roleNewRequest, }, { // She could not route it. An utterance she did not understand does // not outrank the question in front of it. name: "a clarify decision is not a request of its own", text: "выключи свет", routed: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "light_off", HasFn: true}, Clarify: true}, ok: true, want: roleAnswer, }, { // V-577 shape 1. Every token is frame, so the content gate called // this an answer and the reminder took "сегодня" for its time. It // fills nothing she asked about, so the route decides, and the route // says the calendar answers it. name: "an agenda question of pure frame words is a side query", text: "что у меня сегодня?", routed: dec(router.IntentQuery, router.Slots{}), ok: true, want: roleSideQuery, }, { // V-577 shape 2. Neither a slot value nor a request nor a cancel. // It was dropped in silence; it is an aside, and an aside is stored // and re-asked. name: "a fact stated mid-flow is an aside", text: "у меня новый ноутбук", routed: dec(router.IntentNote, router.Slots{Text: "у меня новый ноутбук"}), ok: true, want: roleAside, }, { // A route she is not sure of is not evidence that he stated // anything, and "позвонить маме" is the answer to the other half of // a reminder. name: "an unsure note is not an aside", text: "позвонить маме", routed: router.Decision{Intent: router.IntentNote, Clarify: true}, ok: true, want: roleAnswer, }, { name: "a bare noun that answers nothing is still an answer", text: "ага", ok: false, want: roleAnswer, }, { name: "no route to read falls back to the shape", text: "кто изобрёл телефон", ok: false, want: roleSideQuery, }, { name: "a call-off needs no route at all", text: "отмена", ok: false, want: roleCancel, }, } for _, tc := range cases { if got := classifyTurnRole(q, tc.text, tc.answer, tc.routed, tc.ok); got != tc.want { t.Errorf("%s: classifyTurnRole(%q) = %s, want %s", tc.name, tc.text, got, tc.want) } } } // TestTurnRoleNamesACorrection — the answer overwrites a slot she was not // asking about. Handled like an answer, named as what it is. func TestTurnRoleNamesACorrection(t *testing.T) { nine := time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC) q := &dialogue.PendingQuestion{ Intent: dialogue.Intent(router.IntentReminder), Missing: []dialogue.Slot{dialogue.SlotText}, Slots: dialogue.Slots{HasTime: true, Time: nine.Add(2 * time.Hour)}, } got := classifyTurnRole(q, "нет, на 9", dialogue.Slots{HasTime: true, Time: nine}, router.Decision{}, false) if got != roleCorrection { t.Fatalf("role = %s, want %s", got, roleCorrection) } } // TestRomeIsAnsweredAndTheReminderIsNotInvented — the measured failure of // 2026-08-05, end to end through the real cascade. "напомни позвонить маме" // parks the time question; the weather question that follows must not become // its answer and must not create a reminder for a time nobody asked for. // // V-560 got that far by DROPPING the parked request and saying so, and the // owner rejected the notice on sight: he did not ask to lose the reminder. So // the contract here is V-561's — the flow is suspended, this turn's reply ends // with the question coming back, and nothing says anything was let go. func TestRomeIsAnsweredAndTheReminderIsNotInvented(t *testing.T) { ctx := context.Background() h, st := newRoutingClarifyHandler(t) if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") { t.Fatalf("expected the time question, got %q", reply) } reply := h.handleText(ctx, "web", "какая сейчас погода в Риме?") if strings.Contains(reply, "напомню") { t.Fatalf("the question was eaten as the reminder's time again: %q", reply) } if strings.Contains(reply, clarifyDropped) { t.Fatalf("a side query suspends the flow; nothing was dropped, so nothing may say so: %q", reply) } resumed, _ := clarifyResumedFor(dialogue.SlotTime) if !strings.HasSuffix(reply, resumed) { t.Fatalf("the reply must end with the resumed question %q, got %q", resumed, reply) } if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 { t.Fatalf("a reminder was invented for a time nobody asked for: %v err=%v", reminders, err) } // Still parked, and still on its first attempt: he answered the side query, // not this question, so no retry may have been spent on it. q := h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) if q == nil { t.Fatal("the parked question was dropped instead of suspended") } if q.Attempts != 1 { t.Fatalf("the side query spent a clarify attempt: attempts = %d, want 1", q.Attempts) } if !strings.Contains(q.Utterance, "маме") { t.Fatalf("the suspended request lost what it was about: %q", q.Utterance) } } // TestClarifyCancelEndsTheExchange — "отмена" while she is waiting calls the // half-built request off, out loud, and creates nothing. func TestClarifyCancelEndsTheExchange(t *testing.T) { ctx := context.Background() h, st := newRoutingClarifyHandler(t) if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") { t.Fatalf("expected the time question, got %q", reply) } if reply := h.handleText(ctx, "web", "отмена"); reply != clarifyCancelled { t.Fatalf("reply = %q, want %q", reply, clarifyCancelled) } if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 { t.Fatalf("a cancelled request still landed: %v err=%v", reminders, err) } if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) != nil { t.Fatal("a cancelled exchange must leave nothing parked") } } // TestTheTurnIsRoutedOnce — the cost bound. A turn with a question parked pays // for one extra route and not two: the clarify resolver and the pipeline read // the same memo. func TestTheTurnIsRoutedOnce(t *testing.T) { h, _ := newRoutingClarifyHandler(t) rt := h.newTurnRoute("какая сейчас погода в Риме?", h.now()) ctx := withTurnRoute(withDialogueID(context.Background(), voiceDialogueID), rt) first, ok := h.routeForRole(ctx, rt.text) if !ok { t.Fatal("the cascade must produce a decision to classify against") } second, _, _, err := rt.resolve(ctx) if err != nil { t.Fatalf("resolve: %v", err) } if second.Intent != first.Intent || second.Utterance != first.Utterance { t.Fatalf("the pipeline routed again and got something else: %+v vs %+v", second, first) } } // TestASuspendedQuestionDoesNotRideForever — V-654, the measured failure of // 2026-08-07 (docs/evals/2026-08-07-week-of-usage-transcript.md, t=51 to t=58). // // A side query suspends the parked question, spends no attempt and restarts the // TTL. Nothing else bounded it, so one unfilled time slot came back on the end // of six consecutive unrelated replies and stopped only when a seventh turn // happened to read as a failed answer. Three step-asides, then she lets it go // and says so. func TestASuspendedQuestionDoesNotRideForever(t *testing.T) { ctx := context.Background() h, st := newRoutingClarifyHandler(t) id := dialogueIDFor(sourceText, "web") resumed, _ := clarifyResumedFor(dialogue.SlotTime) if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") { t.Fatalf("expected the time question, got %q", reply) } // Three questions of his own. Each one is answered as itself and each one // brings the open question back, exactly as V-561 asks. asides := []string{ "о чём мы вчера говорили?", "какие у меня напоминания?", "сколько времени?", } for i, text := range asides { reply := h.handleText(ctx, "web", text) if !strings.HasSuffix(reply, resumed) { t.Fatalf("side query %d: the question must come back, got %q", i+1, reply) } if strings.Contains(reply, clarifyDropped) { t.Fatalf("side query %d: nothing was let go yet, so nothing may say so: %q", i+1, reply) } q := h.clarifyStore.Get(id, h.now()) if q == nil { t.Fatalf("side query %d: the question was dropped early", i+1) } if q.Attempts != 1 { t.Fatalf("side query %d: a step-aside spent an attempt: %d", i+1, q.Attempts) } if q.Suspends != i+1 { t.Fatalf("side query %d: suspends = %d, want %d", i+1, q.Suspends, i+1) } } // The fourth. She has stepped aside as often as she is willing to, so the // request goes — out loud, and without the question on the tail. reply := h.handleText(ctx, "web", "что у меня сегодня?") if !strings.Contains(reply, clarifyDropped) { t.Fatalf("the request was let go in silence: %q", reply) } if strings.HasSuffix(reply, resumed) { t.Fatalf("a question she has let go must not be asked again: %q", reply) } if h.clarifyStore.Get(id, h.now()) != nil { t.Fatal("the question must be gone once she has said she let it go") } if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 { t.Fatalf("a reminder was invented for a time nobody gave: %v err=%v", reminders, err) } } // TestAnAnsweredGapResetsTheSuspendBudget — the counter measures CONSECUTIVE // step-asides. He filled a gap, so the run is broken and the next question // starts with its full allowance: a long exchange he is engaged with must not // run out of patience on his behalf. func TestAnAnsweredGapResetsTheSuspendBudget(t *testing.T) { ctx := context.Background() h, _ := newRoutingClarifyHandler(t) id := dialogueIDFor(sourceText, "web") // A bare "напомни" is missing both halves, so answering the subject re-parks // the request with a question about the time. if reply := h.handleText(ctx, "web", "напомни"); !strings.Contains(reply, "?") { t.Fatalf("expected a question, got %q", reply) } if reply := h.handleText(ctx, "web", "какие у меня напоминания?"); reply == "" { t.Fatal("the side query must be answered as itself") } if q := h.clarifyStore.Get(id, h.now()); q == nil || q.Suspends != 1 { t.Fatalf("the side query was not counted: %+v", q) } if reply := h.handleText(ctx, "web", "позвонить маме"); reply == "" { t.Fatal("the answer must be consumed") } q := h.clarifyStore.Get(id, h.now()) if q == nil { t.Fatal("a reminder still needs its time, so a question must be parked") } if q.Suspends != 0 { t.Fatalf("answering a gap must reset the suspend budget: suspends = %d", q.Suspends) } // The ride it already took is carried across the re-park (V-663). Resetting // both counters here is what let one question ride twenty-six replies. if q.Rides != 1 { t.Fatalf("the aside it already took was forgotten: rides = %d", q.Rides) } } // TestTwoBoundsCannotRearmEachOther — V-663. // // MaxSuspends landed and the measurement did not move: twenty-six of 140 turns // carried a tail before it and twenty-six after. This is the shape it misses, // taken from the 2026-08-08 run, where one question rode turns 7 to 13. // // An aside spends no attempt, so MaxAttempts never reaches it. A turn that // reads as a failed answer zeroes Suspends, so MaxSuspends never reaches the // asides either. Alternating the two rearms each bound with the other's // traffic. Rides counts both kinds and is never reset, so it is what ends this. func TestTwoBoundsCannotRearmEachOther(t *testing.T) { ctx := context.Background() h, _ := newRoutingClarifyHandler(t) id := dialogueIDFor(sourceText, "web") resumed, _ := clarifyResumedFor(dialogue.SlotTime) if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") { t.Fatalf("expected the time question, got %q", reply) } // Two asides. Each one rides and neither spends an attempt. for i := 0; i < 2; i++ { reply := h.handleText(ctx, "web", "какие у меня напоминания?") if !strings.HasSuffix(reply, resumed) { t.Fatalf("aside %d: the question must come back, got %q", i+1, reply) } } q := h.clarifyStore.Get(id, h.now()) if q == nil || q.Rides != 2 || q.Suspends != 2 { t.Fatalf("after two asides: %+v", q) } // A pleasantry. It used to read as a failed answer, so she re-asked the // question at a man saying thank you and spent an attempt doing it. Now it // is an aside: answered as itself, question on the tail, one more ride. reply := h.handleText(ctx, "web", "спасибо") if !strings.HasSuffix(reply, resumed) { t.Fatalf("a pleasantry lost the parked question: %q", reply) } q = h.clarifyStore.Get(id, h.now()) if q == nil || q.Attempts != 1 { t.Fatalf("a pleasantry spent an attempt: %+v", q) } if q.Rides != 3 { t.Fatalf("a pleasantry rode free: %+v", q) } // One more ride of any kind and the request goes, out loud. reply = h.handleText(ctx, "web", "какие у меня напоминания?") if !strings.Contains(reply, clarifyDropped) { t.Fatalf("the question rode four asides and was let go in silence: %q", reply) } if strings.HasSuffix(reply, resumed) { t.Fatalf("a question she has let go must not be asked again: %q", reply) } if h.clarifyStore.Get(id, h.now()) != nil { t.Fatal("the question must be gone once she has said she let it go") } }