diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 51fae425..65acb17e 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -546,9 +546,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 @@ -582,8 +586,12 @@ func dashboardBaseURL() string { } // 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. +// 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 viewCommandHint(runID string) string { @@ -592,6 +600,9 @@ func viewCommandHint(runID string) string { binary = os.Args[0] } hint := binary + " agent simulate --view " + 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_test.go b/cmd/lk/simulate_test.go deleted file mode 100644 index 13b57a53..00000000 --- a/cmd/lk/simulate_test.go +++ /dev/null @@ -1,78 +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 ( - "os" - "testing" - - "github.com/stretchr/testify/require" -) - -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")) -} diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 0ec3b9bb..5e99be21 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -222,9 +222,12 @@ 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 outputs are off the transcript unless asked for: they are payloads + // written for the model, and at full length they bury the conversation + showToolOutput bool logScrollOff int logPinned bool logPinnedTotal int @@ -423,7 +426,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 +568,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 +898,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.showToolOutput = !m.showToolOutput + } case "d": if m.detailJobID == "" && m.hasDescription() { m.showDescription = !m.showDescription @@ -1696,12 +1703,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 +1873,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, fc.Arguments), wrapWidth) case *agent.ChatContext_ChatItem_FunctionCallOutput: + if !m.showToolOutput { + 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 +1900,40 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { return b.String() } +// 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") + } +} + +// hasToolOutput reports whether the open job's transcript holds a tool output, +// so the hint is only offered when the toggle would show something. +func (m *simulateModel) hasToolOutput(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 { + if v, ok := item.Item.(*agent.ChatContext_ChatItem_FunctionCallOutput); ok { + 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 +2053,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.hasToolOutput(m.detailJobID) { + if m.showToolOutput { + parts = append(parts, "t hide tool output") + } else { + parts = append(parts, "t show tool output") + } + } if m.hasLogs() { if m.showLogs { parts = append(parts, "Ctrl+L hide logs")