9d80a39a30
The clarify store was keyed per reach in V-466. The dialogue session was not: five call sites read and wrote the constant voiceDialogueID, so anaphora, history and the ordinal candidate list were one slot for the whole daemon. The candidate list is the half that cost something. She recites tasks at the mic, he types "первую сделал" on /chat, and it closes the second task he heard out loud on a surface that never showed him a list. Now every one of those sites reads dialogueIDOf(ctx), which handleText and the voice path already set. resolveCandidate also wrote resolved_by "tap:voice" for every pick, including a typed one. It takes the turn's source now. A row that lies about where it came from is worse than no row. Anaphora across surfaces was the other reading — one continuous conversation with her, any surface. Rejected: a phone open while he talks is the case this box hits, and two clients sharing one slot trample each other.
172 lines
6.6 KiB
Go
172 lines
6.6 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"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),
|
||
}
|
||
ctx := context.Background()
|
||
h.rememberTurn(ctx, 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(ctx, 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)
|
||
}
|
||
}
|