Skip to content
17 changes: 14 additions & 3 deletions cmd/lk/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/lk/simulate_ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
78 changes: 0 additions & 78 deletions cmd/lk/simulate_test.go

This file was deleted.

80 changes: 62 additions & 18 deletions cmd/lk/simulate_tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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}
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 := ""
Expand All @@ -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 ""
Expand Down Expand Up @@ -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")
Expand Down
Loading