Give up instead of acting on a missing slot (V-717)

The clarification attempt cap bounded questions, not the action schema. A
request with two required gaps could spend its budget on the first, fill it,
and reach applyAction with the second still absent, so the cap acted as
permission to execute a partial action.

resolveClarifyAnswer now rebuilds the pending action and re-runs the canonical
missingFor check after every filled gap. One remaining gap yields exactly one
next question while PendingAction.CanAsk permits it. Exhaustion says the
give-up line, pops only the active stack level, and performs no write or
action. finishRebuilt repeats the invariant at the execution boundary, so a
future dialogue caller cannot bypass it. Reminder time answers stay out of the
spoken payload but ride along in the decision copy used for validation.
This commit is contained in:
2026-08-13 11:33:05 +04:00
parent 06576b406c
commit 8ee3b76af6
4 changed files with 154 additions and 35 deletions
+67 -6
View File
@@ -394,25 +394,86 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
// TestClarifySecondGapRespectsTheAttemptCap — the second gap spends a question
// out of the same budget, so it cannot turn a capped exchange into an endless
// one. With one attempt allowed she acts on what she has instead of asking.
// one. With one attempt allowed she gives up visibly and creates nothing: the
// cap is a bound on dialogue, never a path around the action schema (V-717).
func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
ctx := context.Background()
h, _, _ := newClarifyHandler(t)
h, st, _ := newClarifyHandler(t)
h.clarifyMaxAttempts = 1
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
t.Fatal("expected the subject question")
}
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
if !handled {
t.Fatal("the answer must be consumed")
if !handled || reply != clarifyGaveUp {
t.Fatalf("out of attempts she must give up visibly, handled=%v reply=%q", handled, reply)
}
if reply == "Когда?" {
t.Fatal("out of attempts she must not ask a second question")
if isAnyClarifyQuestion(reply) {
t.Fatalf("out of attempts she must not ask another question: %q", reply)
}
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
t.Fatal("no question may stay armed past the cap")
}
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
t.Fatalf("an incomplete exhausted request created a reminder: reminders=%+v err=%v", reminders, err)
}
}
// Exhausting a nested top request removes only that request and makes the
// lower flow audible again in the same reply. This is the multi-gap exhaustion
// shape, not the ordinary failed-answer path covered in repair_test.go.
func TestClarifySecondGapExhaustionResumesLowerFlow(t *testing.T) {
ctx := context.Background()
h, st, _ := newClarifyHandler(t)
older := &dialogue.PendingQuestion{
Intent: dialogue.IntentReminder,
Slots: dialogue.Slots{Text: "позвонить маме"}, Missing: []dialogue.Slot{dialogue.SlotTime},
Utterance: "напомни позвонить маме", Asked: h.now(), TTL: clarifyTTL,
Attempts: 1, MaxAttempts: dialogue.DefaultMaxAttempts,
}
top := &dialogue.PendingQuestion{
Intent: dialogue.IntentReminder, Missing: []dialogue.Slot{dialogue.SlotText},
Utterance: "напомни", Asked: h.now(), TTL: clarifyTTL,
Attempts: 1, MaxAttempts: 1,
}
h.clarifyStore.Push(voiceDialogueID, older)
h.clarifyStore.Push(voiceDialogueID, top)
reply := h.runTurn(ctx, "купить хлеб", sourceText)
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
want := withResumed(clarifyGaveUp, resumed)
if reply != want {
t.Fatalf("reply=%q, want visible top give-up followed by resumed lower question %q", reply, want)
}
if depth := h.clarifyStore.Depth(voiceDialogueID); depth != 1 {
t.Fatalf("exhausting the top request left stack depth %d, want 1", depth)
}
if got := h.clarifyStore.Get(voiceDialogueID, h.now()); got != older {
t.Fatalf("resumed flow=%+v, want the older question", got)
}
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
t.Fatalf("nested exhaustion partially created a reminder: reminders=%+v err=%v", reminders, err)
}
}
// The rebuilt-action boundary repeats the schema invariant even though the
// normal resolver checked it one branch earlier. A future dialogue caller must
// not be able to bypass required slots by calling the completion wrapper.
func TestFinishClarifiedRefusesIncompleteAction(t *testing.T) {
ctx := context.Background()
h, st, _ := newClarifyHandler(t)
dec := router.Decision{
Utterance: "напомни позвонить маме",
Stage: 2,
Intent: router.IntentReminder,
Slots: router.Slots{Text: "позвонить маме"},
}
if reply := h.finishClarified(ctx, dec, dec); reply != clarifyGaveUp {
t.Fatalf("incomplete rebuilt action reply=%q, want %q", reply, clarifyGaveUp)
}
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
t.Fatalf("rebuilt-action guard allowed a partial reminder: reminders=%+v err=%v", reminders, err)
}
}
// TestClarifyProseHoldsThePersona — these lines are hand-written Russian that