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 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 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
60 changes: 48 additions & 12 deletions 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.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.",
Expand Down Expand Up @@ -272,6 +276,16 @@ func buildTaskExists(projectDir string) (bool, error) {
func runSimulate(ctx context.Context, cmd *cli.Command) 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")
Expand Down Expand Up @@ -575,23 +589,40 @@ func dashboardBaseURL() string {
return dashboardURL
}

// viewCommandHint returns the command to re-open a simulation run, carrying
// over --server-url when the run lives somewhere other than the default cloud
// API (e.g. staging), so the printed command targets the same environment.
// The binary name comes from argv[0] so a renamed or path-qualified lk is
// reproduced verbatim.
func viewCommandHint(runID string) string {
// simulateCommandHint returns a `simulate` command targeting an existing run,
// carrying over --server-url when the run lives somewhere other than the
// default cloud API (e.g. staging), so the printed command targets the same
// environment. The binary name comes from argv[0] so a renamed or
// path-qualified lk is reproduced verbatim.
func simulateCommandHint(flag, runID string) string {
binary := "lk"
if len(os.Args) > 0 && os.Args[0] != "" {
binary = os.Args[0]
}
hint := binary + " agent simulate --view " + runID
hint := binary + " agent simulate " + flag + " " + runID
if serverURL != cloudAPIServerURL {
hint += " --server-url " + serverURL
}
return hint
}

func viewCommandHint(runID string) string {
return simulateCommandHint("--view", runID)
}

func exportCommandHint(runID string) string {
return simulateCommandHint("--export", runID) + " > " + 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", exportCommandHint(runID))
}

func simulationDashboardURL(projectID, runID string) string {
if projectID == "" || runID == "" {
return ""
Expand Down Expand Up @@ -637,22 +668,27 @@ func simulationJobCounts(run *livekit.SimulationRun) (total, done, passed, faile
return
}

func decodeRunSummary(run *livekit.SimulationRun) *livekit.SimulationRunSummary {
func decodeRunSummaryStrict(run *livekit.SimulationRun) (*livekit.SimulationRunSummary, error) {
if run == nil || len(run.SummaryZstd) == 0 {
return nil
return nil, nil
}
dec, err := zstd.NewReader(nil)
if err != nil {
return nil
return nil, fmt.Errorf("create zstd decoder: %w", err)
}
defer dec.Close()
raw, err := dec.DecodeAll(run.SummaryZstd, nil)
if err != nil {
return nil
return nil, fmt.Errorf("decompress summary: %w", err)
}
summary := &livekit.SimulationRunSummary{}
if err := proto.Unmarshal(raw, summary); err != nil {
return nil
return nil, fmt.Errorf("unmarshal summary: %w", err)
}
return summary, nil
}

func decodeRunSummary(run *livekit.SimulationRun) *livekit.SimulationRunSummary {
summary, _ := decodeRunSummaryStrict(run)
return summary
}
111 changes: 111 additions & 0 deletions cmd/lk/simulate_json.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// 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"
"encoding/json"
"fmt"
"io"
"strings"

"github.com/livekit/livekit-cli/v2/pkg/config"
"github.com/livekit/protocol/livekit"
lksdk "github.com/livekit/server-sdk-go/v2"
"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.
// EmitDefaultValues keeps zero-valued scalars in the export: a proto3 implicit-presence
// bool is indistinguishable from unset once omitted, so a consumer reading a dropped
// is_error has to guess it.
var simulationRunMarshaler = protojson.MarshalOptions{
UseProtoNames: true,
EmitDefaultValues: 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}

summary, err := decodeRunSummaryStrict(run)
if err != nil {
return fmt.Errorf("decode simulation run summary: %w", err)
}
if 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
}

// exportSimulationRunJSON exports an existing run in a single fetch. An
// unfinished run is an error rather than a wait, so stdout is either a
// complete export or nothing; an unknown run ID surfaces the API's not-found
// error.
func exportSimulationRunJSON(ctx context.Context, pc *config.ProjectConfig, runID string) error {
client := lksdk.NewAgentSimulationClient(serverURL, pc.APIKey, pc.APISecret)

fetchCtx, cancel := context.WithTimeout(ctx, simulationAPITimeout)
defer cancel()
run, err := getSimulationRun(fetchCtx, client, runID)
if err != nil {
return err
}

if !isTerminalRunStatus(run.GetStatus()) {
return fmt.Errorf(
"simulation run %s is still in progress (%s); follow it with %s",
runID,
strings.TrimPrefix(run.GetStatus().String(), "STATUS_"),
viewCommandHint(runID),
)
}

return writeSimulationRunJSON(out.ResultWriter(), run)
}
37 changes: 37 additions & 0 deletions cmd/lk/simulate_json_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// 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 (
"bytes"
"testing"

"github.com/livekit/protocol/livekit"
"github.com/stretchr/testify/require"
)

func TestWriteSimulationRunJSONRejectsInvalidSummary(t *testing.T) {
run := &livekit.SimulationRun{
Id: "run_123",
Status: livekit.SimulationRun_STATUS_COMPLETED,
SummaryZstd: []byte("not a zstd frame"),
}
var output bytes.Buffer

err := writeSimulationRunJSON(&output, run)

require.ErrorContains(t, err, "decode simulation run summary")
require.Empty(t, output.String())
}
14 changes: 14 additions & 0 deletions cmd/lk/simulate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
package main

import (
"context"
"os"
"testing"

"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)

func TestSimulateConfigWarnings(t *testing.T) {
Expand Down Expand Up @@ -76,3 +78,15 @@ func TestViewCommandHintCarriesServerURL(t *testing.T) {
"lk agent simulate --view run_123 --server-url https://cloud-api.staging.livekit.io",
viewCommandHint("run_123"))
}

func TestRunSimulateRejectsEmptyExportRunID(t *testing.T) {
cmd := &cli.Command{
Flags: []cli.Flag{
&cli.StringFlag{Name: "export"},
},
Action: runSimulate,
}

err := cmd.Run(context.Background(), []string{"lk", "--export="})
require.EqualError(t, err, "--export requires a run ID")
}
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