package main import ( "context" "os" "path/filepath" "strings" "testing" "time" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" "github.com/kami/maven/internal/tool" "github.com/kami/maven/internal/voice" ) // newClarifyHandler builds a handler with the clarify path wired and no model: // stub date parser, the real fact parser, and a matcher over whatever tools the // test enabled. `now` is fixed so TTL behaviour is testable. func newClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) { t.Helper() st := newTestStore(t) api := ipc.NewStoreAPI(st) now := time.Date(2026, 7, 31, 9, 0, 0, 0, time.UTC) matcher := tool.NewMatcher(api) h := &reactiveHandler{ api: api, dataStore: st, tools: tool.NewExecutor(api, 2*time.Second), matcher: matcher, replier: voice.NewStubReplier(), now: func() time.Time { return now }, dialogueSessions: dialogue.NewSessionStore(2 * time.Minute), clarifyStore: dialogue.NewClarifyStore(clarifyTTL), extractor: router.Extractor{ Time: router.StubDateTimeParser{}, Acts: matcher, Facts: router.DefaultFactParser{}, }, } return h, st, &now } func clarifyDec(intent router.Intent, slots router.Slots, utterance string) router.Decision { return router.Decision{Utterance: utterance, Stage: 3, Intent: intent, Slots: slots, Clarify: true} } // TestClarifyQuestionForMissingSlot pins which question goes with which gap, and // which intents get no question at all. func TestClarifyQuestionForMissingSlot(t *testing.T) { cases := []struct { name string dec router.Decision want string asked bool }{ {"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "На когда напомнить?", true}, {"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true}, {"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true}, {"reminder that already has a time", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "", false}, {"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false}, {"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false}, } for _, tc := range cases { _, got, asked := clarifyQuestion(tc.dec) if asked != tc.asked || got != tc.want { t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, got, asked, tc.want, tc.asked) } } } // TestClarifyReminderCompletesOnAnswer is the whole point of the feature: she // asks for the missing time and the answer creates the reminder. func TestClarifyReminderCompletesOnAnswer(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")) if !asked || question != "На когда напомнить?" { t.Fatalf("expected the time question, got %q asked=%v", question, asked) } reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00") if !handled { t.Fatal("the answer to an open question must be consumed as an answer") } if reply == clarifyDropped { t.Fatalf("a good answer must not drop the request: %q", reply) } reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)) if err != nil || len(reminders) != 1 { t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err) } if !strings.Contains(reminders[0].Payload, "маме") { t.Fatalf("the reminder lost the original request: %q", reminders[0].Payload) } if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { t.Fatal("the question must be cleared once answered") } } // TestClarifyFactCompletesOnAnswer — the fact path, where the answer carries // both the key and the value. func TestClarifyFactCompletesOnAnswer(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked { t.Fatal("a fact with no key should be asked about") } if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyDropped { t.Fatalf("answer should complete the fact, handled=%v reply=%q", handled, reply) } if fact, err := st.LatestFact(ctx, "water"); err != nil || fact.Key != "water" { t.Fatalf("clarified fact was not written: fact=%+v err=%v", fact, err) } } // TestClarifyAnswerAfterTTLIsANewRequest — a late answer is not an answer. func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) { ctx := context.Background() h, st, now := newClarifyHandler(t) if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { t.Fatal("expected a question") } *now = now.Add(clarifyTTL + time.Second) if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); handled { t.Fatalf("an answer past the TTL must fall through to normal routing, got %q", reply) } if reminders, err := st.DueReminders(ctx, now.Add(48*time.Hour)); err != nil || len(reminders) != 0 { t.Fatalf("expired question must not create anything: reminders=%v err=%v", reminders, err) } } // TestClarifyUnclearAnswerDropsWithoutAskingAgain — MaxAttempts is 1. func TestClarifyUnclearAnswerDropsWithoutAskingAgain(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { t.Fatal("expected a question") } reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю") if !handled || reply != clarifyDropped { t.Fatalf("an unclear answer should drop the request, handled=%v reply=%q", handled, reply) } if strings.Contains(reply, "?") { t.Fatalf("she must not ask a second question: %q", reply) } if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { t.Fatal("a dropped request must leave no armed question") } if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 { t.Fatalf("a dropped request must not create anything: reminders=%v err=%v", reminders, err) } } // TestClarifiedActOffAllowlistIsStillRefused — clarification fills in an // argument, it never grants authority. func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) marker := filepath.Join(t.TempDir(), "not-allowed-ran") if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked { t.Fatal("an act with no fn should be asked about") } reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker) if !handled { t.Fatal("the answer should be consumed") } if strings.Contains(reply, "готово") { t.Fatalf("an act that is not on the allowlist must not report success: %q", reply) } if _, err := os.Stat(marker); !os.IsNotExist(err) { t.Fatalf("a clarified act off the allowlist ran anyway: %v", err) } if tools, err := st.ListTools(ctx, "enabled"); err != nil || len(tools) != 0 { t.Fatalf("clarify must not enable a tool: tools=%+v err=%v", tools, err) } } // TestClarifiedDestructiveActStillNeedsConfirm — the confirm gate survives the // clarify path. func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) marker := filepath.Join(t.TempDir(), "destructive-ran") if err := st.EnableTool(ctx, "delete_backups", []string{"touch", marker}, true, "test", h.now()); err != nil { t.Fatal(err) } if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked { t.Fatal("expected a question") } reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups") if !handled { t.Fatal("the answer should be consumed") } if !strings.Contains(reply, "да") || h.pending == nil { t.Fatalf("a clarified destructive act must still park a confirm: reply=%q pending=%+v", reply, h.pending) } if _, err := os.Stat(marker); !os.IsNotExist(err) { t.Fatalf("a clarified destructive act ran before confirmation: %v", err) } } // TestNoQuestionWhenNothingIsMissing — noise keeps the canned reply, so she // never invents a question for nothing. func TestNoQuestionWhenNothingIsMissing(t *testing.T) { h, _, _ := newClarifyHandler(t) for _, dec := range []router.Decision{ clarifyDec(router.IntentChat, router.Slots{Text: "эм"}, "эм"), clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"), clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."), } { if question, asked := h.askClarify(dec); asked { t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question) } } if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { t.Fatal("noise must not park a question") } } // TestNoPendingQuestionFallsThrough — with nothing parked, an utterance routes // normally. func TestNoPendingQuestionFallsThrough(t *testing.T) { h, _, _ := newClarifyHandler(t) if reply, handled := h.resolveClarifyAnswer(context.Background(), "напомни в 11:00"); handled { t.Fatalf("no open question ⇒ must not be treated as an answer, got %q", reply) } }