the suspend contract, and the row that was waiting for it (V-561)
The parseable twin of the owner's transcript goes green and loses its skip: 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. His transcript verbatim stays skipped, and V-561 was never going to unskip it. What is left there is the parser — StubDateTimeParser reads neither "на 9" nor "на завтра", so the third turn lands as an answer that filled nothing. The skip reason now names V-543 and V-562 instead of this task. Two V-560 tests asserted the drop notice and now assert the suspend: nothing says a request was let go, the reply ends with the resumed question, and the parked question is still there on attempt 1 with what it was about intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user