diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go index af92268..c895c35 100644 --- a/cmd/mavend/clarify_test.go +++ b/cmd/mavend/clarify_test.go @@ -393,3 +393,34 @@ func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) { t.Fatal("no question may stay armed past the cap") } } + +// 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 := context.Background() + h, _, now := newClarifyHandler(t) + + if _, asked := h.askClarify(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(voiceDialogueID, h.now()) != nil { + t.Fatal("the expired question must be gone") + } +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 9d21a06..8223103 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -188,33 +188,42 @@ func (h *reactiveHandler) handleText(ctx context.Context, text string) string { } // runTurn — the reactive turn pipeline shared by the voice and text entry -// points: confirm answer → expired-clarify notice → clarify answer → quiet +// points: expired-clarify notice → confirm answer → clarify answer → quiet // toggle → route → dialogue merge → clarify question → action → replier. // Takes the already-transcribed utterance, returns the reply text; the voice // path wraps it in stt/tts, the text path returns it as-is. // // The ordering is load-bearing — see the step comments. func (h *reactiveHandler) runTurn(ctx context.Context, text string) string { - // 1. confirm turn — if a destructive act is parked, this utterance is its - // y/n answer, not a fresh command. Handled before routing so "да" doesn't - // get classified as some other intent. - if reply, handled := h.resolveConfirm(ctx, text); handled { - return reply - } - - // 2. expired clarify — a question was parked but its TTL ran out, so the + // 1. expired clarify — a question was parked but its TTL ran out, so the // request behind it is gone. Say that out loud (see clarify.go) and carry // on: these words are still routed as a fresh utterance below, with the // notice glued in front of whatever the fresh routing answers. Checked // BEFORE the answer path: reading a parked question drops an expired one. + // + // Taken before the confirm check, not after, because a confirm turn returns + // early. He can be asked a question, walk off, come back and say "да" to a + // confirm that is still parked; computing the notice after that return meant + // he answered the confirm and never heard that the older request was let go. expiredNotice := h.clarifyExpiredNotice() + // 2. confirm turn — if a destructive act is parked, this utterance is its + // y/n answer, not a fresh command. Handled before routing so "да" doesn't + // get classified as some other intent. + if reply, handled := h.resolveConfirm(ctx, text); handled { + return withNotice(expiredNotice, reply) + } + // 3. clarify answer — if she asked a live question last turn, this // utterance is its answer, not a fresh command. After the confirm check: a // y/n gate is armed by her own prompt and is the narrower claim on the // utterance. + // A live question and an expired one cannot both exist for one dialogue id, + // so the notice is empty here in practice. withNotice anyway: every exit + // from runTurn carries it, and that is what stops the next one from + // forgetting. if reply, handled := h.resolveClarifyAnswer(ctx, text); handled { - return reply + return withNotice(expiredNotice, reply) } // 4. quiet-hours toggle — keyword match, not classifier-dependent.