diff --git a/cmd/mavend/dialogue_contract_test.go b/cmd/mavend/dialogue_contract_test.go new file mode 100644 index 0000000..1eac958 --- /dev/null +++ b/cmd/mavend/dialogue_contract_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "strings" + "time" + + "github.com/kami/maven/internal/dialogue" +) + +// Dialogue contract tests (V-563, child of V-558). +// +// Every other clarify test is single-shot: one ask, one answer, one assertion. +// Three bugs of the same family shipped in two days that way — V-554 (a parked +// question ate the three turns after it), V-557 (a confidently routed but +// incomplete reminder parked nothing, so the answer was web-searched) and the +// Rome case in V-558 (a side question was eaten as the time answer). None of +// them is visible in one turn. The dialogue path is a state machine, so it can +// be enumerated instead: whole traces, each with a per-turn expectation and an +// expected END state — what was written to the store, and what is still parked. +// +// Two rules for the rows below. +// +// Where today's behaviour is correct, it is asserted. Where it is WRONG, the row +// carries the CORRECT expectation and is skipped with the Vikunja id that will +// unskip it. A weakened expectation would be worse than no row: it would pin the +// bug as the contract. +// +// Everything runs on the offline floor — hash embedder, no llama-server, no +// ONNX, StubDateTimeParser. That has one consequence worth knowing before +// reading a fire time here: the stub reads "в 11:00" and "через час" and does +// not read "на 9" or "на завтра", so a trace that needs those is noted where it +// sits. + +// claim — which claimant consumed an utterance. Not asserted: it is derived from +// the log lines the daemon already emits and printed on every failure, because +// "the reply differed" does not distinguish a wrong claimant from wrong copy, +// and that distinction is the whole point of V-558. +type claim struct { + utterance string + steps []string +} + +func (c claim) String() string { return c.utterance + " ⇒ " + strings.Join(c.steps, " → ") } + +// claimMarkers — log fragment to claimant name, in the order runTurn checks +// them. The fragments are the daemon's own words (clarify.go, repair.go, +// voice.go); a rename there shows up here as an "unclaimed" step rather than a +// silent mislabel. +var claimMarkers = []struct{ fragment, name string }{ + {"parked question expired", "clarify:expired"}, + {"is its own request", "clarify:stepped-aside"}, + {"gave up on", "clarify:gave-up"}, + {"did not fill", "clarify:re-ask"}, + {"one gap filled", "clarify:ask-second-gap"}, + {"asked about", "clarify:ask"}, + {"repair —", "repair"}, + {"route result: intent=", "route"}, +} + +// claimsOf reads the turn's log output and names the claimants that touched it. +func claimsOf(utterance, logged string) claim { + c := claim{utterance: utterance} + for _, line := range strings.Split(logged, "\n") { + for _, m := range claimMarkers { + if strings.Contains(line, m.fragment) { + name := m.name + if m.name == "route" { + name = "route:" + intentInLine(line) + } + c.steps = append(c.steps, name) + break + } + } + } + if len(c.steps) == 0 { + c.steps = []string{"unclaimed"} + } + return c +} + +func intentInLine(line string) string { + _, rest, ok := strings.Cut(line, "intent=") + if !ok { + return "?" + } + intent, _, _ := strings.Cut(rest, " ") + return intent +} + +// parkedWant — the question that must be armed after a turn. Attempt matters: +// a claimant that spends a retry on an utterance that was never an answer is +// exactly the V-554 shape, and the count is the only place it shows. +type parkedWant struct { + slot dialogue.Slot + attempt int + // carries — a substring the parked utterance must still hold, so a re-park + // that lost the answered subject fails here rather than three turns later. + carries string +} + +// turn — one utterance and everything that must be true right after it. +type turn struct { + say string + // wait — the clock moves this far BEFORE the utterance. The only way to + // reach the TTL without sleeping. + wait time.Duration + // question — the reply must be exactly this clarify question, worded for + // this attempt. Zero slot ⇒ not checked. + question dialogue.Slot + attempt int + contains []string + notContain []string + // noQuestion — the reply must not be any clarify question. Used where the + // correct behaviour is known but her wording for it is not written yet: a + // cancel must not be answered with another question, whatever it does say. + noQuestion bool + expired bool // the reply must open with the TTL notice + // parked — what is armed after the turn. nil ⇒ nothing may be armed. + parked *parkedWant +} + +// endState — what the store holds once the trace is over. Counts and +// substrings, not rows: a trace is about who claimed what, and a payload +// substring is enough to catch a request landing under the wrong words. +type endState struct { + reminders []reminderWant + factKeys []string + notes int + tasks []string +} + +type reminderWant struct { + payload string // substring of the stored payload + fireAt string // "2006-01-02 15:04" in UTC, "" ⇒ not checked +} + +// trace — a named conversation, its turns, and the end state. +type trace struct { + name string + skip string // non-empty ⇒ t.Skip: today's behaviour is wrong, this names the fix + turns []turn + end endState +}