diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 37e52cbe..ee33e6fe 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -72,7 +72,7 @@ var simulateCommand = &cli.Command{ }, Commands: []*cli.Command{ simulateTextCommand, simulateAudioCommand, simulateListCommand, simulateViewCommand, simulateExportCommand, - simulateGetCommand, simulateCancelCommand, + simulateGetCommand, simulateCancelCommand, simulateScenarioCommand, }, Flags: []cli.Flag{ &cli.IntFlag{ @@ -234,6 +234,81 @@ var ( } ) +var simulateScenarioCommand = &cli.Command{ + Name: "scenario", + Usage: "Work with simulation scenarios", + HideHelpCommand: true, + Action: func(ctx context.Context, cmd *cli.Command) error { + return cli.ShowSubcommandHelp(cmd) + }, + Commands: []*cli.Command{ + { + Name: "generate", + Usage: "Generate a scenario from a recorded session and print it as a scenarios file", + UsageText: "lk agent simulate scenario generate --from-session SESSION_ID > scenarios.yaml", + Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + if experimentalAuthEnabled(cmd) { + return nil, nil + } + return loadSimulateProject(ctx, cmd) + }, + Action: generateScenarioCommand, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "from-session", + Usage: "Session `ID` (RM_...) to derive the scenario from", + Required: true, + }, + jsonFlag, + }, + }, + }, +} + +func generateScenarioCommand(ctx context.Context, cmd *cli.Command) error { + sessionID := cmd.String("from-session") + var scenario *livekit.Scenario + if experimentalAuthEnabled(cmd) { + client, projectID, err := simulationProjectID(ctx, cmd) + if err != nil { + return err + } + s, err := client.GenerateScenarioFromSession(ctx, projectID, sessionID) + if err != nil { + return cloudAPIError(err) + } + scenario = &livekit.Scenario{ + Id: util.Deref(s.Id), + Label: util.Deref(s.Label), + Instructions: util.Deref(s.Instructions), + AgentExpectations: util.Deref(s.AgentExpectations), + Tags: util.Deref(s.Tags), + Userdata: util.Deref(s.Userdata), + } + } else { + pc := simulateProjectConfig + client := lksdk.NewAgentSimulationClient(serverURL, pc.APIKey, pc.APISecret) + resp, err := client.CreateScenarioFromSession(ctx, &livekit.Scenario_CreateFromSession_Request{ + ProjectId: pc.ProjectId, + RoomId: sessionID, + }) + if err != nil { + return fmt.Errorf("unable to generate scenario: %w", err) + } + scenario = resp.GetScenario() + } + if cmd.Bool("json") { + util.PrintJSON(scenario) + return nil + } + doc, err := scenarioGroupToYAML(&livekit.ScenarioGroup{Scenarios: []*livekit.Scenario{scenario}}) + if err != nil { + return err + } + _, err = os.Stdout.Write(doc) + return err +} + func simulationProjectID(ctx context.Context, cmd *cli.Command) (*public.Client, string, error) { client, conf, user, err := requireCloudClient(cmd) if err != nil { @@ -384,7 +459,7 @@ func scenarioGroupToYAML(group *livekit.ScenarioGroup) ([]byte, error) { // and JSON-encoded into the proto's string field. type scenariosFile struct { ID string `yaml:"id"` - Name string `yaml:"name"` + Name string `yaml:"name,omitempty"` Scenarios []yamlScenario `yaml:"scenarios"` } @@ -393,8 +468,8 @@ type yamlScenario struct { Label string `yaml:"label"` Instructions string `yaml:"instructions"` AgentExpectations string `yaml:"agent_expectations"` - Tags map[string]string `yaml:"tags"` - Userdata map[string]any `yaml:"userdata"` + Tags map[string]string `yaml:"tags,omitempty"` + Userdata map[string]any `yaml:"userdata,omitempty"` } type simulateConfig struct { diff --git a/cmd/lk/simulate_scenario_ids_test.go b/cmd/lk/simulate_scenario_ids_test.go index 73f8b114..3b986d7f 100644 --- a/cmd/lk/simulate_scenario_ids_test.go +++ b/cmd/lk/simulate_scenario_ids_test.go @@ -197,3 +197,13 @@ func TestScenarioGroupToYAMLFillsIDs(t *testing.T) { require.True(t, strings.HasPrefix(loaded.GetScenarios()[0].GetId(), "SCN_")) require.Equal(t, "fixed", loaded.GetScenarios()[1].GetId()) } + +func TestScenarioGroupToYAMLOmitsEmptyFields(t *testing.T) { + out, err := scenarioGroupToYAML(&livekit.ScenarioGroup{ + Scenarios: []*livekit.Scenario{{Label: "a", Instructions: "x"}}, + }) + require.NoError(t, err) + require.NotContains(t, string(out), "name:") + require.NotContains(t, string(out), "tags:") + require.NotContains(t, string(out), "userdata:") +} diff --git a/pkg/public/simulations.go b/pkg/public/simulations.go index 504a963e..2f7a28eb 100644 --- a/pkg/public/simulations.go +++ b/pkg/public/simulations.go @@ -143,3 +143,18 @@ func (c *Client) CancelSimulationRun(ctx context.Context, projectID, runID strin } return okOrError(resp.StatusCode(), resp.Body) } + +// GenerateScenarioFromSession derives a scenario from a recorded session. The +// scenario is not stored server-side. +func (c *Client) GenerateScenarioFromSession(ctx context.Context, projectID, sessionID string) (*oapi.LivekitScenario, error) { + resp, err := c.gen.SimulationServiceGenerateScenarioFromSessionWithResponse(ctx, projectID, oapi.SimulationServiceGenerateScenarioFromSessionJSONRequestBody{ + RoomId: ptr(sessionID), + }) + if err != nil { + return nil, err + } + if resp.JSON200 == nil { + return nil, responseError(resp.StatusCode(), resp.Body) + } + return requirePayload(resp.JSON200.Scenario, "scenario") +}