Files
Maven/internal/dialogue/pending_test.go
T
claude 6759ff6003 dialogue: a typed pending action behind the parked question (V-559)
A parked clarify said what she heard (an intent) and not what she was
about to do, so the resolver had to infer the action from conversational
history instead of reading it off an object. PendingAction names the
capability being assembled in the ecosystem's dotted form
(reminder.create, fact.write, act.run), the slots it has, the slots it
still wants, when it was asked, attempts and TTL.

Gaps() computes the missing slots from the slots rather than trusting
Missing, because Missing is what she asked and the slots are what she
got. CapabilityFor maps every dialogue.Intent, so the mapping lives here
and dialogue still does not import router (the cycle rule).

Nothing reads it yet: this is the widening V-560 to V-562 build on.
2026-08-06 00:36:33 +04:00

118 lines
3.3 KiB
Go

package dialogue
import (
"testing"
"time"
)
var pendingBase = time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC)
func TestCapabilityForCoversEveryIntent(t *testing.T) {
for _, in := range []Intent{
IntentAct, IntentReminder, IntentFact, IntentNote,
IntentQuery, IntentChat, IntentSystem,
} {
if CapabilityFor(in) == "" {
t.Errorf("intent %q maps to no capability", in)
}
}
if got := CapabilityFor(Intent("nonsense")); got != "" {
t.Errorf("unknown intent became capability %q, want empty", got)
}
}
// A parked question must read as the action it is assembling, without the
// caller having to name the capability twice.
func TestPendingQuestionActionDerivesCapability(t *testing.T) {
q := &PendingQuestion{
Intent: IntentReminder,
Slots: Slots{Text: "позвонить маме"},
Missing: []Slot{SlotTime},
Utterance: "напомни позвонить маме",
Asked: pendingBase,
TTL: time.Minute,
Attempts: 1,
MaxAttempts: 2,
}
a := q.Action()
if a.Capability != CapReminderCreate {
t.Errorf("capability = %q, want %q", a.Capability, CapReminderCreate)
}
if a.Utterance != q.Utterance || a.Attempts != 1 || a.MaxAttempts != 2 || !a.Asked.Equal(pendingBase) {
t.Errorf("action did not carry the question's fields: %+v", a)
}
}
func TestPendingActionGaps(t *testing.T) {
for _, tc := range []struct {
name string
action PendingAction
want []Slot
complete bool
}{
{
name: "time still missing",
action: PendingAction{Missing: []Slot{SlotTime}, Slots: Slots{Text: "позвонить маме"}},
want: []Slot{SlotTime},
complete: false,
},
{
name: "asked slot now filled",
action: PendingAction{Missing: []Slot{SlotTime}, Slots: Slots{Time: pendingBase, HasTime: true}},
want: nil,
complete: true,
},
{
name: "two gaps reported in ask order",
action: PendingAction{Missing: []Slot{SlotText, SlotTime}},
want: []Slot{SlotText, SlotTime},
complete: false,
},
{
name: "nothing asked is complete",
action: PendingAction{},
want: nil,
complete: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := tc.action.Gaps()
if len(got) != len(tc.want) {
t.Fatalf("gaps = %v, want %v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("gaps = %v, want %v", got, tc.want)
}
}
if tc.action.Complete() != tc.complete {
t.Errorf("Complete() = %v, want %v", tc.action.Complete(), tc.complete)
}
})
}
}
// The typed action must answer the TTL and attempt-cap questions the same way
// the parked question always did — this is a widening, not new behaviour.
func TestPendingActionTTLAndAttempts(t *testing.T) {
a := PendingAction{Asked: pendingBase, TTL: time.Minute}
if a.IsExpired(pendingBase.Add(30 * time.Second)) {
t.Error("expired inside the TTL")
}
if !a.IsExpired(pendingBase.Add(2 * time.Minute)) {
t.Error("not expired past the TTL")
}
a.Attempts = DefaultMaxAttempts - 1
if !a.CanAsk() {
t.Error("cannot ask with an attempt left")
}
a.Attempts = DefaultMaxAttempts
if a.CanAsk() {
t.Error("asked past the default cap")
}
a = PendingAction{Asked: pendingBase, TTL: time.Minute, MaxAttempts: 1, Attempts: 1}
if a.CanAsk() {
t.Error("asked past an explicit cap of 1")
}
}