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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+19
-10
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user