diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 97b59ca8..4f131e70 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -43,6 +43,7 @@ import ( "github.com/livekit/protocol/utils" lksdk "github.com/livekit/server-sdk-go/v2" "github.com/livekit/server-sdk-go/v2/pkg/cloudagents" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) @@ -352,11 +353,22 @@ func scenarioGroupToYAML(group *livekit.ScenarioGroup) ([]byte, error) { if f.ID == "" { f.ID = utils.NewGuid(utils.ScenarioGroupPrefix) } + for name, node := range group.GetNodes() { + v, err := protoToYAMLValue(node) + if err != nil { + return nil, fmt.Errorf("failed to encode node %q: %w", name, err) + } + if f.Nodes == nil { + f.Nodes = map[string]any{} + } + f.Nodes[name] = v + } for _, s := range group.GetScenarios() { ys := yamlScenario{ ID: s.GetId(), Label: s.GetLabel(), Instructions: s.GetInstructions(), + Start: s.GetStart(), AgentExpectations: s.GetAgentExpectations(), Tags: s.GetTags(), } @@ -376,17 +388,20 @@ func scenarioGroupToYAML(group *livekit.ScenarioGroup) ([]byte, error) { } // scenariosFile mirrors a scenarios.yaml; `userdata` is a nested mapping here -// and JSON-encoded into the proto's string field. +// and JSON-encoded into the proto's string field. Each `nodes` entry is a +// SimulatorNode in protojson form. type scenariosFile struct { ID string `yaml:"id"` Name string `yaml:"name"` + Nodes map[string]any `yaml:"nodes,omitempty"` Scenarios []yamlScenario `yaml:"scenarios"` } type yamlScenario struct { ID string `yaml:"id"` Label string `yaml:"label"` - Instructions string `yaml:"instructions"` + Instructions string `yaml:"instructions,omitempty"` + Start string `yaml:"start,omitempty"` AgentExpectations string `yaml:"agent_expectations"` Tags map[string]string `yaml:"tags"` Userdata map[string]any `yaml:"userdata"` @@ -454,6 +469,21 @@ func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) { return nil, fmt.Errorf("scenarios file has no id") } group := &livekit.ScenarioGroup{Id: f.ID, Name: f.Name} + names := make([]string, 0, len(f.Nodes)) + for name := range f.Nodes { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + node := &livekit.SimulatorNode{} + if err := yamlValueToProto(f.Nodes[name], node); err != nil { + return nil, fmt.Errorf("node %q: %w", name, err) + } + if group.Nodes == nil { + group.Nodes = map[string]*livekit.SimulatorNode{} + } + group.Nodes[name] = node + } seen := map[string]bool{} for _, s := range f.Scenarios { if s.ID == "" { @@ -463,6 +493,12 @@ func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) { return nil, fmt.Errorf("duplicate scenario id %q", s.ID) } seen[s.ID] = true + if s.Start != "" && s.Instructions != "" { + return nil, fmt.Errorf("scenario %q: set `start` or `instructions`, not both", s.Label) + } + if s.Start != "" && group.Nodes[s.Start] == nil && !builtinSimulatorNodes[s.Start] { + return nil, fmt.Errorf("scenario %q: start %q is not a node", s.Label, s.Start) + } var userdata string if len(s.Userdata) > 0 { b, err := json.Marshal(s.Userdata) @@ -475,6 +511,7 @@ func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) { Id: s.ID, Label: s.Label, Instructions: s.Instructions, + Start: s.Start, AgentExpectations: s.AgentExpectations, Tags: s.Tags, Userdata: userdata, @@ -483,6 +520,37 @@ func loadScenarioGroup(path string) (*livekit.ScenarioGroup, error) { return group, nil } +// builtinSimulatorNodes are the nodes the server provides; a scenario may start at one. +var builtinSimulatorNodes = map[string]bool{ + "livekit/hangup": true, + "livekit/voicemail": true, + "livekit/unavailable": true, +} + +// yamlValueToProto decodes a YAML value through its JSON form with protojson, +// so field names, enum names, durations, and unknown-key errors match the +// server's parser. +func yamlValueToProto(v any, m proto.Message) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + return protojson.Unmarshal(b, m) +} + +// protoToYAMLValue is the inverse of yamlValueToProto. +func protoToYAMLValue(m proto.Message) (any, error) { + b, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(m) + if err != nil { + return nil, err + } + var v any + if err := json.Unmarshal(b, &v); err != nil { + return nil, err + } + return v, nil +} + func generateAgentName() string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789" b := make([]byte, 8) diff --git a/cmd/lk/simulate_scenario_ids_test.go b/cmd/lk/simulate_scenario_ids_test.go index 73f8b114..0d161d18 100644 --- a/cmd/lk/simulate_scenario_ids_test.go +++ b/cmd/lk/simulate_scenario_ids_test.go @@ -20,9 +20,12 @@ import ( "regexp" "strings" "testing" + "time" "github.com/livekit/protocol/livekit" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" "gopkg.in/yaml.v3" ) @@ -197,3 +200,215 @@ func TestScenarioGroupToYAMLFillsIDs(t *testing.T) { require.True(t, strings.HasPrefix(loaded.GetScenarios()[0].GetId(), "SCN_")) require.Equal(t, "fixed", loaded.GetScenarios()[1].GetId()) } + +func TestLoadScenarioGroupSimulatorFlow(t *testing.T) { + const nodes = ` +nodes: + menu: + say: Press 1 for sales. + speaker: MACHINE + routes: + - match: "1" + to: sales + timeout: + after: 8s + to: livekit/hangup + sales: + instructions: You are a sales rep. +` + cases := []struct { + name string + yaml string + want *livekit.ScenarioGroup + wantErr []string + }{ + { + name: "nodes and start", + yaml: ` +id: g1 +name: n` + nodes + ` +scenarios: + - id: s1 + label: call + start: menu +`, + want: &livekit.ScenarioGroup{ + Id: "g1", + Name: "n", + Nodes: map[string]*livekit.SimulatorNode{ + "menu": { + Say: "Press 1 for sales.", + Speaker: livekit.SimulatorNode_MACHINE, + Routes: []*livekit.SimulatorNode_Route{{Match: "1", To: "sales"}}, + Timeout: &livekit.SimulatorNode_Timeout{After: durationpb.New(8 * time.Second), To: "livekit/hangup"}, + }, + "sales": { + Instructions: "You are a sales rep.", + }, + }, + Scenarios: []*livekit.Scenario{{ + Id: "s1", + Label: "call", + Start: "menu", + }}, + }, + }, + { + name: "built-in start", + yaml: ` +id: g1 +name: n +scenarios: + - id: s1 + label: call + start: livekit/unavailable +`, + want: &livekit.ScenarioGroup{ + Id: "g1", + Name: "n", + Scenarios: []*livekit.Scenario{{Id: "s1", Label: "call", Start: "livekit/unavailable"}}, + }, + }, + { + name: "instructions only", + yaml: ` +id: g1 +name: n +scenarios: + - id: s1 + label: order + instructions: order a burger + agent_expectations: confirms the order +`, + want: &livekit.ScenarioGroup{ + Id: "g1", + Name: "n", + Scenarios: []*livekit.Scenario{{ + Id: "s1", + Label: "order", + Instructions: "order a burger", + AgentExpectations: "confirms the order", + }}, + }, + }, + { + name: "unknown node key", + yaml: ` +id: g1 +name: n +nodes: + menu: + sya: typo +scenarios: + - id: s1 + label: call + start: menu +`, + wantErr: []string{`node "menu"`, "sya"}, + }, + { + name: "start and instructions", + yaml: ` +id: g1 +name: n` + nodes + ` +scenarios: + - id: s1 + label: call + start: menu + instructions: hi +`, + wantErr: []string{`scenario "call"`, "`start` or `instructions`, not both"}, + }, + { + name: "unknown start", + yaml: ` +id: g1 +name: n` + nodes + ` +scenarios: + - id: s1 + label: call + start: nowhere +`, + wantErr: []string{`scenario "call"`, `start "nowhere"`}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + group, err := loadScenarioGroup(writeScenariosFile(t, c.yaml)) + if c.wantErr != nil { + for _, e := range c.wantErr { + require.ErrorContains(t, err, e) + } + return + } + require.NoError(t, err) + require.True(t, proto.Equal(c.want, group), "got %v", group) + }) + } +} + +func TestScenarioGroupToYAMLRoundTripsFlow(t *testing.T) { + path := writeScenariosFile(t, ` +id: g1 +name: n +nodes: + menu: + say: Press 1. + speaker: MACHINE + routes: + - match: "1" + to: livekit/hangup + timeout: + after: 1.5s + to: livekit/voicemail +scenarios: + - id: s1 + label: call + start: menu +`) + group, err := loadScenarioGroup(path) + require.NoError(t, err) + out, err := scenarioGroupToYAML(group) + require.NoError(t, err) + again, err := loadScenarioGroup(writeScenariosFile(t, string(out))) + require.NoError(t, err) + require.True(t, proto.Equal(group, again), "got %v\nyaml:\n%s", again, out) +} + +func TestInsertScenarioIDsKeepsNodes(t *testing.T) { + in := `name: n +nodes: + menu: + say: hi # greet +scenarios: + - label: call + start: menu +` + out, added, err := insertScenarioIDs([]byte(in)) + require.NoError(t, err) + require.Equal(t, 2, added) + ids := regexp.MustCompile(`(SCNG?)_\w+`) + want := `id: SCNG_x +name: n +nodes: + menu: + say: hi # greet +scenarios: + - id: SCN_x + label: call + start: menu +` + require.Equal(t, want, ids.ReplaceAllString(string(out), "${1}_x")) +} + +func TestScenarioWithoutStartOrInstructionsLoads(t *testing.T) { + group, err := loadScenarioGroup(writeScenariosFile(t, ` +id: g1 +name: n +scenarios: + - id: s1 + label: call +`)) + require.NoError(t, err) + require.Len(t, group.GetScenarios(), 1) +} diff --git a/go.mod b/go.mod index 71cfb20e..7279a333 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/joho/godotenv v1.5.1 github.com/klauspost/compress v1.20.1 - github.com/livekit/protocol v1.52.2-0.20261008051848-8c1250755780 + github.com/livekit/protocol v1.53.1-0.20261021112906-7b7ed8c98658 github.com/livekit/server-sdk-go/v2 v2.19.0 github.com/mattn/go-isatty v0.0.22 github.com/moby/moby/client v0.4.1 diff --git a/go.sum b/go.sum index 255293c6..1a5e9b33 100644 --- a/go.sum +++ b/go.sum @@ -364,8 +364,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.52.2-0.20261008051848-8c1250755780 h1:7T5JGMTDLcbI/emnTcrspygdWlVepIdHYA54um4v6Qo= -github.com/livekit/protocol v1.52.2-0.20261008051848-8c1250755780/go.mod h1:k7v95/DNhMkslMeBxdQ+lQ6gKc8hMNGYB7t0+6sEGU4= +github.com/livekit/protocol v1.53.1-0.20261021112906-7b7ed8c98658 h1:eGv4bMT1XXAF67kUJoqq3743358SbqENzvD3XxIhqtA= +github.com/livekit/protocol v1.53.1-0.20261021112906-7b7ed8c98658/go.mod h1:kUTAI2TrTngGOiWP6MTAOkJpru6YYGd0MP9wzlDATy4= github.com/livekit/psrpc v0.8.2 h1:7tbkhBJoYlK5Csfkdv1XYbZjyAQeaEZgupvIXNCv7Dc= github.com/livekit/psrpc v0.8.2/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= github.com/livekit/server-sdk-go/v2 v2.19.0 h1:fZ0zmeC/5V+rXiI/MeC05EjwTfXmpaGkr/DXLG3EzgA=