From c8a5b5416ecfe2b4c802d9637c61aa102694ef73 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 01:23:19 +0400 Subject: [PATCH 1/2] a side query suspends the flow instead of ending it (V-561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V-560 classified the side query correctly and then dropped the request behind it, saying "Прошлую просьбу отпускаю." The owner rejected that on sight: he asked about the weather in the middle of setting a reminder, and being told the reminder was let go reports a loss he did not ask for. It had not been lost — there was simply nowhere to put it. There is now. ClarifyStore grew a bounded stack in V-559 and nothing called Push; this is the caller it was built for. A side query leaves the question parked exactly as it is, the words are answered as themselves, and the question comes back on the end of the same reply — one utterance, two acts. The resumed question is not the first one again. "Когда?" works in the same breath as "напомни позвонить маме" and does not work after a turn about Rome, so the deck has a second form per slot that names the request: "На какое время поставить напоминание?". No attempt is spent, because he answered the side query and not the parked question, and charging a retry for a turn that was never an answer is the V-554 shape. clarifyDropped stays for new_request and cancel, where something really does die. Two things can now die at once, so TakeExpired reports a count instead of a bool and the expiry notice has a plural wording — "прошлую просьбу" when two were lost would be a lie about the number. Co-Authored-By: Claude Opus 5 --- cmd/mavend/clarify.go | 192 +++++++++++++++++++++++++++++---- cmd/mavend/clarifytemplates.go | 32 ++++++ cmd/mavend/turnroute.go | 11 ++ cmd/mavend/voice.go | 10 +- internal/dialogue/clarify.go | 23 ++-- 5 files changed, 236 insertions(+), 32 deletions(-) diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go index f0d532d..413b219 100644 --- a/cmd/mavend/clarify.go +++ b/cmd/mavend/clarify.go @@ -6,6 +6,8 @@ import ( "math/rand" "strings" "time" + "unicode" + "unicode/utf8" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/router" @@ -65,8 +67,28 @@ var clarifyExpiredVariants = []string{ "Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.", } -// clarifyExpiredLine picks one of them at random. -func clarifyExpiredLine() string { +// clarifyExpiredPluralVariants — the same notice when TWO parked requests died +// together (Vikunja #561). Since a side query suspends the flow instead of +// dropping it, the stack can hold both the flow and the thing he interrupted it +// with, and TakeExpired drops the whole stack when the top times out. "Прошлую +// просьбу" would then be a lie about the count: he loses two and hears about +// one. +// +// Two phrasings only, against five for the singular. This fires when he walks +// off in the middle of an interrupted exchange, which is rarer than walking off +// in the middle of a plain one, so it repeats less and needs less variety. +var clarifyExpiredPluralVariants = []string{ + "Прости, я слишком долго ждала и отпустила обе прошлые просьбы. Если они ещё нужны, скажи заново.", + "Я не дождалась ответа и убрала обе прошлые просьбы. Повтори, если они всё ещё нужны.", +} + +// clarifyExpiredLine picks one of them at random. n is how many requests died; +// anything above one gets the plural wording, because the bound is two today and +// a third would still be "обе" short of the truth only if MaxStackDepth grew. +func clarifyExpiredLine(n int) string { + if n > 1 { + return clarifyExpiredPluralVariants[rand.Intn(len(clarifyExpiredPluralVariants))] + } return clarifyExpiredVariants[rand.Intn(len(clarifyExpiredVariants))] } @@ -74,23 +96,34 @@ func clarifyExpiredLine() string { // notice is glued in front of this turn's reply (see withNotice), so a caller // checking for it has to match a prefix, not the whole string. func isClarifyExpired(s string) bool { - for _, v := range clarifyExpiredVariants { - if strings.HasPrefix(s, v) { - return true - } - } - return false + _, ok := cutClarifyExpired(s) + return ok } // trimClarifyExpired strips a leading expiry notice, leaving this turn's actual // reply. "" ⇒ the notice was the whole thing. func trimClarifyExpired(s string) string { - for _, v := range clarifyExpiredVariants { - if strings.HasPrefix(s, v) { - return strings.TrimSpace(strings.TrimPrefix(s, v)) + rest, ok := cutClarifyExpired(s) + if !ok { + return strings.TrimSpace(s) + } + return rest +} + +// cutClarifyExpired matches either expiry deck as a prefix and returns what +// follows it. Both decks, since V-561 added the plural line: a caller asking +// "did she say a request timed out" means the fact, not which wording carried +// it, and a helper that knew only the singular would read the two-request +// notice as ordinary reply text. +func cutClarifyExpired(s string) (string, bool) { + for _, deck := range [][]string{clarifyExpiredVariants, clarifyExpiredPluralVariants} { + for _, v := range deck { + if strings.HasPrefix(s, v) { + return strings.TrimSpace(strings.TrimPrefix(s, v)), true + } } } - return strings.TrimSpace(s) + return "", false } // clarifyExpiredNotice returns that line when a parked question had just timed @@ -101,11 +134,12 @@ func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string { if h.clarifyStore == nil { return "" } - if !h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) { + n := h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) + if n == 0 { return "" } - log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh") - return clarifyExpiredLine() + log.Printf("voice: clarify — %d parked question(s) expired, telling him and routing the words fresh", n) + return clarifyExpiredLine(n) } // withNotice glues the expiry notice in front of this turn's reply. One turn @@ -121,6 +155,48 @@ func withNotice(notice, reply string) string { return notice + " " + reply } +// withResumed puts the resumed question on the END of this turn's reply, where +// withNotice puts the expiry notice on the front (Vikunja #561). +// +// The order is the owner's: "в Риме сейчас ..., на какое время поставить +// напоминание?" — answer first, then the open question. A question in front of +// its own answer would read as ignoring what he asked. +// +// A statement's full stop is folded into a comma, so the two acts read as one +// sentence — that is the owner's own punctuation, "в Риме сейчас ..., на какое +// время поставить напоминание?". An answer that is ITSELF a question keeps its +// mark and the resume starts a new sentence: she sometimes answers a side query +// by asking him to say it again, and "переформулировать?, на какое время" folds +// two questions into one unreadable line. +// +// A resume with no answer in front of it is just the question. +func withResumed(reply, resumed string) string { + if resumed == "" { + return reply + } + reply = strings.TrimSpace(reply) + if reply == "" { + return resumed + } + if strings.HasSuffix(reply, "?") { + return reply + " " + resumed + } + if trimmed := strings.TrimRight(reply, ".!"); trimmed != "" { + reply = trimmed + } + return reply + ", " + lowerFirst(resumed) +} + +// lowerFirst lowercases the opening rune, so a deck line written as a standalone +// sentence reads as the second half of one. Only the first rune: "На какое +// время" must become "на какое время" and nothing else in it may move. +func lowerFirst(s string) string { + for i, r := range s { + return string(unicode.ToLower(r)) + s[i+utf8.RuneLen(r):] + } + return s +} + // missingFor returns the slots a decision still needs, most important first. // Empty ⇒ there is nothing identifiable to ask about. func missingFor(dec router.Decision) []dialogue.Slot { @@ -161,7 +237,7 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) ( log.Printf("voice: clarify — act %q matched no capability; saying so instead of asking", dec.Utterance) return actNotRecognized, true } - h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{ + q := &dialogue.PendingQuestion{ Intent: dialogue.Intent(dec.Intent), Slots: toDialogueSlots(dec.Slots), Missing: []dialogue.Slot{slot}, @@ -170,7 +246,33 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) ( TTL: clarifyTTL, Attempts: 1, // this ask MaxAttempts: h.clarifyMaxAttempts, - }) + } + // Push, not Put, when this turn suspended a flow (Vikunja #561): the side + // query needs clarifying of ITS own, and Put would replace the top of the + // stack — which is the very question the side query was allowed to interrupt + // rather than kill. Push keeps both. + // + // Push returns whatever the depth bound forced out, and that one has to be + // spoken: MaxStackDepth is a promise that every level she keeps is a level + // she can name when it dies. It is glued in front, like every other notice + // about something let go. + rt := turnRouteFrom(ctx) + if rt != nil && rt.suspended { + if evicted := h.clarifyStore.Push(dialogueIDOf(ctx), q); evicted != nil { + log.Printf("voice: clarify — stack full at %d, letting go of the request behind %q", dialogue.MaxStackDepth, evicted.Utterance) + rt.dropped = withNotice(rt.dropped, clarifyDropped) + } + // One question per breath still holds. The side query turned out to need + // a question of its own, so THAT is the one she asks; resuming as well + // would put two questions in one reply, which is the interrogation + // askRemainingGap already refuses to run. The suspended flow keeps its + // place underneath and is not lost — if it is never reached it dies on + // the TTL, and the expiry notice (now plural-aware) says so. + rt.resume = "" + log.Printf("voice: clarify — asked about %s for intent=%s, stacked on a suspended flow", slot, dec.Intent) + return question, true + } + h.clarifyStore.Put(dialogueIDOf(ctx), q) log.Printf("voice: clarify — asked about %s for intent=%s", slot, dec.Intent) return question, true } @@ -180,10 +282,17 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) ( // self-reference ("отменила"), as everywhere. const clarifyCancelled = "Хорошо, отменила." -// clarifyDropped — he asked for something else instead, so the parked request +// clarifyDropped — he asked for something ELSE instead, so the parked request // is gone. Glued in front of the answer to what he actually asked, because -// nothing may be dropped in silence. V-561 suspends and resumes it instead of -// letting it go, and this line goes away with it. +// nothing may be dropped in silence. +// +// Only new_request and cancel reach this since V-561. A side query used to as +// well, and the owner rejected it on sight: he asks about the weather in the +// middle of setting a reminder, and hearing "прошлую просьбу отпускаю" tells him +// a thing he did not ask to lose has been lost. It had not been — there was +// simply nowhere to put it. Now there is (ClarifyStore's stack), so a side query +// suspends and resumes, and apologising for a drop that did not happen is worse +// than saying nothing. const clarifyDropped = "Прошлую просьбу отпускаю." // resolveClarifyAnswer reads an utterance against the parked question and @@ -227,7 +336,19 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) case roleCancel: h.clarifyStore.Delete(dialogueIDOf(ctx)) return clarifyCancelled, true - case roleSideQuery, roleNewRequest: + case roleSideQuery: + // He asked something of his own WITHOUT leaving the flow. The question + // stays exactly where it is — same slot, same attempt, same parked + // utterance — and these words go on to be answered as themselves. The + // resumed question is then glued onto the back of that answer, so one + // reply carries both acts (Vikunja #561). + // + // No attempt is spent. He answered the side query, not the parked + // question, and charging a retry for a turn that was never an answer is + // the V-554 shape. + h.noteSuspended(ctx, q) + return "", false + case roleNewRequest: // He moved on. A parked question used to swallow whatever came next, so // one act she could not fulfil ate the following three turns (Vikunja // #554) and a world question set a reminder for a time nobody asked for @@ -283,6 +404,35 @@ func (h *reactiveHandler) noteDropped(ctx context.Context) { } } +// noteSuspended keeps the parked question alive across a side query and records +// the words that bring it back, so runTurn can put them after this turn's answer +// (Vikunja #561). +// +// Two things happen to the question and neither is an attempt. Its clock is +// restarted, because she is about to ask it again and the 90s TTL measures the +// pause since she last spoke it — leaving Asked at the original ask would let a +// flow he is actively working through die of a wait he did not take. And the +// stack is left exactly as it is: the question is already on top, so suspending +// it is not a write. +// +// A slot with no resumed wording (clarifyResumedFor says so) resumes nothing and +// says nothing. She must not claim to be holding a question she cannot re-ask. +func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.PendingQuestion) { + rt := turnRouteFrom(ctx) + if rt == nil || len(q.Missing) == 0 { + return + } + question, ok := clarifyResumedFor(q.Missing[0]) + if !ok { + return + } + q.Asked = h.now() + h.clarifyStore.Put(dialogueIDOf(ctx), q) + rt.resume = question + rt.suspended = true + log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply", q.Missing[0]) +} + // foldAnswerIntoUtterance appends an answered subject to the original words, // unless they already carry it. "напомни" + "позвонить маме" reads as the // request he would have made in one breath. Nothing is appended when the diff --git a/cmd/mavend/clarifytemplates.go b/cmd/mavend/clarifytemplates.go index 99693db..4e366af 100644 --- a/cmd/mavend/clarifytemplates.go +++ b/cmd/mavend/clarifytemplates.go @@ -50,6 +50,38 @@ var clarifyQuestionVariants = map[dialogue.Slot][]string{ }, } +// clarifyResumedVariants — the wording for a question coming BACK after a side +// query took the turn away from it (Vikunja #561). +// +// It is not the first question again. "Когда?" works in the same breath as +// "напомни позвонить маме", because the thing it is about was just said. After +// a turn about the weather in Rome it does not: he has been thinking about +// something else, and a bare "Когда?" asks him to remember what she is holding. +// So the resumed form names the request — "напоминание", "заметка" — and the +// first form stays short. +// +// One wording per slot, not a rotation and not an attempt ladder. A resume does +// not spend an attempt (that is the point of suspending rather than re-asking), +// so there is no attempt number to vary on, and this line is heard once per +// interruption rather than repeatedly. +// +// Persona holds: infinitive, so no gender agreement, "ты" nowhere needed, no pet +// names. +var clarifyResumedVariants = map[dialogue.Slot]string{ + dialogue.SlotTime: "На какое время поставить напоминание?", + dialogue.SlotText: "Так о чём напомнить?", + dialogue.SlotKey: "Так что записать?", + dialogue.SlotFn: "Так какое действие выполнить?", +} + +// clarifyResumedFor gives the resumed wording for a slot. ("", false) when the +// slot has none, and the caller then resumes nothing rather than inventing a +// question — a flow it cannot re-ask is one it must not claim to be holding. +func clarifyResumedFor(slot dialogue.Slot) (string, bool) { + q, ok := clarifyResumedVariants[slot] + return q, ok +} + // actNotRecognized is what an act she cannot run gets (Vikunja #556). // // The deck used to ask "Что сделать?" instead. That question has no answer he diff --git a/cmd/mavend/turnroute.go b/cmd/mavend/turnroute.go index b2a5fa3..d852ad7 100644 --- a/cmd/mavend/turnroute.go +++ b/cmd/mavend/turnroute.go @@ -31,6 +31,17 @@ type turnRoute struct { // dropped — what she let go of this turn and must say out loud. A parked // request that dies without a word leaves him thinking it landed. dropped string + + // resume — the parked question, re-worded, to put AFTER this turn's answer + // (Vikunja #561). A side query does not end the flow it interrupted, so the + // reply carries two acts: the answer he asked for, then the question he + // still owes her. Empty ⇒ nothing was suspended. + resume string + // suspended — a flow is parked underneath this turn. askClarify reads it to + // decide between Put (replace the top) and Push (keep the flow and stack the + // new question on it), because a side query that needs clarifying of its own + // must not overwrite the thing it interrupted. + suspended bool } type turnRouteKey struct{} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 2909b16..1b783d9 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -255,7 +255,7 @@ const ( // 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, src turnSource) string { +func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) (reply string) { // 0. the decision record (V-564). Installed here rather than in the IPC // entry point, so the mic, telegram and the web all leave the same trail — // a record only the web produced would be missing exactly the turns that @@ -311,6 +311,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour // with — carried on the same notice, so every exit below keeps it. expiredNotice = withNotice(expiredNotice, rt.dropped) + // 3b. and if it SUSPENDED a request instead of letting it go, the question + // comes back on the end of whatever these words are answered with (Vikunja + // #561). A deferred append rather than a call at each exit: there are eight + // returns between here and the replier, and the flow has to survive all of + // them — one that forgot would be a request parked for ever, waiting for an + // answer to a question he never heard asked. + defer func() { reply = withResumed(reply, rt.resume) }() + // 4. quiet-hours toggle — keyword match, not classifier-dependent. // "тихий режим" / "quiet on" would route through the classifier // unreliably (it's a command, not a free-form query), so we match it diff --git a/internal/dialogue/clarify.go b/internal/dialogue/clarify.go index 4f5408a..cdab9a4 100644 --- a/internal/dialogue/clarify.go +++ b/internal/dialogue/clarify.go @@ -207,23 +207,26 @@ func (s *ClarifyStore) Depth(id string) int { return len(s.stacks[id]) } -// TakeExpired reports whether a question was parked here but its TTL ran out, -// and drops it. Get drops such a question silently, which leaves the user -// thinking his request is still alive — the caller uses this to tell him it is -// gone before treating his words as a fresh utterance. +// TakeExpired reports HOW MANY parked questions were dropped because the TTL +// ran out, and drops them. 0 ⇒ nothing was parked, or what was parked is still +// live. Get drops such a question silently, which leaves the user thinking his +// request is still alive — the caller uses this to tell him it is gone before +// treating his words as a fresh utterance. // -// It looks at the top only, and drops the whole stack when that one is dead: one -// notice is what a reply can carry, and anything parked under a question that -// timed out has been waiting at least as long. -func (s *ClarifyStore) TakeExpired(id string, now time.Time) bool { +// It looks at the top only, and drops the whole stack when that one is dead: +// anything parked under a question that timed out has been waiting at least as +// long. The COUNT rather than a bool since V-561, because the stack can now +// hold two — the flow and the side query that suspended it — and a notice +// saying "прошлую просьбу" when two died is a lie about the count. +func (s *ClarifyStore) TakeExpired(id string, now time.Time) int { s.mu.Lock() defer s.mu.Unlock() stack := s.stacks[id] if len(stack) == 0 || !stack[len(stack)-1].IsExpired(now) { - return false + return 0 } delete(s.stacks, id) - return true + return len(stack) } // Delete drops every question parked for this id. The old single-slot Delete From 9ed259660d1b54131df8f0784e256a6f2d29ce1e Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 01:23:29 +0400 Subject: [PATCH 2/2] the suspend contract, and the row that was waiting for it (V-561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/mavend/clarify_test.go | 12 ++++++++++-- cmd/mavend/dialogue_contract_test.go | 26 +++++++++++++++---------- cmd/mavend/turnrole_test.go | 29 ++++++++++++++++++++++------ internal/dialogue/clarify_test.go | 16 +++++++-------- internal/dialogue/stack_test.go | 10 ++++++---- 5 files changed, 63 insertions(+), 30 deletions(-) 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))