diff --git a/autocomplete/fish_autocomplete b/autocomplete/fish_autocomplete index 474dbb57..70bf3c87 100644 --- a/autocomplete/fish_autocomplete +++ b/autocomplete/fish_autocomplete @@ -226,6 +226,7 @@ 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 audio -d 'Simulate speech-to-speech interactions using the agent\'s full audio pipeline. By default, simulations run in text-only mode.' 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 run-json -d 'Print the completed run and exact per-job chat contexts as JSON' 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 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' diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 5da0a61b..f1865bc3 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -98,6 +98,10 @@ var simulateCommand = &cli.Command{ Name: "view", Usage: "Open a pre-existing simulation", }, + &cli.BoolFlag{ + Name: "run-json", + Usage: "Print the completed run and exact per-job chat contexts as JSON", + }, &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.", @@ -183,6 +187,7 @@ type simulateConfig struct { 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 + runJSON bool // emit a machine-readable run/transcript artifact on stdout 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) // TODO (steveyoon): add agent deployment support @@ -374,11 +379,12 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error { scenarioGroup: scenarioGroup, scenariosPath: scenariosPath, viewModeRunID: runID, + runJSON: cmd.Bool("run-json"), liveAgent: liveAgent, warnings: simulateConfigWarnings(mode, numSimulations), } - if !isInteractive() { + if simCfg.runJSON || !isInteractive() { return runSimulateCI(ctx, simCfg) } return runSimulateTUI(simCfg) @@ -592,6 +598,19 @@ func viewCommandHint(runID string) string { return hint } +func runJSONCommandHint(runID string) string { + return viewCommandHint(runID) + " --run-json > " + runID + ".json" +} + +// In view mode the re-open hint would echo the command the user just ran, so +// only the export hint is worth printing. +func writeSimulationRunHints(w io.Writer, runID string, viewing bool) { + if !viewing { + fmt.Fprintf(w, "To re-open this simulation, run: %s\n", viewCommandHint(runID)) + } + fmt.Fprintf(w, "To export replay JSON, run: %s\n", runJSONCommandHint(runID)) +} + func simulationDashboardURL(projectID, runID string) string { if projectID == "" || runID == "" { return "" diff --git a/cmd/lk/simulate_ci.go b/cmd/lk/simulate_ci.go index fbbee3c8..335cceb7 100644 --- a/cmd/lk/simulate_ci.go +++ b/cmd/lk/simulate_ci.go @@ -71,7 +71,11 @@ func runSimulateCI(ctx context.Context, config *simulateConfig) error { // --- Setup --- - report := newSimLog(out.ResultWriter(), out.StatusWriter()) + reportOut := out.ResultWriter() + if config.runJSON { + reportOut = io.Discard + } + report := newSimLog(reportOut, out.StatusWriter()) report.BeginSetup() for _, w := range config.warnings { @@ -195,7 +199,11 @@ func runSimulateCI(ctx context.Context, config *simulateConfig) error { // --- Results --- - if !out.Interactive() { + if config.runJSON { + if err := writeSimulationRunJSON(out.ResultWriter(), run); err != nil { + return err + } + } else if !out.Interactive() { report.Results(run, agent) } else { // A terminal is watching; we just couldn't open the TUI (e.g. stdin @@ -244,7 +252,11 @@ func runSimulateCIView(ctx context.Context, config *simulateConfig) error { ctx, stop := signal.NotifyContext(ctx, os.Interrupt) defer stop() - report := newSimLog(out.ResultWriter(), out.StatusWriter()) + reportOut := out.ResultWriter() + if config.runJSON { + reportOut = io.Discard + } + report := newSimLog(reportOut, out.StatusWriter()) runID := config.viewModeRunID ticker := time.NewTicker(simulationPollInterval) @@ -275,7 +287,13 @@ func runSimulateCIView(ctx context.Context, config *simulateConfig) error { } } - report.Results(run, nil) + if config.runJSON { + if err := writeSimulationRunJSON(out.ResultWriter(), run); err != nil { + return err + } + } else { + report.Results(run, nil) + } if url := simulationDashboardURL(config.pc.ProjectId, runID); url != "" { out.Statusf("Dashboard: %s", url) diff --git a/cmd/lk/simulate_json.go b/cmd/lk/simulate_json.go new file mode 100644 index 00000000..e2a8ef7d --- /dev/null +++ b/cmd/lk/simulate_json.go @@ -0,0 +1,71 @@ +// 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 ( + "encoding/json" + "fmt" + "io" + + "github.com/livekit/protocol/livekit" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +const simulationRunJSONVersion = 1 + +// simulationRunJSON is a wrapper only: run and summary stay verbatim protojson, +// so proto field additions reach the export without a change here. +type simulationRunJSON struct { + Version int `json:"version"` + Run json.RawMessage `json:"run"` + Summary json.RawMessage `json:"summary,omitempty"` +} + +// protojson randomizes whitespace; the outer encoder re-indents, normalizing it. +var simulationRunMarshaler = protojson.MarshalOptions{UseProtoNames: true} + +func writeSimulationRunJSON(w io.Writer, run *livekit.SimulationRun) error { + if run == nil { + return fmt.Errorf("cannot export a nil simulation run") + } + + export := simulationRunJSON{Version: simulationRunJSONVersion} + + if summary := decodeRunSummary(run); summary != nil { + encoded, err := simulationRunMarshaler.Marshal(summary) + if err != nil { + return fmt.Errorf("encode simulation run summary: %w", err) + } + export.Summary = encoded + } + + // The summary is exported decoded above; the compressed copy would only bloat stdout. + stripped := proto.Clone(run).(*livekit.SimulationRun) + stripped.SummaryZstd = nil + encoded, err := simulationRunMarshaler.Marshal(stripped) + if err != nil { + return fmt.Errorf("encode simulation run: %w", err) + } + export.Run = encoded + + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + encoder.SetEscapeHTML(false) + if err := encoder.Encode(export); err != nil { + return fmt.Errorf("encode simulation run JSON: %w", err) + } + return nil +} diff --git a/cmd/lk/simulate_test.go b/cmd/lk/simulate_test.go index 13b57a53..96ed6411 100644 --- a/cmd/lk/simulate_test.go +++ b/cmd/lk/simulate_test.go @@ -15,6 +15,7 @@ package main import ( + "bytes" "os" "testing" @@ -76,3 +77,29 @@ func TestViewCommandHintCarriesServerURL(t *testing.T) { "lk agent simulate --view run_123 --server-url https://cloud-api.staging.livekit.io", viewCommandHint("run_123")) } + +func TestSimulationRunHintsPutReplayJSONBelowViewTip(t *testing.T) { + origArgs := os.Args + origServerURL := serverURL + t.Cleanup(func() { + os.Args = origArgs + serverURL = origServerURL + }) + os.Args = []string{"lk"} + serverURL = cloudAPIServerURL + + var output bytes.Buffer + writeSimulationRunHints(&output, "run_123", false) + + require.Equal(t, + "To re-open this simulation, run: lk agent simulate --view run_123\n"+ + "To export replay JSON, run: lk agent simulate --view run_123 --run-json > run_123.json\n", + output.String()) + + output.Reset() + writeSimulationRunHints(&output, "run_123", true) + + require.Equal(t, + "To export replay JSON, run: lk agent simulate --view run_123 --run-json > run_123.json\n", + output.String()) +} diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 64e5d4b4..e2322381 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -82,11 +82,11 @@ func runSimulateTUI(config *simulateConfig) error { } if m.config.mode == modeView { - fmt.Fprintf(os.Stderr, "To re-open this simulation, run: %s\n", viewCommandHint(m.config.viewModeRunID)) + writeSimulationRunHints(os.Stderr, m.config.viewModeRunID, true) } else if m.runID != "" && !m.runFinished { cancelSimulationRun(config.client, m.runID) } else if m.runID != "" { - fmt.Fprintf(os.Stderr, "To re-open this simulation, run: %s\n", viewCommandHint(m.runID)) + writeSimulationRunHints(os.Stderr, m.runID, false) } if runErr != nil {