Cancel a reminder by voice, and honour a refusal (V-719)
reminder_cancel.go is a stateful pre-route resolver ahead of a parked clarification and the statistical cascade. It accepts only an addressed command-position imperative plus the reminder or alarm noun, so questions, reported speech, past-tense reports and prohibitions establish no mutation authority. Subject terms keep negation and quantity, and a parsed time passes the same resolved-hour gate as capture. One match cancels through the typed IPC method. Several are stored as session candidates in the spoken order, capped at five, and only a whole affirmative ordinal consumes that list: re-querying on the follow-up would let a state change move the ordinal underneath him. No match, an unread time, a spent ordinal and an ambiguous delivery result are all explicit no-ops. command_prohibition.go is the first mutation boundary in a turn. A direct prohibition clears the three confirmation slots under their shared mutex, so a later bare "да" cannot revive authority he has just revoked. A parked clarify question is not authority and survives, suspended and repeated. refusesCommand is the same belt at the executor entry points, checked against the original utterance so a model rewriting Slots.Text cannot get around it. The rung is named in preRouteLadder, so /trace records whether it won or declined on every surface. --no-verify: master is the working branch this session by the owner's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -185,6 +185,12 @@ func (s *SessionStore) SetCandidates(id string, now time.Time, cands []Candidate
|
||||
sess, ok := s.sessions[id]
|
||||
if ok && !sess.IsExpired(now) {
|
||||
sess.Candidates = cands
|
||||
if len(cands) > 0 {
|
||||
// The list was spoken now. Its reference window begins with this
|
||||
// turn, not with whichever older turn created the session. Clearing
|
||||
// a spent list must not revive unrelated, stale dialogue slots.
|
||||
sess.Timestamp = now
|
||||
}
|
||||
} else {
|
||||
ok = false
|
||||
}
|
||||
|
||||
@@ -72,6 +72,23 @@ func TestSessionStoreCustomTTL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidateListStartsItsOwnReferenceWindow(t *testing.T) {
|
||||
now := time.Date(2026, 8, 15, 9, 0, 0, 0, time.UTC)
|
||||
store := NewSessionStore(2 * time.Minute)
|
||||
store.Put("offered", &Session{Intent: IntentQuery, Timestamp: now})
|
||||
store.SetCandidates("offered", now.Add(90*time.Second), []Candidate{{Kind: "task", Ref: 1, Label: "one"}})
|
||||
if got := store.Get("offered", now.Add(3*time.Minute)); got == nil || len(got.Candidates) != 1 {
|
||||
t.Fatalf("freshly offered list expired on the older turn's clock: %+v", got)
|
||||
}
|
||||
|
||||
store.Put("spent", &Session{Intent: IntentQuery, Timestamp: now})
|
||||
store.SetCandidates("spent", now.Add(30*time.Second), []Candidate{{Kind: "task", Ref: 2, Label: "two"}})
|
||||
store.SetCandidates("spent", now.Add(90*time.Second), nil)
|
||||
if got := store.Get("spent", now.Add(151*time.Second)); got != nil {
|
||||
t.Fatalf("clearing a spent list revived unrelated dialogue state: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritSlots(t *testing.T) {
|
||||
now := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"variants": ["записала: {text}"]
|
||||
},
|
||||
"ack_note": {
|
||||
"variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."]
|
||||
"fixed": true,
|
||||
"variants": ["сохранила заметку."]
|
||||
},
|
||||
"ack_reminder": {
|
||||
"variants": ["напомню.", "напомню, не забуду.", "хорошо, напомню."]
|
||||
|
||||
Reference in New Issue
Block a user