mavend: take the clarify expiry notice before the confirm turn

runTurn computed the notice at step 2, after the confirm check had already
returned. So he could be asked a question, walk off until it expired, come back
and say "да" to a confirm that was still parked. The confirm answered and he
never heard that the older request had been let go, even though the store had
dropped it. Every other exit from runTurn carries the notice.

The notice is now taken first and every early return wraps in withNotice,
including the clarify answer path, where it is empty in practice because one
dialogue id holds one question.

Found in review of #50.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
kami
2026-08-01 14:04:51 +04:00
parent f891a81ab2
commit b2eb08bb51
2 changed files with 50 additions and 10 deletions
+31
View File
@@ -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")
}
}