diff --git a/autocomplete/fish_autocomplete b/autocomplete/fish_autocomplete index d7df2680..2ed04d47 100644 --- a/autocomplete/fish_autocomplete +++ b/autocomplete/fish_autocomplete @@ -224,15 +224,23 @@ complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcomma complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l concurrency -r -d 'Max simulations running in parallel (default: server-side limit)' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l scenarios -r -d 'Path to a scenarios `FILE` (yaml). If omitted, scenarios are generated from the agent\'s source' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l yes -s y -d 'Skip the source-upload confirmation prompt (required for non-interactive runs that generate from source)' -complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l view -r -d 'Open a pre-existing simulation' -complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l export -r -d 'Print the run with run `ID` and its exact per-job chat contexts as JSON. Nothing is run or polled: the run must already be finished' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l agent-name -r -d 'Run against an already-running agent instead of spawning one locally. Pass the registered `NAME`, or "" to target the project\'s default agent (the one that auto-joins every room). Requires --scenarios.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l help -s h -d 'show help' -complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and not __fish_seen_subcommand_from audio' -a 'audio' -d 'Simulate speech-to-speech interactions using the agent\'s full audio pipeline' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and not __fish_seen_subcommand_from text audio list view export' -a 'text' -d 'Simulate text-only interactions' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and __fish_seen_subcommand_from text' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and not __fish_seen_subcommand_from text audio list view export' -a 'audio' -d 'Simulate speech-to-speech interactions using the agent\'s full audio pipeline' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and __fish_seen_subcommand_from audio' -f -l background-noise -d 'Mix ambient noise into the simulated user\'s audio' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and __fish_seen_subcommand_from audio' -f -l low-quality-microphone -d 'Publish the simulated user\'s audio as a low-quality microphone would capture it' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and __fish_seen_subcommand_from audio' -f -l packet-loss -d 'Drop packets from the simulated user\'s audio track' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and __fish_seen_subcommand_from audio' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and not __fish_seen_subcommand_from text audio list view export' -a 'list' -d 'List the project\'s most recent simulation runs' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and __fish_seen_subcommand_from list' -f -l limit -r -d 'maximum number of runs to return. If unset, defaults to API page size' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and __fish_seen_subcommand_from list' -f -l json -s j -d 'Output as JSON' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and __fish_seen_subcommand_from list' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and not __fish_seen_subcommand_from text audio list view export' -a 'view' -d 'Open a pre-existing simulation run' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and __fish_seen_subcommand_from view' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and not __fish_seen_subcommand_from text audio list view export' -a 'export' -d 'Print a finished run and its exact per-job chat contexts as JSON. Nothing is run or polled' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate; and __fish_seen_subcommand_from export' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy promote status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console daemon simulate help h' -a 'help' -d 'Shows a list of commands or help for one command' complete -c lk -n '__fish_seen_subcommand_from analytics' -f -l experimental -d 'Enable experimental features' complete -c lk -n '__fish_seen_subcommand_from analytics' -f -l help -s h -d 'show help' diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index bc1eac4a..25d6ec3b 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -56,13 +56,16 @@ const ( ) var simulateCommand = &cli.Command{ - Name: "simulate", - Usage: "Run agent simulations against LiveKit Cloud", - ArgsUsage: "[entrypoint]", + Name: "simulate", + Usage: "Run agent simulations against LiveKit Cloud", // Hide the implicit `help` subcommand so shell completion falls back to // native filename completion for the entrypoint arg (see startCommand). HideHelpCommand: true, Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + // Bare `simulate` only prints help; no project is needed for that. + if cmd.Args().Len() == 0 { + return nil, nil + } pc, err := loadProjectDetails(cmd) if err != nil { return nil, err @@ -71,9 +74,9 @@ var simulateCommand = &cli.Command{ return nil, nil }, Action: func(ctx context.Context, cmd *cli.Command) error { - return runSimulate(ctx, cmd, livekit.SimulationMode_SIMULATION_MODE_TEXT) + return cli.ShowSubcommandHelp(cmd) }, - Commands: []*cli.Command{simulateAudioCommand}, + Commands: []*cli.Command{simulateTextCommand, simulateAudioCommand, simulateListCommand, simulateViewCommand, simulateExportCommand}, Flags: []cli.Flag{ &cli.IntFlag{ Name: "num-simulations", @@ -93,14 +96,6 @@ var simulateCommand = &cli.Command{ Aliases: []string{"y"}, Usage: "Skip the source-upload confirmation prompt (required for non-interactive runs that generate from source)", }, - &cli.StringFlag{ - Name: "view", - Usage: "Open a pre-existing simulation", - }, - &cli.StringFlag{ - Name: "export", - Usage: "Print the run with run `ID` and its exact per-job chat contexts as JSON. Nothing is run or polled: the run must already be finished", - }, &cli.StringFlag{ Name: "agent-name", Usage: "Run against an already-running agent instead of spawning one locally. Pass the registered `NAME`, or \"\" to target the project's default agent (the one that auto-joins every room). Requires --scenarios.", @@ -108,6 +103,17 @@ var simulateCommand = &cli.Command{ }, } +var simulateTextCommand = &cli.Command{ + Name: "text", + Usage: "Simulate text-only interactions", + Description: "Options on lk agent simulate apply here too, e.g. --scenarios and --agent-name.", + ArgsUsage: "[entrypoint]", + HideHelpCommand: true, + Action: func(ctx context.Context, cmd *cli.Command) error { + return runSimulate(ctx, cmd, livekit.SimulationMode_SIMULATION_MODE_TEXT) + }, +} + // simulateAudioCommand inherits every flag on `simulate`: flags there are // persistent (cli.FlagBase.Local defaults to false), so they parse on either // side of the subcommand name. @@ -136,6 +142,60 @@ var simulateAudioCommand = &cli.Command{ }, } +// Run flags on `simulate` are persistent, so these subcommands parse them too; +// they take a run ID instead and ignore the rest. +var simulateViewCommand = &cli.Command{ + Name: "view", + Usage: "Open a pre-existing simulation run", + ArgsUsage: "", + HideHelpCommand: true, + Action: runSimulateView, +} + +var simulateExportCommand = &cli.Command{ + Name: "export", + Usage: "Print a finished run and its exact per-job chat contexts as JSON. Nothing is run or polled", + ArgsUsage: "", + HideHelpCommand: true, + Action: func(ctx context.Context, cmd *cli.Command) error { + runID, err := simulateRunIDArg(cmd) + if err != nil { + return err + } + return exportSimulationRunJSON(ctx, simulateProjectConfig, runID) + }, +} + +func simulateRunIDArg(cmd *cli.Command) (string, error) { + runID := cmd.Args().First() + if runID == "" { + return "", fmt.Errorf("%s requires a run ID", cmd.Name) + } + return runID, nil +} + +// runSimulateView opens a pre-existing run: nothing is spawned, so no agent +// project, entrypoint or scenarios are needed. +func runSimulateView(ctx context.Context, cmd *cli.Command) error { + runID, err := simulateRunIDArg(cmd) + if err != nil { + return err + } + pc := simulateProjectConfig + simCfg := &simulateConfig{ + ctx: ctx, + client: lksdk.NewAgentSimulationClient(serverURL, pc.APIKey, pc.APISecret), + pc: pc, + mode: modeView, + simulationMode: livekit.SimulationMode_SIMULATION_MODE_TEXT, + viewModeRunID: runID, + } + if !isInteractive() { + return runSimulateCI(ctx, simCfg) + } + return runSimulateTUI(simCfg) +} + // writeGeneratedScenariosTemp writes a generated run's scenarios to a temp // scenarios.yaml; "" when the run carries none. func writeGeneratedScenariosTemp(run *livekit.SimulationRun) (string, error) { @@ -213,7 +273,7 @@ type simulateConfig struct { entrypoint string scenarioGroup *livekit.ScenarioGroup scenariosPath string // path to the --scenarios file (empty when generating from source) - viewModeRunID string // non-empty when --view opens a pre-existing run + viewModeRunID string // non-empty when `view` opens a pre-existing run liveAgent bool // --agent-name: run against an already-running agent, don't spawn one warnings []string // config-level warnings surfaced at setup (e.g. ignored flags) @@ -309,19 +369,8 @@ func buildTaskExists(projectDir string) (bool, error) { func runSimulate(ctx context.Context, cmd *cli.Command, simulationMode livekit.SimulationMode) error { pc := simulateProjectConfig - // --export is a one-shot read of a finished run, so it short-circuits - // every other flag: no agent, no run creation, no polling. - if cmd.IsSet("export") { - exportRunID := cmd.String("export") - if exportRunID == "" { - return fmt.Errorf("--export requires a run ID") - } - return exportSimulationRunJSON(ctx, pc, exportRunID) - } - numSimulations := int32(cmd.Int("num-simulations")) concurrency := int32(cmd.Int("concurrency")) - runID := cmd.String("view") liveAgentName := cmd.String("agent-name") // never auto-discovered: an explicit --scenarios file is the source of @@ -346,9 +395,6 @@ func runSimulate(ctx context.Context, cmd *cli.Command, simulationMode livekit.S } liveAgent = true agentName = liveAgentName - } else if runID != "" { - // --view opens a pre-existing run: nothing is spawned, so no agent - // project or entrypoint is needed. } else { agentName = generateAgentName() projectDir, projectType, err = agentfs.DetectProjectRoot(".") @@ -383,14 +429,9 @@ func runSimulate(ctx context.Context, cmd *cli.Command, simulationMode livekit.S } } - var mode simulateMode - switch { - case runID != "": - mode = modeView - case scenarioGroup != nil && len(scenarioGroup.Scenarios) > 0: + mode := modeGenerateFromSource + if scenarioGroup != nil && len(scenarioGroup.Scenarios) > 0 { mode = modeScenarios - default: - mode = modeGenerateFromSource } if mode == modeGenerateFromSource { @@ -415,7 +456,6 @@ func runSimulate(ctx context.Context, cmd *cli.Command, simulationMode livekit.S entrypoint: entrypoint, scenarioGroup: scenarioGroup, scenariosPath: scenariosPath, - viewModeRunID: runID, liveAgent: liveAgent, warnings: simulateConfigWarnings(mode, numSimulations), } @@ -688,12 +728,12 @@ func dashboardBaseURL() string { // --project would resolve those. // The binary name comes from argv[0] so a renamed or path-qualified lk is // reproduced verbatim. -func simulateCommandHint(flag, runID string) string { +func simulateCommandHint(subcommand, runID string) string { binary := "lk" if len(os.Args) > 0 && os.Args[0] != "" { binary = os.Args[0] } - hint := binary + " agent simulate " + flag + " " + runID + hint := binary + " agent simulate " + subcommand + " " + runID if simulateProjectConfig != nil && simulateProjectConfig.Name != "" { hint += " --project " + simulateProjectConfig.Name } @@ -704,11 +744,11 @@ func simulateCommandHint(flag, runID string) string { } func viewCommandHint(runID string) string { - return simulateCommandHint("--view", runID) + return simulateCommandHint("view", runID) } func exportCommandHint(runID string) string { - return simulateCommandHint("--export", runID) + " > " + runID + ".json" + return simulateCommandHint("export", runID) + " > " + runID + ".json" } // In view mode the re-open hint would echo the command the user just ran, so diff --git a/cmd/lk/simulate_list.go b/cmd/lk/simulate_list.go new file mode 100644 index 00000000..0e3e6123 --- /dev/null +++ b/cmd/lk/simulate_list.go @@ -0,0 +1,97 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/urfave/cli/v3" + + "github.com/livekit/livekit-cli/v2/pkg/util" + "github.com/livekit/protocol/livekit" + lksdk "github.com/livekit/server-sdk-go/v2" +) + +var simulateListCommand = &cli.Command{ + Name: "list", + Usage: "List the project's most recent simulation runs", + HideHelpCommand: true, + Action: listSimulationRuns, + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "limit", + Usage: "maximum number of runs to return. If unset, defaults to API page size", + }, + jsonFlag, + }, +} + +// listSimulationRuns prints runs newest first, without jobs. Pass/fail detail +// lives in `view`. +func listSimulationRuns(ctx context.Context, cmd *cli.Command) error { + pc := simulateProjectConfig + client := lksdk.NewAgentSimulationClient(serverURL, pc.APIKey, pc.APISecret) + + limit := cmd.Int("limit") + var runs []*livekit.SimulationRun + var resp *livekit.SimulationRun_List_Response + for resp == nil || (len(runs) < limit && resp.NextPageToken.GetToken() != "") { + req := &livekit.SimulationRun_List_Request{ProjectId: pc.ProjectId} + if resp != nil { + req.PageToken = &livekit.TokenPagination{Token: resp.NextPageToken.GetToken()} + } + pageCtx, cancel := context.WithTimeout(ctx, simulationAPITimeout) + var err error + resp, err = client.ListSimulationRuns(pageCtx, req) + cancel() + if err != nil { + return fmt.Errorf("unable to list simulation runs: %w", err) + } + runs = append(runs, resp.Runs...) + } + if limit > 0 && len(runs) > limit { + runs = runs[:limit] + } + + if cmd.Bool("json") { + util.PrintJSON(&livekit.SimulationRun_List_Response{Runs: runs}) + return nil + } + + if len(runs) == 0 { + out.Status("No simulation runs found") + return nil + } + + var rows [][]string + for _, run := range runs { + rows = append(rows, []string{ + run.GetId(), + formatDeployedAt(run.GetCreatedAt().AsTime()), + strings.TrimPrefix(run.GetStatus().String(), "STATUS_"), + strings.TrimPrefix(run.GetMode().String(), "SIMULATION_MODE_"), + run.GetAgentName(), + }) + } + + t := util.CreateTable(). + Headers("ID", "Created At", "Status", "Mode", "Agent"). + Rows(rows...) + out.Result(t) + fmt.Fprintf(out.StatusWriter(), "To open a run: %s\n", viewCommandHint("")) + return nil +}