package main import ( "context" "strings" "testing" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" "github.com/kami/maven/internal/tool" "github.com/kami/maven/internal/voice" ) func TestReactiveNotesReminders(t *testing.T) { ctx := context.Background() st := newTestStore(t) api := ipc.NewStoreAPI(st) now := time.Now() emb := router.NewHashEmbedder(1024) matcher := tool.NewMatcher(api) rtr := buildRouter(emb, matcher, 0.55, nil, nil) h := &reactiveHandler{ api: api, recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()}, router: rtr, replier: voice.NewStubReplier(), now: func() time.Time { return now }, dataStore: st, } t.Run("reminder capture", func(t *testing.T) { fireAt := now.Add(24 * time.Hour).Truncate(time.Millisecond) dec := router.Decision{ Intent: router.IntentReminder, Utterance: "напомни завтра позвонить маме", Slots: router.Slots{ Text: "завтра позвонить маме", Time: fireAt, HasTime: true, }, } // The confirmation is phrased from the row now (Vikunja #507), so it // names the stored hour rather than leaving the replier to read one // out of the sentence. reply := h.applyAction(ctx, dec) if want := "хорошо, напомню завтра в " + fireAt.Format("15:04") + "."; reply != want { t.Errorf("reply = %q, want %q", reply, want) } reminders, err := st.ListReminders(ctx, 10) if err != nil { t.Fatalf("ListReminders: %v", err) } if len(reminders) == 0 { t.Fatal("expected at least one reminder, got none") } last := reminders[0] if last.FireTs.UnixMilli() != fireAt.UnixMilli() { t.Errorf("reminder FireTs = %v, want %v", last.FireTs, fireAt) } if last.Status != "pending" { t.Errorf("reminder Status = %q, want pending", last.Status) } }) t.Run("note capture", func(t *testing.T) { dec := router.Decision{ Intent: router.IntentNote, Utterance: "запомни что кофе закончился", } reply := h.applyAction(ctx, dec) if reply != "" { t.Errorf("expected empty reply from applyAction, got %q", reply) } notes, err := st.RecentNotes(ctx, 10) if err != nil { t.Fatalf("RecentNotes: %v", err) } if len(notes) == 0 { t.Fatal("expected at least one note, got none") } last := notes[0] if last.Text != "кофе закончился" { t.Errorf("note text = %q, want %q", last.Text, "кофе закончился") } }) } // TestRunTurnExplicitNoteStoresOnlyTheBody pins the live failure end to end: // a routed text turn reaches actionNote, stores only the dictated body in both // durable and vector memory, and cannot ask the resident model to choose the // acknowledgement's grammatical gender (V-721). func TestRunTurnExplicitNoteStoresOnlyTheBody(t *testing.T) { ctx := context.Background() st := newTestStore(t) api := ipc.NewStoreAPI(st) now := time.Date(2026, 8, 15, 8, 0, 0, 0, time.FixedZone("+04", 4*60*60)) emb := router.NewHashEmbedder(1024) mem := memory.NewInMemoryStore() rtr := router.New(router.Config{ Grammars: []router.Grammar{{ Name: "explicit-note-test", Decide: func(string) (router.Decision, bool) { return router.Decision{ Stage: 0, Intent: router.IntentNote, Confidence: 1, // Deliberately hostile model slot: neither persistence nor // acknowledgement may use it. Slots: router.Slots{Text: "ты поедешь на дачу"}, }, true }, }}, Threshold: 0.55, }) model := &countingCompleter{out: `{"response":"Хорошо, сохранил.","mood":"neutral"}`} h := &reactiveHandler{ api: api, router: rtr, recall: recallWiring{embedder: emb, memStore: mem}, replier: newLLMReplier(model, nil), now: func() time.Time { return now }, dataStore: st, } const utterance = "запомни: запасной ключ лежит в синей коробке" if reply := h.runTurn(ctx, utterance, sourceText); reply != "сохранила заметку." { t.Fatalf("reply = %q, want the fixed feminine acknowledgement", reply) } if model.calls != 0 { t.Fatalf("resident model was called %d time(s) for a note acknowledgement", model.calls) } notes, err := st.RecentNotes(ctx, 10) if err != nil { t.Fatalf("RecentNotes: %v", err) } const body = "запасной ключ лежит в синей коробке" if len(notes) != 1 || notes[0].Text != body || notes[0].Source != "tap:voice" || !notes[0].Ts.Equal(now) { t.Fatalf("stored notes = %+v, want one exact body at the turn time", notes) } records, err := mem.ByPrefix(ctx, "note:") if err != nil { t.Fatalf("vector catalog: %v", err) } if len(records) != 1 || records[0].Meta["text"] != body { t.Fatalf("vector records = %+v, want the same extracted body", records) } if records[0].Meta["text"] == utterance || records[0].Meta["text"] == "ты поедешь на дачу" { t.Fatalf("vector metadata used a command or model rewrite: %+v", records[0].Meta) } if !phraser.IsAck(phraser.AckNote, nil, "сохранила заметку.") { t.Fatal("fixed acknowledgement is not registered as the note acknowledgement") } } // TestSpokenTaskCaptureFilesATask — the whole path, from the utterance to the // task table. It went dead when the router started claiming the marker as an // act: capture rides the note intent, so nothing below actionNote was ever // reached and every capture answered "Что сделать?" (Vikunja #467). func TestSpokenTaskCaptureFilesATask(t *testing.T) { ctx := context.Background() st := newTestStore(t) api := ipc.NewStoreAPI(st) now := time.Now() emb := router.NewHashEmbedder(1024) matcher := tool.NewMatcher(api) h := &reactiveHandler{ api: api, recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()}, router: buildRouter(emb, matcher, 0.55, nil, nil), replier: voice.NewStubReplier(), now: func() time.Time { return now }, dataStore: st, } reply := h.handleText(ctx, "web", "добавь в задачи купить молоко") if !strings.Contains(reply, "купить молоко") { t.Fatalf("capture did not claim the turn: %q", reply) } open, err := st.ListTasks(ctx, store.TaskOpen) if err != nil || len(open) != 1 { t.Fatalf("task was not filed: tasks=%v err=%v", open, err) } // The words he said, not the model's rewrite of them. if open[0].Text != "купить молоко" { t.Fatalf("task text was rewritten: %q", open[0].Text) } }