diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go index ac084a5..09682e4 100644 --- a/cmd/mavend/clarify_test.go +++ b/cmd/mavend/clarify_test.go @@ -581,8 +581,16 @@ func TestClarifyStepsAsideForItsOwnRequest(t *testing.T) { if reply, handled := h.resolveClarifyAnswer(ctx, "кто изобрёл телефон"); handled { t.Fatalf("a world question must route as itself, got %q", reply) } - if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { - t.Error("the parked question must be dropped, not left to eat the turn after this one") + // Not eating the turn is `handled == false` above, and that is the whole of + // #554. Since V-561 the question also SURVIVES it: a side query suspends the + // flow rather than ending it, so the reminder is still there and still on the + // attempt it was parked with. + q := h.clarifyStore.Get(voiceDialogueID, h.now()) + if q == nil { + t.Fatal("a side query must suspend the parked question, not drop it") + } + if q.Attempts != 1 { + t.Errorf("a turn that was never an answer spent an attempt: %d, want 1", q.Attempts) } } diff --git a/cmd/mavend/dialogue_contract_test.go b/cmd/mavend/dialogue_contract_test.go index 89b8e64..b4c7533 100644 --- a/cmd/mavend/dialogue_contract_test.go +++ b/cmd/mavend/dialogue_contract_test.go @@ -452,13 +452,16 @@ func dialogueTraces() []trace { // still standing, on the same attempt — a side query is not a failed // answer and must not spend a retry. // - // Unskipping this needs more than V-561. "на 9" and "на завтра" are not - // read by StubDateTimeParser, which is what the offline floor runs, so - // the row below it is the same shape in words the floor can parse and is - // the one to watch first. + // Unskipping this needs more than V-561, and V-561 landing did not change + // that. The suspend and resume it asked for is done — the row below is + // the same shape in words the floor can parse and is green. What is left + // here is the parser: StubDateTimeParser does not read "на 9" or "на + // завтра", so turn 3 lands as an answer that filled nothing and spends a + // retry, which is what this row now fails on. V-562 and V-543 own the + // ambiguous hour and the day correction behind those two words. { name: "the owner's transcript from V-561", - skip: "V-561: a parked question is not suspended for a side query and never resumes", + skip: "V-543/V-562: the floor's date parser reads neither «на 9» nor «на завтра»", turns: []turn{ {say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1, parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}}, @@ -470,13 +473,16 @@ func dialogueTraces() []trace { }, end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}}, }, - // The same shape said in words StubDateTimeParser reads, so this row - // turns green on V-561 alone. Same three claims: Rome is answered, the - // question survives the side query on the same attempt, and the answer - // after it completes the reminder he actually asked for. + // The same shape said in words StubDateTimeParser reads. GREEN since + // V-561. Same three claims: Rome is answered, the question survives the + // side query on the same attempt, and the answer after it completes the + // reminder he actually asked for. + // + // It sits under the "fail today" header because the row above it still + // does. Do not re-skip it to tidy that up: this is the owner's + // acceptance test in the only words the offline floor can read. { name: "nested question: a parked question, then one of his own", - skip: "V-561: a side query drops the parked question instead of suspending it", turns: []turn{ {say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1, parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}}, diff --git a/cmd/mavend/turnrole_test.go b/cmd/mavend/turnrole_test.go index 88fdf71..4d85b68 100644 --- a/cmd/mavend/turnrole_test.go +++ b/cmd/mavend/turnrole_test.go @@ -167,8 +167,12 @@ func TestTurnRoleNamesACorrection(t *testing.T) { // 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, must not create a reminder for a time nobody asked for, and must -// not be dropped in silence. +// 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) @@ -180,14 +184,27 @@ func TestRomeIsAnsweredAndTheReminderIsNotInvented(t *testing.T) { if strings.Contains(reply, "напомню") { t.Fatalf("the question was eaten as the reminder's time again: %q", reply) } - if !strings.HasPrefix(reply, clarifyDropped) { - t.Fatalf("the parked request died without a word: %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) } - if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) != nil { - t.Fatal("the parked question must be gone, not left to eat the next turn") + // 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) } } diff --git a/internal/dialogue/clarify_test.go b/internal/dialogue/clarify_test.go index 513e2e6..0bfdfda 100644 --- a/internal/dialogue/clarify_test.go +++ b/internal/dialogue/clarify_test.go @@ -64,21 +64,21 @@ func TestClarifyStoreGetPutDelete(t *testing.T) { // whose TTL ran out. func TestClarifyStoreTakeExpired(t *testing.T) { s := NewClarifyStore(time.Minute) - if s.TakeExpired("voice", base) { - t.Fatal("nothing parked ⇒ nothing expired") + if n := s.TakeExpired("voice", base); n != 0 { + t.Fatalf("nothing parked ⇒ nothing expired, got %d", n) } s.Put("voice", &PendingQuestion{Missing: []Slot{SlotTime}, Asked: base, TTL: time.Minute}) - if s.TakeExpired("voice", base.Add(30*time.Second)) { - t.Fatal("a live question must not report as expired") + if n := s.TakeExpired("voice", base.Add(30*time.Second)); n != 0 { + t.Fatalf("a live question must not report as expired, got %d", n) } if s.Get("voice", base.Add(30*time.Second)) == nil { t.Fatal("a live question must survive TakeExpired") } - if !s.TakeExpired("voice", base.Add(2*time.Minute)) { - t.Fatal("a stale question must report as expired") + if n := s.TakeExpired("voice", base.Add(2*time.Minute)); n != 1 { + t.Fatalf("a stale question must report as one expired, got %d", n) } - if s.TakeExpired("voice", base.Add(2*time.Minute)) { - t.Fatal("TakeExpired must drop the question, so the second call is false") + if n := s.TakeExpired("voice", base.Add(2*time.Minute)); n != 0 { + t.Fatalf("TakeExpired must drop the question, so the second call is 0, got %d", n) } } diff --git a/internal/dialogue/stack_test.go b/internal/dialogue/stack_test.go index 8cd9156..ddc54a1 100644 --- a/internal/dialogue/stack_test.go +++ b/internal/dialogue/stack_test.go @@ -116,14 +116,16 @@ func TestStackExpiryDropsTheStackAndIsReported(t *testing.T) { s.Push("voice", parked("напомни", pendingBase)) s.Push("voice", parked("погода", pendingBase)) - if !s.TakeExpired("voice", late) { - t.Error("TakeExpired did not report the timed-out exchange") + // Two died, and the count says two: the notice that reports this has a + // plural wording since V-561, and it is chosen off this number. + if n := s.TakeExpired("voice", late); n != 2 { + t.Errorf("TakeExpired reported %d timed-out questions, want 2", n) } if s.Depth("voice") != 0 { t.Error("TakeExpired left entries behind") } - if s.TakeExpired("voice", late) { - t.Error("TakeExpired reported twice") + if n := s.TakeExpired("voice", late); n != 0 { + t.Errorf("TakeExpired reported twice: %d", n) } // Pop of an expired top yields nothing rather than a dead action. s.Push("voice", parked("напомни", pendingBase))