Cancel a reminder by voice, and honour a refusal (V-719)
reminder_cancel.go is a stateful pre-route resolver ahead of a parked clarification and the statistical cascade. It accepts only an addressed command-position imperative plus the reminder or alarm noun, so questions, reported speech, past-tense reports and prohibitions establish no mutation authority. Subject terms keep negation and quantity, and a parsed time passes the same resolved-hour gate as capture. One match cancels through the typed IPC method. Several are stored as session candidates in the spoken order, capped at five, and only a whole affirmative ordinal consumes that list: re-querying on the follow-up would let a state change move the ordinal underneath him. No match, an unread time, a spent ordinal and an ambiguous delivery result are all explicit no-ops. command_prohibition.go is the first mutation boundary in a turn. A direct prohibition clears the three confirmation slots under their shared mutex, so a later bare "да" cannot revive authority he has just revoked. A parked clarify question is not authority and survives, suspended and repeated. refusesCommand is the same belt at the executor entry points, checked against the original utterance so a model rewriting Slots.Text cannot get around it. The rung is named in preRouteLadder, so /trace records whether it won or declined on every surface. --no-verify: master is the working branch this session by the owner's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+341
-11
@@ -12,6 +12,7 @@
|
||||
// what was SENT — every delivery.Sendable the dispatcher emitted
|
||||
// what ARRIVED — the unified intake journal from #283
|
||||
// what TOOLS were called — the recorded requests against fake Praxis/Nexis/Hexis
|
||||
// what is DURABLE — typed notes/tasks/reminders/facts store state
|
||||
// what did NOT happen — expect_no_send / expect_no_call, first-class
|
||||
//
|
||||
// The last one is the point. Maven's hard constraints are mostly negative —
|
||||
@@ -190,14 +191,81 @@ type step struct {
|
||||
|
||||
// --- assertions ---
|
||||
|
||||
ExpectReply []string `json:"expect_reply_contains,omitempty"`
|
||||
ExpectNotReply []string `json:"expect_reply_lacks,omitempty"`
|
||||
ExpectSent []string `json:"expect_sent_contains,omitempty"`
|
||||
ExpectNoSend bool `json:"expect_no_send,omitempty"`
|
||||
ExpectCalled []string `json:"expect_called,omitempty"`
|
||||
ExpectNotCalled []string `json:"expect_not_called,omitempty"`
|
||||
ExpectEvents []string `json:"expect_events,omitempty"`
|
||||
ExpectNoEvents bool `json:"expect_no_events,omitempty"`
|
||||
ExpectReply []string `json:"expect_reply_contains,omitempty"`
|
||||
ExpectNotReply []string `json:"expect_reply_lacks,omitempty"`
|
||||
ExpectSent []string `json:"expect_sent_contains,omitempty"`
|
||||
ExpectNoSend bool `json:"expect_no_send,omitempty"`
|
||||
ExpectCalled []string `json:"expect_called,omitempty"`
|
||||
ExpectNotCalled []string `json:"expect_not_called,omitempty"`
|
||||
ExpectEvents []string `json:"expect_events,omitempty"`
|
||||
ExpectNoEvents bool `json:"expect_no_events,omitempty"`
|
||||
ExpectStore *storeStateExpectation `json:"expect_store,omitempty"`
|
||||
}
|
||||
|
||||
// storeStateExpectation is a typed, exact read of Maven's four user-visible
|
||||
// durable stores. Reply assertions prove what she said; these prove what the
|
||||
// turn actually committed. Each selected collection can assert its total row
|
||||
// count and exact row identity independently, so a duplicate insert cannot be
|
||||
// hidden by finding one matching row.
|
||||
type storeStateExpectation struct {
|
||||
Notes *noteStateExpectation `json:"notes,omitempty"`
|
||||
Tasks *taskStateExpectation `json:"tasks,omitempty"`
|
||||
Reminders *reminderStateExpectation `json:"reminders,omitempty"`
|
||||
Facts *factStateExpectation `json:"facts,omitempty"`
|
||||
}
|
||||
|
||||
type noteStateExpectation struct {
|
||||
Count *int `json:"count,omitempty"`
|
||||
Rows []noteRowExpectation `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
type noteRowExpectation struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
At string `json:"at,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
type taskStateExpectation struct {
|
||||
Count *int `json:"count,omitempty"`
|
||||
Rows []taskRowExpectation `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
type taskRowExpectation struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ResolvedAt string `json:"resolved_at,omitempty"`
|
||||
ResolvedBy string `json:"resolved_by,omitempty"`
|
||||
}
|
||||
|
||||
type reminderStateExpectation struct {
|
||||
Count *int `json:"count,omitempty"`
|
||||
Rows []reminderRowExpectation `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
type reminderRowExpectation struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
FireAt string `json:"fire_at,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type factStateExpectation struct {
|
||||
Count *int `json:"count,omitempty"`
|
||||
Rows []factRowExpectation `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
type factRowExpectation struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
At string `json:"at,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Confidence *float64 `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
type signalStep struct {
|
||||
@@ -440,6 +508,17 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
||||
})
|
||||
tl := newTickLoop(st, gatherer, dispatcher, phraser.NewStub(), rules,
|
||||
time.Minute, 5*time.Minute, 0, nil, nil, nil, nil)
|
||||
// Production upgrades the voice handler from the direct store adapter to
|
||||
// daemonAPI after the tick loop exists. Mirror that seam so a simulated
|
||||
// day-plan query reads the real store-backed plan instead of the direct
|
||||
// adapter's "not available" refusal. The fake clock is the one deliberate
|
||||
// difference from production's time.Now.
|
||||
api = &daemonAPI{
|
||||
CoreAPI: api,
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan {
|
||||
return tl.dayPlan(ctx, clock.Now())
|
||||
},
|
||||
}
|
||||
|
||||
scripted := &scriptedLLM{entries: sc.Script}
|
||||
|
||||
@@ -492,6 +571,7 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
||||
// act panicked the moment the matcher was consulted.
|
||||
matcher := tool.NewMatcher(api)
|
||||
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted), nil)
|
||||
timeParser := router.NewPythonDateParser()
|
||||
|
||||
w.handler = &reactiveHandler{
|
||||
stt: simTranscriber{},
|
||||
@@ -510,10 +590,11 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
||||
replier: newLLMReplier(scripted, nil),
|
||||
now: clock.Now,
|
||||
dataStore: st,
|
||||
timeParser: router.StubDateTimeParser{},
|
||||
timeParser: timeParser,
|
||||
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
||||
clarifyStore: dialogue.NewClarifyStore(time.Hour),
|
||||
clarifyMaxAttempts: dialogue.DefaultMaxAttempts,
|
||||
extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},
|
||||
ecosystem: eco,
|
||||
}
|
||||
return w
|
||||
@@ -599,7 +680,7 @@ func (w *simWorld) run(sc scenario) {
|
||||
eventsBefore := w.publishCount()
|
||||
|
||||
w.stimulate(ctx, s)
|
||||
w.assert(i, s, sendsBefore, callsBefore, eventsBefore)
|
||||
w.assert(ctx, i, s, sendsBefore, callsBefore, eventsBefore)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,7 +881,7 @@ func (w *simWorld) callPaths() []string { return w.callPathsSince(nil) }
|
||||
// Assertions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (w *simWorld) assert(i int, s step, sendsBefore int, callsBefore []int, eventsBefore int) {
|
||||
func (w *simWorld) assert(ctx context.Context, i int, s step, sendsBefore int, callsBefore []int, eventsBefore int) {
|
||||
w.t.Helper()
|
||||
where := fmt.Sprintf("step %d (%s)", i+1, s.At)
|
||||
if s.Note != "" {
|
||||
@@ -864,6 +945,197 @@ func (w *simWorld) assert(i int, s step, sendsBefore int, callsBefore []int, eve
|
||||
fail("expected nothing to arrive, %d event(s) were published",
|
||||
w.publishCount()-eventsBefore)
|
||||
}
|
||||
if s.ExpectStore != nil {
|
||||
w.assertStoreState(ctx, *s.ExpectStore, fail)
|
||||
}
|
||||
}
|
||||
|
||||
const simStateReadLimit = 10_000
|
||||
|
||||
func (w *simWorld) assertStoreState(ctx context.Context, want storeStateExpectation, fail func(string, ...any)) {
|
||||
if want.Notes != nil {
|
||||
rows, err := w.store.RecentNotes(ctx, simStateReadLimit)
|
||||
if err != nil {
|
||||
fail("read notes for store assertion: %v", err)
|
||||
} else {
|
||||
assertStateCount("notes", want.Notes.Count, len(rows), fail)
|
||||
used := make([]bool, len(rows))
|
||||
for _, expected := range want.Notes.Rows {
|
||||
matched, matchErr := matchDistinct(rows, used, func(row store.Note) (bool, error) {
|
||||
return w.noteStateMatches(row, expected)
|
||||
})
|
||||
if matchErr != nil {
|
||||
fail("invalid note expectation %+v: %v", expected, matchErr)
|
||||
} else if !matched {
|
||||
fail("no distinct note matches %+v; notes: %+v", expected, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if want.Tasks != nil {
|
||||
rows, err := w.store.ListTasks(ctx, "")
|
||||
if err != nil {
|
||||
fail("read tasks for store assertion: %v", err)
|
||||
} else {
|
||||
if want.Tasks.Count != nil && *want.Tasks.Count > store.MaxTaskRows {
|
||||
fail("task count assertion %d exceeds the store read bound %d", *want.Tasks.Count, store.MaxTaskRows)
|
||||
} else {
|
||||
assertStateCount("tasks", want.Tasks.Count, len(rows), fail)
|
||||
}
|
||||
used := make([]bool, len(rows))
|
||||
for _, expected := range want.Tasks.Rows {
|
||||
matched, matchErr := matchDistinct(rows, used, func(row store.Task) (bool, error) {
|
||||
return w.taskStateMatches(row, expected)
|
||||
})
|
||||
if matchErr != nil {
|
||||
fail("invalid task expectation %+v: %v", expected, matchErr)
|
||||
} else if !matched {
|
||||
fail("no distinct task matches %+v; tasks: %+v", expected, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if want.Reminders != nil {
|
||||
rows, err := w.store.ListReminders(ctx, simStateReadLimit)
|
||||
if err != nil {
|
||||
fail("read reminders for store assertion: %v", err)
|
||||
} else {
|
||||
assertStateCount("reminders", want.Reminders.Count, len(rows), fail)
|
||||
used := make([]bool, len(rows))
|
||||
for _, expected := range want.Reminders.Rows {
|
||||
matched, matchErr := matchDistinct(rows, used, func(row store.Reminder) (bool, error) {
|
||||
return w.reminderStateMatches(row, expected)
|
||||
})
|
||||
if matchErr != nil {
|
||||
fail("invalid reminder expectation %+v: %v", expected, matchErr)
|
||||
} else if !matched {
|
||||
fail("no distinct reminder matches %+v; reminders: %+v", expected, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if want.Facts != nil {
|
||||
rows, err := w.store.RecentFacts(ctx, simStateReadLimit)
|
||||
if err != nil {
|
||||
fail("read facts for store assertion: %v", err)
|
||||
} else {
|
||||
assertStateCount("facts", want.Facts.Count, len(rows), fail)
|
||||
used := make([]bool, len(rows))
|
||||
for _, expected := range want.Facts.Rows {
|
||||
matched, matchErr := matchDistinct(rows, used, func(row store.Fact) (bool, error) {
|
||||
return w.factStateMatches(row, expected)
|
||||
})
|
||||
if matchErr != nil {
|
||||
fail("invalid fact expectation %+v: %v", expected, matchErr)
|
||||
} else if !matched {
|
||||
fail("no distinct fact matches %+v; facts: %+v", expected, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertStateCount(kind string, want *int, got int, fail func(string, ...any)) {
|
||||
if want != nil && got != *want {
|
||||
fail("%s count = %d, want %d", kind, got, *want)
|
||||
}
|
||||
}
|
||||
|
||||
// matchDistinct prevents two expectations from being satisfied by the same
|
||||
// durable row. This is important for identity assertions where two records may
|
||||
// intentionally carry the same text but have different lifecycle states.
|
||||
func matchDistinct[T any](rows []T, used []bool, matches func(T) (bool, error)) (bool, error) {
|
||||
for i, row := range rows {
|
||||
if used[i] {
|
||||
continue
|
||||
}
|
||||
ok, err := matches(row)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if ok {
|
||||
used[i] = true
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (w *simWorld) noteStateMatches(got store.Note, want noteRowExpectation) (bool, error) {
|
||||
if want.ID != 0 && got.ID != want.ID || want.Text != "" && got.Text != want.Text ||
|
||||
want.Source != "" && got.Source != want.Source {
|
||||
return false, nil
|
||||
}
|
||||
return w.stateTimeMatches(got.Ts, want.At)
|
||||
}
|
||||
|
||||
func (w *simWorld) taskStateMatches(got store.Task, want taskRowExpectation) (bool, error) {
|
||||
if want.ID != 0 && got.ID != want.ID || want.Text != "" && got.Text != want.Text ||
|
||||
want.Source != "" && got.Source != want.Source || want.Status != "" && got.Status != want.Status ||
|
||||
want.ResolvedBy != "" && got.ResolvedBy != want.ResolvedBy {
|
||||
return false, nil
|
||||
}
|
||||
if ok, err := w.stateTimeMatches(got.CreatedTs, want.CreatedAt); err != nil || !ok {
|
||||
return ok, err
|
||||
}
|
||||
if want.ResolvedAt == "" {
|
||||
return true, nil
|
||||
}
|
||||
if got.ResolvedTs == nil {
|
||||
return false, nil
|
||||
}
|
||||
return w.stateTimeMatches(*got.ResolvedTs, want.ResolvedAt)
|
||||
}
|
||||
|
||||
func (w *simWorld) reminderStateMatches(got store.Reminder, want reminderRowExpectation) (bool, error) {
|
||||
if want.ID != 0 && got.ID != want.ID || want.Text != "" && got.Text() != want.Text ||
|
||||
want.Status != "" && got.Status != want.Status {
|
||||
return false, nil
|
||||
}
|
||||
return w.stateTimeMatches(got.FireTs, want.FireAt)
|
||||
}
|
||||
|
||||
func (w *simWorld) factStateMatches(got store.Fact, want factRowExpectation) (bool, error) {
|
||||
if want.ID != 0 && got.ID != want.ID || want.Kind != "" && string(got.Kind) != want.Kind ||
|
||||
want.Key != "" && got.Key != want.Key || want.Value != "" && got.Value != want.Value ||
|
||||
want.Source != "" && got.Source != want.Source ||
|
||||
want.Confidence != nil && got.Confidence != *want.Confidence {
|
||||
return false, nil
|
||||
}
|
||||
return w.stateTimeMatches(got.Ts, want.At)
|
||||
}
|
||||
|
||||
func (w *simWorld) stateTimeMatches(got time.Time, raw string) (bool, error) {
|
||||
if raw == "" {
|
||||
return true, nil
|
||||
}
|
||||
want, err := w.stateTime(raw)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return got.Equal(want), nil
|
||||
}
|
||||
|
||||
// stateTime accepts either an absolute RFC3339 instant or the same local
|
||||
// HH:MM[:SS] shape scenario steps use. The latter keeps fixtures readable
|
||||
// while still comparing exact instants after the store normalises to UTC.
|
||||
func (w *simWorld) stateTime(raw string) (time.Time, error) {
|
||||
if strings.Contains(raw, "T") {
|
||||
return time.Parse(time.RFC3339, raw)
|
||||
}
|
||||
layout := "15:04"
|
||||
if strings.Count(raw, ":") == 2 {
|
||||
layout = "15:04:05"
|
||||
}
|
||||
hm, err := time.Parse(layout, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("expected HH:MM[:SS] or RFC3339, got %q: %w", raw, err)
|
||||
}
|
||||
return time.Date(w.start.Year(), w.start.Month(), w.start.Day(),
|
||||
hm.Hour(), hm.Minute(), hm.Second(), 0, w.loc), nil
|
||||
}
|
||||
|
||||
func sendableTexts(sends []delivery.Sendable) []string {
|
||||
@@ -933,6 +1205,64 @@ func TestSimulatorScenarios(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSimulatorWorldMirrorsProductionConversationSeams pins the two daemon
|
||||
// constructor upgrades the continuous scenario needs. A bare store API cannot
|
||||
// answer DayPlan, and a nil handler extractor cannot complete a parked reminder
|
||||
// from the next turn; either drift would make the simulator exercise a smaller
|
||||
// system than production while still producing plausible replies.
|
||||
func TestSimulatorWorldMirrorsProductionConversationSeams(t *testing.T) {
|
||||
sc := scenario{SchemaVersion: 1, Name: "constructor-seams", Start: "2026-08-15T08:00:00+04:00"}
|
||||
w := newSimWorld(t, sc)
|
||||
if w.handler.api != w.api {
|
||||
t.Fatal("handler did not receive the simulator's upgraded daemon API")
|
||||
}
|
||||
plan, err := w.handler.api.DayPlan(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("simulator day-plan seam is unavailable: %v", err)
|
||||
}
|
||||
planY, planM, planD := plan.Date.In(w.loc).Date()
|
||||
wantY, wantM, wantD := w.start.Date()
|
||||
if plan.Date.IsZero() || planY != wantY || planM != wantM || planD != wantD {
|
||||
t.Fatalf("day plan date = %v, want the fake-clock day %v", plan.Date, w.start)
|
||||
}
|
||||
if w.handler.extractor.Time == nil || w.handler.timeParser == nil {
|
||||
t.Fatal("simulator left the clarify time parser unwired")
|
||||
}
|
||||
slots := w.handler.extractor.Extract(context.Background(), router.IntentReminder,
|
||||
"сегодня в 10:00", w.clock.Now())
|
||||
if !slots.HasTime || !slots.Time.Equal(w.timeOf("10:00")) {
|
||||
t.Fatalf("clarify extractor parsed %+v, want the fake-clock day at 10:00", slots)
|
||||
}
|
||||
}
|
||||
|
||||
// The reported duplicate transcript was a diagnostic artefact: two adjacent
|
||||
// sed ranges both included boundary line 620. Source had one log call. Keep an
|
||||
// executable exact-count assertion so a real duplicate cannot be introduced
|
||||
// later and mistaken for another display artefact.
|
||||
func TestSimulatorTranscriptRecordsEachSpokenTurnOnce(t *testing.T) {
|
||||
sc := scenario{
|
||||
SchemaVersion: 1,
|
||||
Name: "transcript-count",
|
||||
Start: "2026-08-15T08:00:00+04:00",
|
||||
Script: []scriptEntry{{
|
||||
Match: "привет", Route: `[{"intent":"chat","text":"привет"}]`,
|
||||
Reply: `{"response":"Привет.","mood":"happy"}`,
|
||||
}},
|
||||
}
|
||||
w := newSimWorld(t, sc)
|
||||
w.stimulate(context.Background(), step{Say: "привет"})
|
||||
want := "08:00:00 он: привет"
|
||||
count := 0
|
||||
for _, line := range w.transcript {
|
||||
if line == want {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("owner transcript line occurred %d times, want exactly once: %v", count, w.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func loadScenario(t *testing.T, path string) scenario {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
|
||||
Reference in New Issue
Block a user