From bf91dd0fb615ed77bfc4060c856c4493c52ac284 Mon Sep 17 00:00:00 2001 From: Rob Dysell Date: Tue, 8 Sep 2026 12:51:48 +0200 Subject: [PATCH 1/3] Pass --deployment from lk agent simulate into SimulationRun create. Omitted AgentDispatch deployment defaults to production. Shared Cloud agent names cannot pin staging/dev until create carries field 14 and the simulation service copies it onto the dispatch. Fixes livekit/livekit-cli#965 Co-authored-by: Cursor --- cmd/lk/simulate.go | 51 ++++++++++++++++---------- cmd/lk/simulate_deployment.go | 44 +++++++++++++++++++++++ cmd/lk/simulate_deployment_test.go | 58 ++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 19 deletions(-) create mode 100644 cmd/lk/simulate_deployment.go create mode 100644 cmd/lk/simulate_deployment_test.go diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 315345b8..e2480ce5 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -105,6 +105,11 @@ var simulateCommand = &cli.Command{ 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.", }, + &cli.StringFlag{ + Name: "deployment", + Usage: "deployment of the agent to dispatch to (leave empty for production)", + Aliases: []string{"d"}, + }, }, } @@ -114,7 +119,7 @@ var simulateCommand = &cli.Command{ var simulateAudioCommand = &cli.Command{ Name: "audio", Usage: "Simulate speech-to-speech interactions using the agent's full audio pipeline", - Description: "Options on lk agent simulate apply here too, e.g. --scenarios and --agent-name.", + Description: "Options on lk agent simulate apply here too, e.g. --scenarios, --agent-name, and --deployment.", ArgsUsage: "[entrypoint]", HideHelpCommand: true, Action: func(ctx context.Context, cmd *cli.Command) error { @@ -222,8 +227,9 @@ type simulateConfig struct { lowQualityMicrophone bool packetLoss bool - // TODO (steveyoon): add agent deployment support - // agentDeployment string + // Cloud AgentDispatch.deployment. Empty = production. Only meaningful + // with --agent-name; a locally spawned worker is not a Cloud deployment. + agentDeployment string } type simulateMode int @@ -339,6 +345,11 @@ func runSimulate(ctx context.Context, cmd *cli.Command, simulationMode livekit.S // --agent-name (even empty) means: run against an already-running agent, // don't spawn one. https://docs.livekit.io/agents/server/agent-dispatch/#automatic + agentDeployment := cmd.String("deployment") + if cmd.IsSet("deployment") && agentDeployment != "" && !cmd.IsSet("agent-name") { + return fmt.Errorf("--deployment requires --agent-name (a locally spawned worker is not a Cloud deployment)") + } + if cmd.IsSet("agent-name") { // nothing is spawned, so there's no source to generate scenarios from. if scenariosPath == "" { @@ -402,22 +413,23 @@ func runSimulate(ctx context.Context, cmd *cli.Command, simulationMode livekit.S simClient := lksdk.NewAgentSimulationClient(serverURL, pc.APIKey, pc.APISecret) simCfg := &simulateConfig{ - ctx: ctx, - client: simClient, - pc: pc, - numSimulations: numSimulations, - concurrency: concurrency, - mode: mode, - simulationMode: simulationMode, - agentName: agentName, - projectDir: projectDir, - projectType: projectType, - entrypoint: entrypoint, - scenarioGroup: scenarioGroup, - scenariosPath: scenariosPath, - viewModeRunID: runID, - liveAgent: liveAgent, - warnings: simulateConfigWarnings(mode, numSimulations), + ctx: ctx, + client: simClient, + pc: pc, + numSimulations: numSimulations, + concurrency: concurrency, + mode: mode, + simulationMode: simulationMode, + agentName: agentName, + projectDir: projectDir, + projectType: projectType, + entrypoint: entrypoint, + scenarioGroup: scenarioGroup, + scenariosPath: scenariosPath, + viewModeRunID: runID, + liveAgent: liveAgent, + agentDeployment: agentDeployment, + warnings: simulateConfigWarnings(mode, numSimulations), } if simulationMode == livekit.SimulationMode_SIMULATION_MODE_AUDIO { @@ -570,6 +582,7 @@ func createSimulationRun(ctx context.Context, c *simulateConfig) (string, *livek LowQualityMicrophone: c.lowQualityMicrophone, PacketLoss: c.packetLoss, } + setSimulationCreateDeployment(req, c.agentDeployment) if c.concurrency > 0 { req.Concurrency = &c.concurrency } diff --git a/cmd/lk/simulate_deployment.go b/cmd/lk/simulate_deployment.go new file mode 100644 index 00000000..5b9dfae4 --- /dev/null +++ b/cmd/lk/simulate_deployment.go @@ -0,0 +1,44 @@ +// Copyright 2025 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 ( + "github.com/livekit/protocol/livekit" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// simulationCreateDeploymentField is SimulationRun.Create.Request.deployment. +// Empty/unset = production, matching CreateAgentDispatchRequest.deployment. +const simulationCreateDeploymentField protowire.Number = 14 + +// setSimulationCreateDeployment writes deployment onto the create request. +// Prefer the generated field when this CLI's protocol module has it; otherwise +// emit protobuf field 14 as unknown bytes so Cloud can pin AgentDispatch before +// a protocol module bump lands here. +func setSimulationCreateDeployment(req *livekit.SimulationRun_Create_Request, deployment string) { + if req == nil || deployment == "" { + return + } + msg := req.ProtoReflect() + if fd := msg.Descriptor().Fields().ByName("deployment"); fd != nil { + msg.Set(fd, protoreflect.ValueOfString(deployment)) + return + } + var b []byte + b = protowire.AppendTag(b, simulationCreateDeploymentField, protowire.BytesType) + b = protowire.AppendString(b, deployment) + msg.SetUnknown(append(msg.GetUnknown(), b...)) +} diff --git a/cmd/lk/simulate_deployment_test.go b/cmd/lk/simulate_deployment_test.go new file mode 100644 index 00000000..e4126136 --- /dev/null +++ b/cmd/lk/simulate_deployment_test.go @@ -0,0 +1,58 @@ +// Copyright 2025 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 ( + "testing" + + "github.com/livekit/protocol/livekit" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" +) + +func TestSetSimulationCreateDeploymentEmitsField14(t *testing.T) { + req := &livekit.SimulationRun_Create_Request{AgentName: "myudda-agent"} + setSimulationCreateDeployment(req, "staging") + + raw, err := proto.Marshal(req) + require.NoError(t, err) + + var got string + for len(raw) > 0 { + num, typ, n := protowire.ConsumeTag(raw) + require.False(t, n < 0) + raw = raw[n:] + if typ != protowire.BytesType { + _, n = protowire.ConsumeFieldValue(num, typ, raw) + require.False(t, n < 0) + raw = raw[n:] + continue + } + val, n := protowire.ConsumeBytes(raw) + require.False(t, n < 0) + raw = raw[n:] + if num == simulationCreateDeploymentField { + got = string(val) + } + } + require.Equal(t, "staging", got) +} + +func TestSetSimulationCreateDeploymentEmptyIsOmitted(t *testing.T) { + req := &livekit.SimulationRun_Create_Request{AgentName: "myudda-agent"} + setSimulationCreateDeployment(req, "") + require.Empty(t, req.ProtoReflect().GetUnknown()) +} From 6c0ce3f088f6407253218f6e825fa239f95d93ad Mon Sep 17 00:00:00 2001 From: Rob Dysell Date: Tue, 8 Sep 2026 13:18:47 +0200 Subject: [PATCH 2/3] Fix simulate deployment test for protowire.ConsumeFieldValue. CI failed to compile: ConsumeFieldValue returns one int, not two. Co-authored-by: Cursor --- cmd/lk/simulate_deployment_test.go | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/cmd/lk/simulate_deployment_test.go b/cmd/lk/simulate_deployment_test.go index e4126136..27e0feed 100644 --- a/cmd/lk/simulate_deployment_test.go +++ b/cmd/lk/simulate_deployment_test.go @@ -31,22 +31,23 @@ func TestSetSimulationCreateDeploymentEmitsField14(t *testing.T) { require.NoError(t, err) var got string - for len(raw) > 0 { - num, typ, n := protowire.ConsumeTag(raw) - require.False(t, n < 0) - raw = raw[n:] - if typ != protowire.BytesType { - _, n = protowire.ConsumeFieldValue(num, typ, raw) - require.False(t, n < 0) - raw = raw[n:] + rest := raw + for len(rest) > 0 { + num, typ, n := protowire.ConsumeTag(rest) + require.Greater(t, n, 0) + rest = rest[n:] + if typ == protowire.BytesType { + val, n := protowire.ConsumeBytes(rest) + require.Greater(t, n, 0) + rest = rest[n:] + if num == simulationCreateDeploymentField { + got = string(val) + } continue } - val, n := protowire.ConsumeBytes(raw) - require.False(t, n < 0) - raw = raw[n:] - if num == simulationCreateDeploymentField { - got = string(val) - } + skip := protowire.ConsumeFieldValue(num, typ, rest) + require.Greater(t, skip, 0) + rest = rest[skip:] } require.Equal(t, "staging", got) } From 0dab093a546cba50868f1f4623017de6cdfb052e Mon Sep 17 00:00:00 2001 From: Rob Dysell Date: Tue, 8 Sep 2026 13:31:42 +0200 Subject: [PATCH 3/3] Regenerate fish autocomplete for simulate --deployment. Ubuntu CI compares this file to generate-fish-completion output. Co-authored-by: Cursor --- autocomplete/fish_autocomplete | 1 + 1 file changed, 1 insertion(+) diff --git a/autocomplete/fish_autocomplete b/autocomplete/fish_autocomplete index d7df2680..8eb50d74 100644 --- a/autocomplete/fish_autocomplete +++ b/autocomplete/fish_autocomplete @@ -227,6 +227,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 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 deployment -s d -r -d 'deployment of the agent to dispatch to (leave empty for production)' 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 -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'