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.
This commit is contained in:
2026-08-06 00:36:33 +04:00
parent 39284cd851
commit 6759ff6003
2 changed files with 218 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
package dialogue
import "time"
// Capability names the thing being assembled across a clarify exchange —
// "reminder.create", not "reminder". A router intent says what she heard; a
// capability says what she is about to do, and those are not the same word:
// three intents currently reach exactly one capability each, but a fact key
// that turns out to be a Hexis target does not. Named in the ecosystem's
// dotted form because that is what a confirmation binds (cmd/mavend/confirm.go)
// and what Hexis registers.
//
// This package must stay free of internal/router (the cycle rule that makes
// Slots a hand-kept copy), so the mapping from an intent lives here and reads
// off dialogue.Intent only.
type Capability string
const (
CapReminderCreate Capability = "reminder.create"
CapFactWrite Capability = "fact.write"
CapNoteWrite Capability = "note.write"
CapActRun Capability = "act.run"
CapQueryAnswer Capability = "query.answer"
CapChatReply Capability = "chat.reply"
CapSystemControl Capability = "system.control"
)
// intentCapability — the one place an intent becomes a capability. Every intent
// is listed, including the four that are never worth a clarifying question, so a
// parked action always knows what it is even when nothing asks it.
var intentCapability = map[Intent]Capability{
IntentReminder: CapReminderCreate,
IntentFact: CapFactWrite,
IntentNote: CapNoteWrite,
IntentAct: CapActRun,
IntentQuery: CapQueryAnswer,
IntentChat: CapChatReply,
IntentSystem: CapSystemControl,
}
// CapabilityFor maps a router intent (already narrowed to dialogue.Intent by
// the caller) to the capability being assembled. "" for an intent she does not
// recognise — an unknown intent must not silently become a real capability.
func CapabilityFor(in Intent) Capability {
return intentCapability[in]
}
// PendingAction is the action Maven is assembling, as an object rather than as
// conversational history: which capability, the slots it already has, the slots
// it is still missing, when she asked, how many questions that has cost and how
// long the answer stays welcome.
//
// It exists because the resolver used to have to infer all of that from a
// parked question plus the previous turn (Vikunja #558): "is this his answer or
// a new request" is answerable against an object and guessy against a
// transcript. PendingQuestion carries one of these and keeps its own flat
// fields, so this is a widening — nothing reads the capability yet.
type PendingAction struct {
Capability Capability
Slots Slots // what is filled so far
Missing []Slot // what she is waiting for, in the order to ask about
Utterance string // his original raw words, as the action's provenance
Asked time.Time
TTL time.Duration
Attempts int // questions already asked about this action
// MaxAttempts caps Attempts. 0 ⇒ DefaultMaxAttempts.
MaxAttempts int
}
// maxAttempts is MaxAttempts with the default filled in.
func (a *PendingAction) maxAttempts() int {
if a.MaxAttempts <= 0 {
return DefaultMaxAttempts
}
return a.MaxAttempts
}
// IsExpired — the answer came too late for this action to still be his answer.
func (a *PendingAction) IsExpired(now time.Time) bool {
return now.After(a.Asked.Add(a.TTL))
}
// CanAsk reports whether she may ask another question about this action.
func (a *PendingAction) CanAsk() bool {
return a.Attempts < a.maxAttempts()
}
// Gaps lists the slots this action asked for and still does not have. Computed
// from the slots rather than trusted from Missing, because Missing is what she
// asked about and the slots are what she got — an answer can fill a gap she
// never asked about, and a re-park must not ask again for something now filled.
func (a *PendingAction) Gaps() []Slot {
return StillMissing(a.Missing, a.Slots)
}
// Complete reports whether every slot this action was waiting for is filled, so
// it can run. Note that this is completeness against what she ASKED, not
// against the capability's whole schema — validating that is V-562.
func (a *PendingAction) Complete() bool {
return len(a.Gaps()) == 0
}
+117
View File
@@ -0,0 +1,117 @@
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")
}
}