Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions autocomplete/fish_autocomplete
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
21 changes: 20 additions & 1 deletion cmd/lk/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 ""
Expand Down
26 changes: 22 additions & 4 deletions cmd/lk/simulate_ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
71 changes: 71 additions & 0 deletions cmd/lk/simulate_json.go
Original file line number Diff line number Diff line change
@@ -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
}
27 changes: 27 additions & 0 deletions cmd/lk/simulate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package main

import (
"bytes"
"os"
"testing"

Expand Down Expand Up @@ -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())
}
4 changes: 2 additions & 2 deletions cmd/lk/simulate_tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading