fix: the slots converter was still dropping the fact Value (V-365)

dialogue.Slots gained Value in 925ce22, but toDialogueSlots never copied
it, so a clarifying answer carrying a fact payload still landed nowhere:
clarify.go:202 sends the answer through the converter, and the SlotValue
arm reads answer.Value.

Both converters now carry every field. TestSlotsParity compares the two
field sets by name and type; TestSlotsRoundTrip populates every router
field and checks the round trip, and fails the fixture itself when a new
field is left zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QChoBS5qJSrCV98oNUnHNU
This commit is contained in:
2026-08-02 09:35:47 +04:00
parent e332f167b2
commit 5e66aa8f22
2 changed files with 83 additions and 5 deletions
+12 -5
View File
@@ -12,13 +12,21 @@ import (
// which waits on voice-print attribution (see PROGRESS multi-user deferral).
const voiceDialogueID = "voice"
// toDialogueSlots projects the router's slots onto the dialogue layer's subset
// (everything except the fact Value, which the dialogue layer doesn't carry).
// toDialogueSlots and applyDialogueSlots are the only bridge between
// router.Slots and dialogue.Slots. dialogue must not import router (import
// cycle), so the two structs are hand-kept copies and every field has to be
// carried by hand here. Adding a field to either struct without adding it to
// BOTH functions loses a slot silently — nothing fails to build. The tests in
// slotsparity_test.go fail when the field sets or the converters stop matching;
// when they do, fix these two functions, not the tests.
// toDialogueSlots projects the router's slots onto the dialogue layer's copy.
func toDialogueSlots(s router.Slots) dialogue.Slots {
return dialogue.Slots{
Time: s.Time,
HasTime: s.HasTime,
Key: s.Key,
Value: s.Value,
HasKey: s.HasKey,
Text: s.Text,
Fn: s.Fn,
@@ -27,11 +35,10 @@ func toDialogueSlots(s router.Slots) dialogue.Slots {
}
}
// applyDialogueSlots writes inherited dialogue slots back onto router slots,
// preserving router-only fields (Value) the dialogue layer never touched.
// applyDialogueSlots writes dialogue slots back onto router slots.
func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots {
base.Time, base.HasTime = d.Time, d.HasTime
base.Key, base.HasKey = d.Key, d.HasKey
base.Key, base.Value, base.HasKey = d.Key, d.Value, d.HasKey
base.Text = d.Text
base.Fn, base.Args, base.HasFn = d.Fn, d.Args, d.HasFn
return base
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"reflect"
"testing"
"time"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/router"
)
// TestSlotsParity — dialogue.Slots is a hand-kept copy of router.Slots
// (dialogue must not import router: import cycle). Drift is silent, so this
// test compares the two field sets by name and type. If it fails, add the new
// field to both structs AND to toDialogueSlots/applyDialogueSlots in
// followup.go — do not relax the test.
func TestSlotsParity(t *testing.T) {
fields := func(v any) map[string]string {
rt := reflect.TypeOf(v)
out := make(map[string]string, rt.NumField())
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
out[f.Name] = f.Type.String()
}
return out
}
rf, df := fields(router.Slots{}), fields(dialogue.Slots{})
for name, typ := range rf {
dt, ok := df[name]
if !ok {
t.Errorf("router.Slots.%s (%s) missing from dialogue.Slots", name, typ)
continue
}
if dt != typ {
t.Errorf("field %s: router has %s, dialogue has %s", name, typ, dt)
}
}
for name, typ := range df {
if _, ok := rf[name]; !ok {
t.Errorf("dialogue.Slots.%s (%s) missing from router.Slots", name, typ)
}
}
}
// TestSlotsRoundTrip — the converters carry every field. A field the parity
// test accepts can still be dropped in transit, so round-trip a fully
// populated value and compare.
func TestSlotsRoundTrip(t *testing.T) {
full := router.Slots{
Time: time.Date(2026, 8, 2, 11, 0, 0, 0, time.UTC),
HasTime: true,
Fn: "restart",
Args: []string{"nginx"},
HasFn: true,
Key: "water",
Value: `"drank"`,
HasKey: true,
Text: "выпил воды",
}
// Every field must be non-zero, or the round-trip proves nothing.
rv := reflect.ValueOf(full)
for i := 0; i < rv.NumField(); i++ {
if rv.Field(i).IsZero() {
t.Fatalf("field %s is zero: extend this fixture so the round-trip covers it",
rv.Type().Field(i).Name)
}
}
if got := applyDialogueSlots(router.Slots{}, toDialogueSlots(full)); !reflect.DeepEqual(got, full) {
t.Errorf("round-trip lost a slot:\n got %+v\nwant %+v", got, full)
}
}