Merge suspend and resume (#219)

V-561, and the owner's acceptance transcript. A side query no longer kills the
flow it interrupted. The answer comes first and the parked question comes back
in the same reply.

  напомни позвонить маме        -> Когда?
  какая сейчас погода в Риме?   -> погода не настроена, на какое время
                                   поставить напоминание?
  в 21:00                       -> хорошо, напомню сегодня в 21:00.

The owner rejected "Прошлую просьбу отпускаю." for this shape. It is kept for
new_request and cancel, where something really was dropped, and gone for
side_query.

clarifyResumedVariants is a new deck, one wording per slot, all infinitive so
there is no gender to get wrong. A resume spends no attempt, so it is not an
attempt ladder.

Resume is a deferred call in runTurn rather than a call at each exit. Eight
returns sit between the fall-through and the replier, and one that forgot would
park a request for ever.

askClarify pushes rather than puts when a side query needs clarifying of its
own. Put would replace the top, which is the flow the side query was allowed to
interrupt rather than kill.

TakeExpired returns a count, not a bool. The stack holds two and drops both when
the top times out, so the singular "прошлую просьбу" would have been a lie
about the number. clarifyExpiredPluralVariants covers it.

The contract row for a nested question is green and unskipped. The owner's own
transcript stays skipped, because StubDateTimeParser reads neither "на 9" nor
"на завтра". That is V-543 and V-562, and the skip reason now says so.
This commit is contained in:
2026-08-06 01:34:32 +04:00
10 changed files with 299 additions and 62 deletions
+13 -10
View File
@@ -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
+8 -8
View File
@@ -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)
}
}
+6 -4
View File
@@ -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))