diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 31e67270..e45278ce 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -560,9 +560,13 @@ func uploadSource(ctx context.Context, client *lksdk.AgentSimulationClient, runI return nil } -func getSimulationRun(ctx context.Context, client *lksdk.AgentSimulationClient, runID string) (*livekit.SimulationRun, error) { +// getSimulationRun carries the project ID because the server accepts either an +// API key or a session token, and the session path cannot resolve the run +// without it. +func getSimulationRun(ctx context.Context, client *lksdk.AgentSimulationClient, runID, projectID string) (*livekit.SimulationRun, error) { resp, err := client.GetSimulationRun(ctx, &livekit.SimulationRun_Get_Request{ SimulationRunId: runID, + ProjectId: projectID, }) if err != nil { return nil, err @@ -596,16 +600,23 @@ func dashboardBaseURL() 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. +// carrying over the resolved project and --server-url when the run lives +// somewhere other than the default cloud API (e.g. staging), so the printed +// command targets the same project and environment regardless of which project +// is default when it is run. The project name is empty when credentials came +// from flags or the environment rather than a configured project, and no +// --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 { binary := "lk" if len(os.Args) > 0 && os.Args[0] != "" { binary = os.Args[0] } hint := binary + " agent simulate " + flag + " " + runID + if simulateProjectConfig != nil && simulateProjectConfig.Name != "" { + hint += " --project " + simulateProjectConfig.Name + } if serverURL != cloudAPIServerURL { hint += " --server-url " + serverURL } diff --git a/cmd/lk/simulate_ci.go b/cmd/lk/simulate_ci.go index fbbee3c8..1514bde8 100644 --- a/cmd/lk/simulate_ci.go +++ b/cmd/lk/simulate_ci.go @@ -146,7 +146,7 @@ func runSimulateCI(ctx context.Context, config *simulateConfig) error { for { pollCtx, pollCancel := context.WithTimeout(ctx, simulationAPITimeout) - run, err = getSimulationRun(pollCtx, config.client, runID) + run, err = getSimulationRun(pollCtx, config.client, runID, config.pc.ProjectId) pollCancel() if err != nil { @@ -254,7 +254,7 @@ func runSimulateCIView(ctx context.Context, config *simulateConfig) error { for { pollCtx, pollCancel := context.WithTimeout(ctx, simulationAPITimeout) var err error - run, err = getSimulationRun(pollCtx, config.client, runID) + run, err = getSimulationRun(pollCtx, config.client, runID, config.pc.ProjectId) pollCancel() if err != nil { if ctx.Err() != nil { diff --git a/cmd/lk/simulate_json.go b/cmd/lk/simulate_json.go index 11049bec..50808c06 100644 --- a/cmd/lk/simulate_json.go +++ b/cmd/lk/simulate_json.go @@ -93,7 +93,7 @@ func exportSimulationRunJSON(ctx context.Context, pc *config.ProjectConfig, runI fetchCtx, cancel := context.WithTimeout(ctx, simulationAPITimeout) defer cancel() - run, err := getSimulationRun(fetchCtx, client, runID) + run, err := getSimulationRun(fetchCtx, client, runID, pc.ProjectId) if err != nil { return err } diff --git a/cmd/lk/simulate_test.go b/cmd/lk/simulate_test.go deleted file mode 100644 index 6b178814..00000000 --- a/cmd/lk/simulate_test.go +++ /dev/null @@ -1,92 +0,0 @@ -// 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" - "os" - "testing" - - "github.com/stretchr/testify/require" - "github.com/urfave/cli/v3" -) - -func TestSimulateConfigWarnings(t *testing.T) { - // -n with a scenarios file: the flag does nothing, warn. - warns := simulateConfigWarnings(modeScenarios, 5) - require.Len(t, warns, 1) - require.Contains(t, warns[0], "--num-simulations has no effect") - require.Contains(t, warns[0], "--concurrency") - - // -n while generating from source: the flag is meaningful, no warning. - require.Empty(t, simulateConfigWarnings(modeGenerateFromSource, 5)) - // no -n at all: no warning. - require.Empty(t, simulateConfigWarnings(modeScenarios, 0)) - // view mode ignores everything silently. - require.Empty(t, simulateConfigWarnings(modeView, 5)) -} - -func TestViewCommandHintUsesArgv0(t *testing.T) { - origArgs := os.Args - origServerURL := serverURL - t.Cleanup(func() { - os.Args = origArgs - serverURL = origServerURL - }) - serverURL = cloudAPIServerURL - - for _, tc := range []struct { - name string - argv0 string - want string - }{ - {"plain lk", "lk", "lk agent simulate --view run_123"}, - {"path-qualified", "/usr/local/bin/lk", "/usr/local/bin/lk agent simulate --view run_123"}, - {"renamed binary", "lk-dev", "lk-dev agent simulate --view run_123"}, - {"empty argv0 falls back", "", "lk agent simulate --view run_123"}, - } { - t.Run(tc.name, func(t *testing.T) { - os.Args = []string{tc.argv0} - require.Equal(t, tc.want, viewCommandHint("run_123")) - }) - } -} - -func TestViewCommandHintCarriesServerURL(t *testing.T) { - origArgs := os.Args - origServerURL := serverURL - t.Cleanup(func() { - os.Args = origArgs - serverURL = origServerURL - }) - os.Args = []string{"lk"} - serverURL = "https://cloud-api.staging.livekit.io" - - require.Equal(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") -} diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 69c6c93a..d7c57217 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -222,9 +222,13 @@ type simulateModel struct { // been emitted for it, so a re-render only ever appends its new tail. // detailWidth is the width that text was wrapped at: scrollback cannot be // re-wrapped, so a resize rebaselines instead of reprinting. - detailPrinted string - detailWidth int - showLogs bool + detailPrinted string + detailWidth int + showLogs bool + // tool arguments and outputs are off the transcript unless asked for: they + // are payloads written for the model, and at full length they bury the + // conversation + showToolDetail bool logScrollOff int logPinned bool logPinnedTotal int @@ -423,7 +427,7 @@ func (m *simulateModel) runSetup() tea.Cmd { if c.mode == modeView { ctx, cancel := context.WithTimeout(context.Background(), simulationAPITimeout) defer cancel() - run, err := getSimulationRun(ctx, m.config.client, m.config.viewModeRunID) + run, err := getSimulationRun(ctx, m.config.client, m.config.viewModeRunID, m.config.pc.ProjectId) if err != nil { m.err = err } @@ -565,7 +569,7 @@ func (m *simulateModel) pollSimulation() tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), simulationAPITimeout) defer cancel() - run, err := getSimulationRun(ctx, m.config.client, m.runID) + run, err := getSimulationRun(ctx, m.config.client, m.runID, m.config.pc.ProjectId) return simulationRunMsg{run: run, err: err} } } @@ -895,6 +899,10 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.showLogs = !m.showLogs m.logScrollOff = 0 m.logPinned = false + case "t": + if m.detailJobID != "" { + m.showToolDetail = !m.showToolDetail + } case "d": if m.detailJobID == "" && m.hasDescription() { m.showDescription = !m.showDescription @@ -1696,12 +1704,14 @@ func (m *simulateModel) flushDetail() tea.Cmd { return nil } tail, ok := detailTail(m.detailPrinted, rendered) - first := m.detailPrinted == "" + // a whole-body reprint has to erase what it replaces, or the copy it + // supersedes stays in the scrollback above it + reprint := m.detailPrinted == "" || !strings.HasPrefix(rendered, m.detailPrinted) m.detailPrinted = rendered if !ok { return nil } - if first { + if reprint { tail = clearScrollback + tail } return tea.Println(tail) @@ -1864,25 +1874,19 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { } case *agent.ChatContext_ChatItem_FunctionCall: fc := v.FunctionCall - args := fc.Arguments - if len(args) > 80 { - args = args[:80] + "..." - } ensureAgentBlock() - b.WriteString(dimStyle.Render(fmt.Sprintf(" ƒ %s(%s)", fc.Name, args))) - b.WriteString("\n") + writeToolItem(&b, fmt.Sprintf("ƒ %s(%s)", fc.Name, m.toolArguments(fc.Arguments)), wrapWidth) case *agent.ChatContext_ChatItem_FunctionCallOutput: + if !m.showToolDetail { + continue + } fco := v.FunctionCallOutput output := strings.TrimSpace(fco.Output) if output == "" { continue } - if len(output) > 80 { - output = output[:80] + "..." - } ensureAgentBlock() - b.WriteString(dimStyle.Render(fmt.Sprintf(" → %s", output))) - b.WriteString("\n") + writeToolItem(&b, "→ "+output, wrapWidth) case *agent.ChatContext_ChatItem_AgentHandoff: h := v.AgentHandoff old := "" @@ -1897,6 +1901,60 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { return b.String() } +// toolArguments renders a call's arguments for the transcript. Collapsed, an +// argument list stands for itself with an ellipsis: the call's name is what +// reads the conversation, and full JSON payloads bury it. +func (m *simulateModel) toolArguments(arguments string) string { + arguments = strings.TrimSpace(arguments) + if m.showToolDetail { + return arguments + } + if arguments == "" || arguments == "{}" { + return "" + } + return "…" +} + +// writeToolItem appends one tool line to b, wrapped to the transcript's measure +// with its continuations indented under the marker, so a long output stays +// readable as a block instead of one run-on row. +func writeToolItem(b *strings.Builder, text string, wrapWidth int) { + for i, line := range wrapLines(text, wrapWidth-2) { + indent := " " + if i > 0 { + indent = " " + } + b.WriteString(dimStyle.Render(indent + line)) + b.WriteString("\n") + } +} + +// hasToolDetail reports whether the open job's transcript holds a tool call's +// arguments or output, so the hint is only offered when the toggle would show +// something. +func (m *simulateModel) hasToolDetail(jobID string) bool { + if m.summary == nil || m.summary.ChatHistory == nil { + return false + } + chatCtx, ok := m.summary.ChatHistory[jobID] + if !ok || chatCtx == nil { + return false + } + for _, item := range chatCtx.Items { + switch v := item.Item.(type) { + case *agent.ChatContext_ChatItem_FunctionCall: + if args := strings.TrimSpace(v.FunctionCall.Arguments); args != "" && args != "{}" { + return true + } + case *agent.ChatContext_ChatItem_FunctionCallOutput: + if strings.TrimSpace(v.FunctionCallOutput.Output) != "" { + return true + } + } + } + return false +} + func chatMessageText(msg *agent.ChatMessage) string { if msg == nil || len(msg.Content) == 0 { return "" @@ -2016,6 +2074,13 @@ func (m *simulateModel) renderHint() string { case m.detailJobID != "": // the job view is in the terminal's scrollback, which scrolls itself parts = append(parts, "c copy scenario · ←/ESC back to list") + if m.hasToolDetail(m.detailJobID) { + if m.showToolDetail { + parts = append(parts, "t hide tool detail") + } else { + parts = append(parts, "t show tool detail") + } + } if m.hasLogs() { if m.showLogs { parts = append(parts, "Ctrl+L hide logs")