From 4202fad7da1d42c592eafb87e902c8bed7dc424e Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Mon, 3 Aug 2026 16:59:35 -0400 Subject: [PATCH 01/11] fix(simulate): erase the job view a reprint replaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail view is printed into the scrollback incrementally: flushDetail diffs the render against what it already printed and prints only the growth. A render that is not an append of the previous one cannot be patched in place, so it is reprinted whole — but clearScrollback was only prepended on the first print of a job, leaving the superseded copy above it. Toggling logs with ctrl+L off is exactly that case (the Logs block is the tail of renderDetail, so turning it on appends but turning it off shortens), so every toggle stacked another copy of the job. --- cmd/lk/simulate_tui.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 69c6c93a..9b00bfde 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -1696,12 +1696,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) From b68d92db0910f659fce5efd579eaf4a4a3809cc9 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Mon, 3 Aug 2026 20:06:59 -0400 Subject: [PATCH 02/11] feat(simulate): t expands full tool calls and outputs in a job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transcript clips tool arguments and outputs to 80 characters, which is right for reading the conversation but hides exactly what a tool was asked for and what came back — the part you want when a job failed on a tool call. t, in the job detail view, toggles the clip off: arguments and outputs render whole, wrapped to the transcript measure with continuations indented under the marker. The hint offers the key only when the open job has something clipped to reveal. Clipping now cuts on a rune boundary; tool payloads carry guest names and quoted speech, and a byte slice could halve a rune. --- cmd/lk/simulate_tui.go | 100 +++++++++++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 14 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 9b00bfde..732c914a 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -23,6 +23,7 @@ import ( "sort" "strings" "time" + "unicode/utf8" "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/textinput" @@ -222,9 +223,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 call arguments and outputs are clipped to a preview in the + // transcript; showToolDetail renders them whole instead + showToolDetail bool logScrollOff int logPinned bool logPinnedTotal int @@ -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 @@ -1866,25 +1874,16 @@ 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") + m.writeToolItem(&b, fmt.Sprintf("ƒ %s(%s)", fc.Name, m.toolValue(fc.Arguments)), wrapWidth) case *agent.ChatContext_ChatItem_FunctionCallOutput: 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") + m.writeToolItem(&b, "→ "+m.toolValue(output), wrapWidth) case *agent.ChatContext_ChatItem_AgentHandoff: h := v.AgentHandoff old := "" @@ -1899,6 +1898,72 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { return b.String() } +// toolPreviewLen is how much of a tool call's arguments or output the +// transcript shows when tool detail is collapsed — enough to tell two calls +// apart without a lookup table's worth of JSON burying the conversation. +const toolPreviewLen = 80 + +// toolValue is a tool argument blob or output as the transcript should carry +// it: clipped to a preview, or whole when tool detail is expanded. +func (m *simulateModel) toolValue(s string) string { + if m.showToolDetail || len(s) <= toolPreviewLen { + return s + } + // clip on a rune boundary; arguments and outputs carry guest names, + // currency symbols, and quoted speech + clipped := s[:toolPreviewLen] + for len(clipped) > 0 && !utf8.ValidString(clipped) { + clipped = clipped[:len(clipped)-1] + } + return clipped + "..." +} + +// writeToolItem appends one tool line to b. Collapsed, it is a single row and +// the terminal deals with any overflow. Expanded, it wraps 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 (m *simulateModel) writeToolItem(b *strings.Builder, text string, wrapWidth int) { + if !m.showToolDetail { + b.WriteString(dimStyle.Render(" " + text)) + b.WriteString("\n") + return + } + 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 anything the +// tool-detail toggle would reveal, so the hint is only offered when it does +// 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 len(v.FunctionCall.Arguments) > toolPreviewLen { + return true + } + case *agent.ChatContext_ChatItem_FunctionCallOutput: + if len(strings.TrimSpace(v.FunctionCallOutput.Output)) > toolPreviewLen { + return true + } + } + } + return false +} + func chatMessageText(msg *agent.ChatMessage) string { if msg == nil || len(msg.Content) == 0 { return "" @@ -2018,6 +2083,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 clip tool detail") + } else { + parts = append(parts, "t full tool detail") + } + } if m.hasLogs() { if m.showLogs { parts = append(parts, "Ctrl+L hide logs") From 69f9fc7227451b793fda218a87696b8530ec025b Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Tue, 4 Aug 2026 07:25:32 -0400 Subject: [PATCH 03/11] fix(simulate): carry --project into the re-open hint --- cmd/lk/simulate.go | 15 +++++++++++---- cmd/lk/simulate_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 31e67270..6a42048e 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -47,6 +47,9 @@ func init() { var ( simulateProjectConfig *config.ProjectConfig + // simulateProjectFlag is the explicit --project name, if any; the hints must + // reproduce it or a re-open resolves against a different project. + simulateProjectFlag string ) const ( @@ -68,6 +71,7 @@ var simulateCommand = &cli.Command{ return nil, err } simulateProjectConfig = pc + simulateProjectFlag = cmd.String("project") return nil, nil }, Action: runSimulate, @@ -596,16 +600,19 @@ 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 --project and --server-url when the run lives somewhere other +// than the default project and cloud API (e.g. staging), so the printed command +// targets the same project and 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 " + flag + " " + runID + if simulateProjectFlag != "" { + hint += " --project " + simulateProjectFlag + } if serverURL != cloudAPIServerURL { hint += " --server-url " + serverURL } diff --git a/cmd/lk/simulate_test.go b/cmd/lk/simulate_test.go index 6b178814..b7b8b01a 100644 --- a/cmd/lk/simulate_test.go +++ b/cmd/lk/simulate_test.go @@ -90,3 +90,27 @@ func TestRunSimulateRejectsEmptyExportRunID(t *testing.T) { err := cmd.Run(context.Background(), []string{"lk", "--export="}) require.EqualError(t, err, "--export requires a run ID") } + +func TestViewCommandHintCarriesProject(t *testing.T) { + origArgs := os.Args + origServerURL := serverURL + origProject := simulateProjectFlag + t.Cleanup(func() { + os.Args = origArgs + serverURL = origServerURL + simulateProjectFlag = origProject + }) + os.Args = []string{"lk"} + serverURL = "https://cloud-api.example.com" + simulateProjectFlag = "my-project" + + require.Equal(t, + "lk agent simulate --view run_123 --project my-project"+ + " --server-url https://cloud-api.example.com", + viewCommandHint("run_123")) + + simulateProjectFlag = "" + require.Equal(t, + "lk agent simulate --view run_123 --server-url https://cloud-api.example.com", + viewCommandHint("run_123")) +} From f208a27afea9308c852cc122b5aa1fce42d21f8d Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Tue, 4 Aug 2026 07:41:32 -0400 Subject: [PATCH 04/11] feat(simulate): t shows and hides tool output, hidden by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool outputs are payloads written for the model, not for a reader: a policy blob or a lookup table's worth of JSON between two spoken turns buries the conversation the job is about. Keep them off the transcript and put them behind t, whole rather than clipped, since a clipped payload answers nothing about why a job failed on a tool call. The tool call itself stays on the transcript either way — which tool ran, with a preview of its arguments, is part of reading what the agent did. --- cmd/lk/simulate_tui.go | 70 ++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 40 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 732c914a..116e2d40 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -226,9 +226,9 @@ type simulateModel struct { detailPrinted string detailWidth int showLogs bool - // tool call arguments and outputs are clipped to a preview in the - // transcript; showToolDetail renders them whole instead - showToolDetail 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 @@ -901,7 +901,7 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.logPinned = false case "t": if m.detailJobID != "" { - m.showToolDetail = !m.showToolDetail + m.showToolOutput = !m.showToolOutput } case "d": if m.detailJobID == "" && m.hasDescription() { @@ -1875,15 +1875,18 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { case *agent.ChatContext_ChatItem_FunctionCall: fc := v.FunctionCall ensureAgentBlock() - m.writeToolItem(&b, fmt.Sprintf("ƒ %s(%s)", fc.Name, m.toolValue(fc.Arguments)), wrapWidth) + writeToolItem(&b, fmt.Sprintf("ƒ %s(%s)", fc.Name, clipToolValue(fc.Arguments)), wrapWidth) case *agent.ChatContext_ChatItem_FunctionCallOutput: + if !m.showToolOutput { + continue + } fco := v.FunctionCallOutput output := strings.TrimSpace(fco.Output) if output == "" { continue } ensureAgentBlock() - m.writeToolItem(&b, "→ "+m.toolValue(output), wrapWidth) + writeToolItem(&b, "→ "+output, wrapWidth) case *agent.ChatContext_ChatItem_AgentHandoff: h := v.AgentHandoff old := "" @@ -1898,19 +1901,18 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { return b.String() } -// toolPreviewLen is how much of a tool call's arguments or output the -// transcript shows when tool detail is collapsed — enough to tell two calls -// apart without a lookup table's worth of JSON burying the conversation. +// toolPreviewLen is how much of a tool call's arguments the transcript shows — +// enough to tell two calls apart without a lookup table's worth of JSON burying +// the conversation. Tool outputs are shown whole, or not at all. const toolPreviewLen = 80 -// toolValue is a tool argument blob or output as the transcript should carry -// it: clipped to a preview, or whole when tool detail is expanded. -func (m *simulateModel) toolValue(s string) string { - if m.showToolDetail || len(s) <= toolPreviewLen { +// clipToolValue is a tool argument blob as the transcript carries it. +func clipToolValue(s string) string { + if len(s) <= toolPreviewLen { return s } - // clip on a rune boundary; arguments and outputs carry guest names, - // currency symbols, and quoted speech + // clip on a rune boundary; arguments carry guest names, currency symbols, + // and quoted speech clipped := s[:toolPreviewLen] for len(clipped) > 0 && !utf8.ValidString(clipped) { clipped = clipped[:len(clipped)-1] @@ -1918,16 +1920,10 @@ func (m *simulateModel) toolValue(s string) string { return clipped + "..." } -// writeToolItem appends one tool line to b. Collapsed, it is a single row and -// the terminal deals with any overflow. Expanded, it wraps 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 (m *simulateModel) writeToolItem(b *strings.Builder, text string, wrapWidth int) { - if !m.showToolDetail { - b.WriteString(dimStyle.Render(" " + text)) - b.WriteString("\n") - 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 { @@ -1938,10 +1934,9 @@ func (m *simulateModel) writeToolItem(b *strings.Builder, text string, wrapWidth } } -// hasToolDetail reports whether the open job's transcript holds anything the -// tool-detail toggle would reveal, so the hint is only offered when it does -// something. -func (m *simulateModel) hasToolDetail(jobID string) bool { +// 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 } @@ -1950,13 +1945,8 @@ func (m *simulateModel) hasToolDetail(jobID string) bool { return false } for _, item := range chatCtx.Items { - switch v := item.Item.(type) { - case *agent.ChatContext_ChatItem_FunctionCall: - if len(v.FunctionCall.Arguments) > toolPreviewLen { - return true - } - case *agent.ChatContext_ChatItem_FunctionCallOutput: - if len(strings.TrimSpace(v.FunctionCallOutput.Output)) > toolPreviewLen { + if v, ok := item.Item.(*agent.ChatContext_ChatItem_FunctionCallOutput); ok { + if strings.TrimSpace(v.FunctionCallOutput.Output) != "" { return true } } @@ -2083,11 +2073,11 @@ 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 clip tool detail") + if m.hasToolOutput(m.detailJobID) { + if m.showToolOutput { + parts = append(parts, "t hide tool output") } else { - parts = append(parts, "t full tool detail") + parts = append(parts, "t tool output") } } if m.hasLogs() { From 75090d87ce4a4a7e0794b7ab62443f79f2c8dcb0 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Tue, 4 Aug 2026 07:46:59 -0400 Subject: [PATCH 05/11] fix(simulate): say show in the tool output hint The verb was implicit on the way in and explicit on the way out. --- cmd/lk/simulate_tui.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 116e2d40..9fca7414 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -2077,7 +2077,7 @@ func (m *simulateModel) renderHint() string { if m.showToolOutput { parts = append(parts, "t hide tool output") } else { - parts = append(parts, "t tool output") + parts = append(parts, "t show tool output") } } if m.hasLogs() { From deba217619344ce7243d2ab93b6e5c37bb77e063 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Tue, 4 Aug 2026 07:51:14 -0400 Subject: [PATCH 06/11] fix(simulate): send the project ID when fetching a simulation run GetSimulationRun accepts an API key or a session token, and the session path rejects the request when project_id is absent. Every caller already has the resolved project, so pass it through. --- cmd/lk/simulate.go | 6 +++++- cmd/lk/simulate_ci.go | 4 ++-- cmd/lk/simulate_tui.go | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 6a42048e..87d1464a 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -564,9 +564,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 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_tui.go b/cmd/lk/simulate_tui.go index 9fca7414..92129e65 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -427,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 } @@ -569,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} } } From 7bebf9f7969fdd5e7f7ec623fc7fc25fb9338f34 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Wed, 5 Aug 2026 13:27:19 -0400 Subject: [PATCH 07/11] fix(simulate): take the hint's project from the resolved config The re-open hint read --project off the command, so it only reproduced an explicitly passed project. The resolved config already carries the name for every configured-project path, and pinning it means the hint targets the same project even if the default changes. --- cmd/lk/simulate.go | 20 ++++++++++---------- cmd/lk/simulate_json.go | 2 +- cmd/lk/simulate_test.go | 11 +++++++---- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 87d1464a..e45278ce 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -47,9 +47,6 @@ func init() { var ( simulateProjectConfig *config.ProjectConfig - // simulateProjectFlag is the explicit --project name, if any; the hints must - // reproduce it or a re-open resolves against a different project. - simulateProjectFlag string ) const ( @@ -71,7 +68,6 @@ var simulateCommand = &cli.Command{ return nil, err } simulateProjectConfig = pc - simulateProjectFlag = cmd.String("project") return nil, nil }, Action: runSimulate, @@ -604,18 +600,22 @@ func dashboardBaseURL() string { } // simulateCommandHint returns a `simulate` command targeting an existing run, -// carrying over --project and --server-url when the run lives somewhere other -// than the default project and cloud API (e.g. staging), so the printed command -// targets the same project and 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 simulateProjectFlag != "" { - hint += " --project " + simulateProjectFlag + if simulateProjectConfig != nil && simulateProjectConfig.Name != "" { + hint += " --project " + simulateProjectConfig.Name } if serverURL != cloudAPIServerURL { hint += " --server-url " + serverURL 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 index b7b8b01a..be830650 100644 --- a/cmd/lk/simulate_test.go +++ b/cmd/lk/simulate_test.go @@ -21,6 +21,8 @@ import ( "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" + + "github.com/livekit/livekit-cli/v2/pkg/config" ) func TestSimulateConfigWarnings(t *testing.T) { @@ -94,22 +96,23 @@ func TestRunSimulateRejectsEmptyExportRunID(t *testing.T) { func TestViewCommandHintCarriesProject(t *testing.T) { origArgs := os.Args origServerURL := serverURL - origProject := simulateProjectFlag + origProject := simulateProjectConfig t.Cleanup(func() { os.Args = origArgs serverURL = origServerURL - simulateProjectFlag = origProject + simulateProjectConfig = origProject }) os.Args = []string{"lk"} serverURL = "https://cloud-api.example.com" - simulateProjectFlag = "my-project" + simulateProjectConfig = &config.ProjectConfig{Name: "my-project"} require.Equal(t, "lk agent simulate --view run_123 --project my-project"+ " --server-url https://cloud-api.example.com", viewCommandHint("run_123")) - simulateProjectFlag = "" + // credentials from flags or the environment resolve no project name + simulateProjectConfig = &config.ProjectConfig{} require.Equal(t, "lk agent simulate --view run_123 --server-url https://cloud-api.example.com", viewCommandHint("run_123")) From 6ab53ad1d26ab2cf7fa248a5eb4e18dbd9ed7fc5 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Wed, 5 Aug 2026 13:30:07 -0400 Subject: [PATCH 08/11] test(simulate): drop cmd/lk/simulate_test.go --- cmd/lk/simulate_test.go | 119 ---------------------------------------- 1 file changed, 119 deletions(-) delete mode 100644 cmd/lk/simulate_test.go diff --git a/cmd/lk/simulate_test.go b/cmd/lk/simulate_test.go deleted file mode 100644 index be830650..00000000 --- a/cmd/lk/simulate_test.go +++ /dev/null @@ -1,119 +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" - - "github.com/livekit/livekit-cli/v2/pkg/config" -) - -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") -} - -func TestViewCommandHintCarriesProject(t *testing.T) { - origArgs := os.Args - origServerURL := serverURL - origProject := simulateProjectConfig - t.Cleanup(func() { - os.Args = origArgs - serverURL = origServerURL - simulateProjectConfig = origProject - }) - os.Args = []string{"lk"} - serverURL = "https://cloud-api.example.com" - simulateProjectConfig = &config.ProjectConfig{Name: "my-project"} - - require.Equal(t, - "lk agent simulate --view run_123 --project my-project"+ - " --server-url https://cloud-api.example.com", - viewCommandHint("run_123")) - - // credentials from flags or the environment resolve no project name - simulateProjectConfig = &config.ProjectConfig{} - require.Equal(t, - "lk agent simulate --view run_123 --server-url https://cloud-api.example.com", - viewCommandHint("run_123")) -} From e14bf8228ab9080e48cd0b8a5f0df3bdf3bfe07e Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Wed, 5 Aug 2026 13:38:12 -0400 Subject: [PATCH 09/11] feat(simulate): show tool arguments whole --- cmd/lk/simulate_tui.go | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 92129e65..b7025b8d 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -23,7 +23,6 @@ import ( "sort" "strings" "time" - "unicode/utf8" "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/textinput" @@ -1875,7 +1874,7 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { case *agent.ChatContext_ChatItem_FunctionCall: fc := v.FunctionCall ensureAgentBlock() - writeToolItem(&b, fmt.Sprintf("ƒ %s(%s)", fc.Name, clipToolValue(fc.Arguments)), wrapWidth) + writeToolItem(&b, fmt.Sprintf("ƒ %s(%s)", fc.Name, fc.Arguments), wrapWidth) case *agent.ChatContext_ChatItem_FunctionCallOutput: if !m.showToolOutput { continue @@ -1901,25 +1900,6 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { return b.String() } -// toolPreviewLen is how much of a tool call's arguments the transcript shows — -// enough to tell two calls apart without a lookup table's worth of JSON burying -// the conversation. Tool outputs are shown whole, or not at all. -const toolPreviewLen = 80 - -// clipToolValue is a tool argument blob as the transcript carries it. -func clipToolValue(s string) string { - if len(s) <= toolPreviewLen { - return s - } - // clip on a rune boundary; arguments carry guest names, currency symbols, - // and quoted speech - clipped := s[:toolPreviewLen] - for len(clipped) > 0 && !utf8.ValidString(clipped) { - clipped = clipped[:len(clipped)-1] - } - return clipped + "..." -} - // 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. From 3360d436964c1a22e724ed13b2b021cb351423eb Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Thu, 6 Aug 2026 15:39:53 -0400 Subject: [PATCH 10/11] refactor(simulate): name the tool toggle for detail, not output --- cmd/lk/simulate_tui.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index b7025b8d..3e415e6f 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -227,7 +227,7 @@ type simulateModel struct { 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 + showToolDetail bool logScrollOff int logPinned bool logPinnedTotal int @@ -900,7 +900,7 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.logPinned = false case "t": if m.detailJobID != "" { - m.showToolOutput = !m.showToolOutput + m.showToolDetail = !m.showToolDetail } case "d": if m.detailJobID == "" && m.hasDescription() { @@ -1876,7 +1876,7 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { ensureAgentBlock() writeToolItem(&b, fmt.Sprintf("ƒ %s(%s)", fc.Name, fc.Arguments), wrapWidth) case *agent.ChatContext_ChatItem_FunctionCallOutput: - if !m.showToolOutput { + if !m.showToolDetail { continue } fco := v.FunctionCallOutput @@ -1914,9 +1914,9 @@ func writeToolItem(b *strings.Builder, text string, wrapWidth int) { } } -// hasToolOutput reports whether the open job's transcript holds a tool output, +// hasToolDetail 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 { +func (m *simulateModel) hasToolDetail(jobID string) bool { if m.summary == nil || m.summary.ChatHistory == nil { return false } @@ -2053,8 +2053,8 @@ 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 { + if m.hasToolDetail(m.detailJobID) { + if m.showToolDetail { parts = append(parts, "t hide tool output") } else { parts = append(parts, "t show tool output") From 0ffee71bccac13a63379458d8747a8b509fe2d05 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Thu, 6 Aug 2026 15:41:09 -0400 Subject: [PATCH 11/11] feat(simulate): collapse tool arguments until t is pressed --- cmd/lk/simulate_tui.go | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 3e415e6f..d7c57217 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -225,8 +225,9 @@ type simulateModel struct { 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 + // 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 @@ -1874,7 +1875,7 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { case *agent.ChatContext_ChatItem_FunctionCall: fc := v.FunctionCall ensureAgentBlock() - writeToolItem(&b, fmt.Sprintf("ƒ %s(%s)", fc.Name, fc.Arguments), wrapWidth) + writeToolItem(&b, fmt.Sprintf("ƒ %s(%s)", fc.Name, m.toolArguments(fc.Arguments)), wrapWidth) case *agent.ChatContext_ChatItem_FunctionCallOutput: if !m.showToolDetail { continue @@ -1900,6 +1901,20 @@ 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. @@ -1914,8 +1929,9 @@ func writeToolItem(b *strings.Builder, text string, wrapWidth int) { } } -// hasToolDetail reports whether the open job's transcript holds a tool output, -// so the hint is only offered when the toggle would show something. +// 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 @@ -1925,7 +1941,12 @@ func (m *simulateModel) hasToolDetail(jobID string) bool { return false } for _, item := range chatCtx.Items { - if v, ok := item.Item.(*agent.ChatContext_ChatItem_FunctionCallOutput); ok { + 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 } @@ -2055,9 +2076,9 @@ func (m *simulateModel) renderHint() string { parts = append(parts, "c copy scenario · ←/ESC back to list") if m.hasToolDetail(m.detailJobID) { if m.showToolDetail { - parts = append(parts, "t hide tool output") + parts = append(parts, "t hide tool detail") } else { - parts = append(parts, "t show tool output") + parts = append(parts, "t show tool detail") } } if m.hasLogs() {