diff --git a/cmd/mavend/confirm.go b/cmd/mavend/confirm.go index 83171fb..0fa21e1 100644 --- a/cmd/mavend/confirm.go +++ b/cmd/mavend/confirm.go @@ -107,14 +107,18 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve return pr != nil && !h.now().After(pr.expiry) }, yes: func() string { - // Only record the acceptance. The tick loop reads accepted - // routines and nudges on their own interval. Building a - // reminder here made a routine fire exactly once (Vikunja #366). - if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil { - log.Printf("voice: accept proposed routine: %v", err) - return "не получилось запомнить рутину." - } - return "буду напоминать." + // Voice does NOT accept (Vikunja #367). Accepting hands the + // tick loop a standing new reason to speak, which is the same + // tier as enabling a tool — and DESIGN.md § "surface caps + // authority" says a room mic, reachable by anyone present, is + // structurally incapable of layer 3. So a spoken "да" leaves + // the row 'proposed' and points at the authed page, where the + // accept button is gated at step-up. The convenience of + // answering out loud stays; the authority does not move. + // + // Acceptance itself is recorded by /routines, and the tick + // loop nudges on the interval from there (Vikunja #366). + return "поняла — подтверди на странице рутин, и начну напоминать." }, no: func() string { if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil { diff --git a/cmd/mavend/patterns_test.go b/cmd/mavend/patterns_test.go index 1b14524..0809f73 100644 --- a/cmd/mavend/patterns_test.go +++ b/cmd/mavend/patterns_test.go @@ -9,6 +9,7 @@ import ( "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/delivery" + "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/pattern" "github.com/kami/maven/internal/store" @@ -283,3 +284,81 @@ func TestTickProposalCooldownSpacesAnnouncements(t *testing.T) { } } } + +// TestVoiceYesDoesNotAcceptRoutine — Vikunja #367. Accepting a routine hands +// the tick loop a standing new reason to speak, which DESIGN.md puts at layer +// 3, and voice is structurally incapable of layer 3. A spoken "да" must park +// the decision for the authed page, not flip the row itself. +func TestVoiceYesDoesNotAcceptRoutine(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + now := refNow() + seedRefillEvents(t, st, ctx, now, pattern.MinEvents-1) + + h := &reactiveHandler{api: ipc.NewStoreAPI(st), dataStore: st, now: func() time.Time { return now }} + + // The MinEvents'th event is the one that makes the pattern detectable, and + // it goes through the voice path so the proposal is parked for a y/n. + last := now.Add(time.Duration(pattern.MinEvents-1) * 7 * 24 * time.Hour) + factID, err := st.WriteFact(ctx, last, store.KindSelf, "cat_water", "refill", "voice", 1.0, sql.NullInt64{}) + if err != nil { + t.Fatalf("write fact: %v", err) + } + if phrase := h.detectPattern(ctx, factID, "cat_water", "refill", last); phrase == "" { + t.Fatal("expected a parked routine proposal") + } + + reply, handled := h.resolveConfirm(ctx, "да") + if !handled { + t.Fatal("the spoken yes should be consumed by the routine confirm") + } + if !strings.Contains(reply, "рутин") { + t.Fatalf("reply should send him to the routines page, got %q", reply) + } + + rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineAccepted) + if err != nil { + t.Fatalf("list accepted: %v", err) + } + if len(rows) != 0 { + t.Fatalf("voice accepted a routine: %+v", rows) + } + proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed) + if err != nil { + t.Fatalf("list proposed: %v", err) + } + if len(proposed) != 1 { + t.Fatalf("proposed routines = %d, want 1 (still waiting for the page)", len(proposed)) + } +} + +// TestVoiceNoStillDismissesRoutine — declining does not move the boundary +// outward, so voice keeps it. Only acceptance is gated. +func TestVoiceNoStillDismissesRoutine(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + now := refNow() + seedRefillEvents(t, st, ctx, now, pattern.MinEvents-1) + + h := &reactiveHandler{api: ipc.NewStoreAPI(st), dataStore: st, now: func() time.Time { return now }} + + last := now.Add(time.Duration(pattern.MinEvents-1) * 7 * 24 * time.Hour) + factID, err := st.WriteFact(ctx, last, store.KindSelf, "cat_water", "refill", "voice", 1.0, sql.NullInt64{}) + if err != nil { + t.Fatalf("write fact: %v", err) + } + if phrase := h.detectPattern(ctx, factID, "cat_water", "refill", last); phrase == "" { + t.Fatal("expected a parked routine proposal") + } + + if _, handled := h.resolveConfirm(ctx, "нет"); !handled { + t.Fatal("the spoken no should be consumed by the routine confirm") + } + rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineDismissed) + if err != nil { + t.Fatalf("list dismissed: %v", err) + } + if len(rows) != 1 { + t.Fatalf("dismissed routines = %d, want 1", len(rows)) + } +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index b39823e..8711f01 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -1210,13 +1210,10 @@ func routineRows(rs []ipc.ProposedRoutine) []routineRow { return out } -// acceptRoutine creates the recurring reminder for a proposal, then marks the -// proposal accepted and links the reminder to it. Weekly patterns get a cron -// expression; any other interval fires once. -// -// TODO(vikunja#46): this mirrors the voice accept path in cmd/mavend/voice.go. -// When the tick loop learns to read accepted proposals directly, both callers -// should hand off to one place in core instead of each building a reminder. +// acceptRoutine marks a proposal accepted. This page is the ONLY surface that +// may do it (Vikunja #367): accepting gives the tick loop a standing new +// reason to speak, which DESIGN.md puts at layer 3, and the button here is +// behind step-up. Voice can park the question and dismiss, never accept. func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error { proposed, err := core.ListProposedRoutines(ctx) if err != nil {