17964d1162
rememberTurn runs after followUpMerge, which has already inherited a Text slot from the previous same-intent turn, so the fill-if-empty rule pinned the first topic of a run of query turns and never released it. "во сколько у меня встреча", then "какие у меня планы", then "а завтра?" continued the meeting — two turns stale. Overwrite for system and query, where Text is a topic and not a payload. A continuation is the exception and keeps what it inherited: its own utterance is the ellipsis, and the topic it carries is the real one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
170 lines
6.6 KiB
Go
170 lines
6.6 KiB
Go
package main
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/dialogue"
|
||
"github.com/kami/maven/internal/router"
|
||
)
|
||
|
||
var contNow = time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||
|
||
func contSession(intent dialogue.Intent, key string) *dialogue.Session {
|
||
return &dialogue.Session{
|
||
Intent: intent,
|
||
Slots: dialogue.Slots{Key: key, HasKey: key != "", Text: "какие напоминания на сегодня"},
|
||
Timestamp: contNow.Add(-30 * time.Second),
|
||
TTL: 2 * time.Minute,
|
||
}
|
||
}
|
||
|
||
func TestContinuationInheritsTheQuestion(t *testing.T) {
|
||
prev := contSession(dialogue.IntentQuery, "water")
|
||
dec, ok := continuationDecision(prev, "а завтра?", contNow)
|
||
if !ok {
|
||
t.Fatal("continuationDecision returned false, want a decision")
|
||
}
|
||
if dec.Intent != router.IntentQuery {
|
||
t.Errorf("intent = %q, want query", dec.Intent)
|
||
}
|
||
if dec.Slots.Key != "water" || !dec.Slots.HasKey {
|
||
t.Errorf("key = %q, want water carried over", dec.Slots.Key)
|
||
}
|
||
if !dec.Slots.HasTime {
|
||
t.Fatal("no time slot; the whole point is re-aiming the day")
|
||
}
|
||
if got, want := dec.Slots.Time.Format("2006-01-02"), "2026-08-02"; got != want {
|
||
t.Errorf("time = %s, want %s", got, want)
|
||
}
|
||
}
|
||
|
||
func TestContinuationAcceptsABareDate(t *testing.T) {
|
||
prev := contSession(dialogue.IntentQuery, "water")
|
||
for _, s := range []string{"завтра?", "вчера", "а вчера?", "и завтра"} {
|
||
if _, ok := continuationDecision(prev, s, contNow); !ok {
|
||
t.Errorf("continuationDecision(%q) = false, want true", s)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestContinuationDeclinesWhatIsNotAnEllipsis(t *testing.T) {
|
||
prev := contSession(dialogue.IntentQuery, "water")
|
||
for _, s := range []string{
|
||
// No date to re-aim at — an ordinary short utterance, the router's job.
|
||
"а что там", "а бэкап?", "привет", "",
|
||
// Content of its own: the verb is not an ellipsis.
|
||
"напомни завтра позвонить маме",
|
||
// Too long to be an ellipsis even with a date in it.
|
||
"а что у меня стоит в календаре на завтра",
|
||
} {
|
||
if _, ok := continuationDecision(prev, s, contNow); ok {
|
||
t.Errorf("continuationDecision(%q) = true, want false", s)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestContinuationDeclinesUncontinuableIntents(t *testing.T) {
|
||
// act is the one that matters: inheriting an allowlisted fn from a
|
||
// two-word utterance would be a way to run a destructive command.
|
||
// reminder is here because its payload is its Text, and the Text embeds
|
||
// the day word it was created with — see continuableIntents.
|
||
for _, in := range []dialogue.Intent{
|
||
dialogue.IntentAct, dialogue.IntentFact, dialogue.IntentNote,
|
||
dialogue.IntentChat, dialogue.IntentReminder,
|
||
} {
|
||
if _, ok := continuationDecision(contSession(in, "water"), "а завтра?", contNow); ok {
|
||
t.Errorf("continuationDecision inherited intent %q, want refusal", in)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestContinuationDeclinesWithoutALiveSession(t *testing.T) {
|
||
if _, ok := continuationDecision(nil, "а завтра?", contNow); ok {
|
||
t.Error("continued with no previous turn")
|
||
}
|
||
stale := contSession(dialogue.IntentQuery, "water")
|
||
stale.Timestamp = contNow.Add(-10 * time.Minute)
|
||
if _, ok := continuationDecision(stale, "а завтра?", contNow); ok {
|
||
t.Error("continued an expired session")
|
||
}
|
||
}
|
||
|
||
func TestContinuationNeverCarriesAnFn(t *testing.T) {
|
||
prev := contSession(dialogue.IntentQuery, "water")
|
||
prev.Slots.Fn, prev.Slots.HasFn = "restart", true
|
||
dec, ok := continuationDecision(prev, "а завтра?", contNow)
|
||
if !ok {
|
||
t.Fatal("want a decision")
|
||
}
|
||
if dec.Slots.HasFn || dec.Slots.Fn != "" {
|
||
t.Fatalf("carried fn %q into a continuation", dec.Slots.Fn)
|
||
}
|
||
}
|
||
|
||
// TestContinuationCarriesTheTopic — the ellipsis names the day; what he is
|
||
// asking ABOUT has to come from the previous turn, or replySystem keyword-
|
||
// matches "а завтра?" and finds nothing. Caught on the deployed daemon.
|
||
func TestContinuationCarriesTheTopic(t *testing.T) {
|
||
prev := contSession(dialogue.IntentSystem, "")
|
||
prev.Slots.Text = "какой сегодня день"
|
||
dec, ok := continuationDecision(prev, "а завтра?", contNow)
|
||
if !ok {
|
||
t.Fatal("want a decision")
|
||
}
|
||
if dec.Slots.Text != "какой сегодня день" {
|
||
t.Fatalf("Slots.Text = %q, want the previous turn's topic", dec.Slots.Text)
|
||
}
|
||
}
|
||
|
||
// TestReplySystemIgnoresAnInheritedTopic — the regression the deployed daemon
|
||
// showed on 01-08-2026: followUpMerge fills an empty Text from the previous
|
||
// same-intent turn, so a plain "привет" after "какой сегодня день" arrived at
|
||
// replySystem carrying the old topic and was answered with the date. Only a
|
||
// continuation may widen the keyword match.
|
||
func TestReplySystemIgnoresAnInheritedTopic(t *testing.T) {
|
||
h := &reactiveHandler{now: func() time.Time { return contNow }}
|
||
inherited := router.Decision{
|
||
Utterance: "привет",
|
||
Intent: router.IntentSystem,
|
||
Slots: router.Slots{Text: "какой сегодня день"},
|
||
}
|
||
if got := h.replySystem(nil, inherited); got != "пока не умею отвечать на этот вопрос." {
|
||
t.Fatalf("replySystem answered %q on an inherited topic", got)
|
||
}
|
||
cont := inherited
|
||
cont.Utterance, cont.Continued = "а завтра?", true
|
||
if got := h.replySystem(nil, cont); got == "пока не умею отвечать на этот вопрос." {
|
||
t.Fatalf("replySystem refused a real continuation")
|
||
}
|
||
}
|
||
|
||
// TestRememberTurnRefreshesTheTopic — rememberTurn runs after followUpMerge,
|
||
// which has already inherited a Text from the previous same-intent turn. A
|
||
// fill-if-empty rule therefore pins the FIRST topic of a run of query turns
|
||
// and never lets go, so a later "а завтра?" continues a question two turns
|
||
// old. Seen on the deployed daemon, 01-08-2026.
|
||
func TestRememberTurnRefreshesTheTopic(t *testing.T) {
|
||
h := &reactiveHandler{
|
||
now: func() time.Time { return contNow },
|
||
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
|
||
}
|
||
h.rememberTurn(nil, router.Decision{
|
||
Intent: router.IntentQuery, Utterance: "во сколько у меня встреча",
|
||
}, contNow)
|
||
// The second turn arrives with the first turn's Text already merged in.
|
||
prev := h.dialogueSessions.Get(voiceDialogueID, contNow)
|
||
h.rememberTurn(prev, router.Decision{
|
||
Intent: router.IntentQuery,
|
||
Utterance: "какие у меня планы",
|
||
Slots: router.Slots{Text: "во сколько у меня встреча"},
|
||
}, contNow)
|
||
got := h.dialogueSessions.Get(voiceDialogueID, contNow)
|
||
if got == nil {
|
||
t.Fatal("no session")
|
||
}
|
||
if got.Slots.Text != "какие у меня планы" {
|
||
t.Fatalf("topic = %q, want the latest turn's", got.Slots.Text)
|
||
}
|
||
}
|