From 2ff39b159d2d1ec25094d94113699b64625138ca Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Mon, 20 Jul 2026 18:53:02 -0400 Subject: [PATCH 1/2] simulate: attribute tool calls and handoffs to the agent in TUI transcript Function calls, tool outputs, and handoffs appear in the chat history after the user message that triggered them, so they rendered under the You header. Open an Agent block for them instead. --- cmd/lk/simulate_tui.go | 41 ++++++++++++---- cmd/lk/simulate_tui_test.go | 93 +++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 cmd/lk/simulate_tui_test.go diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 14d0a9e4..9f3796dc 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -1648,6 +1648,22 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { } wrapStyle := lipgloss.NewStyle().Width(wrapWidth) + // Tool calls, tool outputs, and handoffs are agent actions, but appear in + // the chat history after the user message that triggered them and before + // the agent's spoken reply. Open an Agent block for them when needed so + // they don't render under the user's header. + currentSpeaker := "" + toolOpenedAgentBlock := false + ensureAgentBlock := func() { + if currentSpeaker == "agent" { + return + } + b.WriteString("\n") + fmt.Fprintf(&b, " %s\n", agentStyle.Render("Agent")) + currentSpeaker = "agent" + toolOpenedAgentBlock = true + } + for _, item := range chatCtx.Items { switch v := item.Item.(type) { case *agent.ChatContext_ChatItem_Message: @@ -1656,15 +1672,21 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { if text == "" { continue } - b.WriteString("\n") - switch msg.Role { - case agent.ChatRole_USER: - fmt.Fprintf(&b, " %s\n", userStyle.Render("You")) - case agent.ChatRole_ASSISTANT: - fmt.Fprintf(&b, " %s\n", agentStyle.Render("Agent")) - default: - fmt.Fprintf(&b, " %s\n", dimStyle.Render(string(msg.Role))) + if msg.Role != agent.ChatRole_ASSISTANT || !toolOpenedAgentBlock { + b.WriteString("\n") + switch msg.Role { + case agent.ChatRole_USER: + fmt.Fprintf(&b, " %s\n", userStyle.Render("You")) + currentSpeaker = "user" + case agent.ChatRole_ASSISTANT: + fmt.Fprintf(&b, " %s\n", agentStyle.Render("Agent")) + currentSpeaker = "agent" + default: + fmt.Fprintf(&b, " %s\n", dimStyle.Render(string(msg.Role))) + currentSpeaker = string(msg.Role) + } } + toolOpenedAgentBlock = false for line := range strings.SplitSeq(wrapStyle.Render(text), "\n") { b.WriteString(" " + line + "\n") } @@ -1674,6 +1696,7 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { if len(args) > 80 { args = args[:80] + "..." } + ensureAgentBlock() b.WriteString(dimStyle.Render(fmt.Sprintf(" ƒ %s(%s)", fc.Name, args))) b.WriteString("\n") case *agent.ChatContext_ChatItem_FunctionCallOutput: @@ -1685,6 +1708,7 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { if len(output) > 80 { output = output[:80] + "..." } + ensureAgentBlock() b.WriteString(dimStyle.Render(fmt.Sprintf(" → %s", output))) b.WriteString("\n") case *agent.ChatContext_ChatItem_AgentHandoff: @@ -1693,6 +1717,7 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { if h.OldAgentId != nil && *h.OldAgentId != "" { old = *h.OldAgentId + " → " } + ensureAgentBlock() b.WriteString(dimStyle.Render(fmt.Sprintf(" ⤳ %s%s", old, h.NewAgentId))) b.WriteString("\n") } diff --git a/cmd/lk/simulate_tui_test.go b/cmd/lk/simulate_tui_test.go new file mode 100644 index 00000000..eecf85e4 --- /dev/null +++ b/cmd/lk/simulate_tui_test.go @@ -0,0 +1,93 @@ +// 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 ( + "strings" + "testing" + + "github.com/livekit/protocol/livekit" + agent "github.com/livekit/protocol/livekit/agent" + "github.com/stretchr/testify/require" +) + +func chatMsg(role agent.ChatRole, text string) *agent.ChatContext_ChatItem { + return &agent.ChatContext_ChatItem{ + Item: &agent.ChatContext_ChatItem_Message{ + Message: &agent.ChatMessage{ + Role: role, + Content: []*agent.ChatMessage_ChatContent{ + {Payload: &agent.ChatMessage_ChatContent_Text{Text: text}}, + }, + }, + }, + } +} + +// speakerOfLine returns the nearest speaker header ("You"/"Agent") above the +// first transcript line containing needle. +func speakerOfLine(t *testing.T, transcript, needle string) string { + t.Helper() + lines := strings.Split(transcript, "\n") + idx := -1 + for i, line := range lines { + if strings.Contains(line, needle) { + idx = i + break + } + } + require.NotEqual(t, -1, idx, "line %q not found in transcript:\n%s", needle, transcript) + for i := idx - 1; i >= 0; i-- { + switch strings.TrimSpace(lines[i]) { + case "You", "Agent": + return strings.TrimSpace(lines[i]) + } + } + t.Fatalf("no speaker header above %q in transcript:\n%s", needle, transcript) + return "" +} + +func TestRenderChatTranscript_ToolCallsAttributedToAgent(t *testing.T) { + oldAgent := "get_email_task" + m := &simulateModel{ + width: 100, + summary: &livekit.SimulationRunSummary{ + ChatHistory: map[string]*agent.ChatContext{ + "job1": {Items: []*agent.ChatContext_ChatItem{ + chatMsg(agent.ChatRole_ASSISTANT, "Could you please provide your email address?"), + // The agent's tool call and handoff happen after the user + // message and before the agent's spoken reply. + chatMsg(agent.ChatRole_USER, "Do you really need that? It's a@b.com."), + {Item: &agent.ChatContext_ChatItem_FunctionCall{ + FunctionCall: &agent.FunctionCall{Name: "update_email_address", Arguments: `{"email":"a@b.com"}`}, + }}, + {Item: &agent.ChatContext_ChatItem_AgentHandoff{ + AgentHandoff: &agent.AgentHandoff{OldAgentId: &oldAgent, NewAgentId: "front_desk_agent"}, + }}, + chatMsg(agent.ChatRole_ASSISTANT, "Got it, you're all set."), + }}, + }, + }, + } + + out := m.renderChatTranscript("job1") + + require.Equal(t, "Agent", speakerOfLine(t, out, "ƒ update_email_address"), + "tool calls are agent actions and must not render under the user's block") + require.Equal(t, "Agent", speakerOfLine(t, out, "⤳ get_email_task → front_desk_agent"), + "handoffs are agent actions and must not render under the user's block") + require.Equal(t, "You", speakerOfLine(t, out, "Do you really need that?")) + require.Equal(t, "Agent", speakerOfLine(t, out, "Got it, you're all set.")) +} From 93b42aede66297cb1a35740a82c715efa5904c66 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 24 Jul 2026 10:45:04 -0400 Subject: [PATCH 2/2] simulate: remove TUI transcript test --- cmd/lk/simulate_tui_test.go | 93 ------------------------------------- 1 file changed, 93 deletions(-) delete mode 100644 cmd/lk/simulate_tui_test.go diff --git a/cmd/lk/simulate_tui_test.go b/cmd/lk/simulate_tui_test.go deleted file mode 100644 index eecf85e4..00000000 --- a/cmd/lk/simulate_tui_test.go +++ /dev/null @@ -1,93 +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 ( - "strings" - "testing" - - "github.com/livekit/protocol/livekit" - agent "github.com/livekit/protocol/livekit/agent" - "github.com/stretchr/testify/require" -) - -func chatMsg(role agent.ChatRole, text string) *agent.ChatContext_ChatItem { - return &agent.ChatContext_ChatItem{ - Item: &agent.ChatContext_ChatItem_Message{ - Message: &agent.ChatMessage{ - Role: role, - Content: []*agent.ChatMessage_ChatContent{ - {Payload: &agent.ChatMessage_ChatContent_Text{Text: text}}, - }, - }, - }, - } -} - -// speakerOfLine returns the nearest speaker header ("You"/"Agent") above the -// first transcript line containing needle. -func speakerOfLine(t *testing.T, transcript, needle string) string { - t.Helper() - lines := strings.Split(transcript, "\n") - idx := -1 - for i, line := range lines { - if strings.Contains(line, needle) { - idx = i - break - } - } - require.NotEqual(t, -1, idx, "line %q not found in transcript:\n%s", needle, transcript) - for i := idx - 1; i >= 0; i-- { - switch strings.TrimSpace(lines[i]) { - case "You", "Agent": - return strings.TrimSpace(lines[i]) - } - } - t.Fatalf("no speaker header above %q in transcript:\n%s", needle, transcript) - return "" -} - -func TestRenderChatTranscript_ToolCallsAttributedToAgent(t *testing.T) { - oldAgent := "get_email_task" - m := &simulateModel{ - width: 100, - summary: &livekit.SimulationRunSummary{ - ChatHistory: map[string]*agent.ChatContext{ - "job1": {Items: []*agent.ChatContext_ChatItem{ - chatMsg(agent.ChatRole_ASSISTANT, "Could you please provide your email address?"), - // The agent's tool call and handoff happen after the user - // message and before the agent's spoken reply. - chatMsg(agent.ChatRole_USER, "Do you really need that? It's a@b.com."), - {Item: &agent.ChatContext_ChatItem_FunctionCall{ - FunctionCall: &agent.FunctionCall{Name: "update_email_address", Arguments: `{"email":"a@b.com"}`}, - }}, - {Item: &agent.ChatContext_ChatItem_AgentHandoff{ - AgentHandoff: &agent.AgentHandoff{OldAgentId: &oldAgent, NewAgentId: "front_desk_agent"}, - }}, - chatMsg(agent.ChatRole_ASSISTANT, "Got it, you're all set."), - }}, - }, - }, - } - - out := m.renderChatTranscript("job1") - - require.Equal(t, "Agent", speakerOfLine(t, out, "ƒ update_email_address"), - "tool calls are agent actions and must not render under the user's block") - require.Equal(t, "Agent", speakerOfLine(t, out, "⤳ get_email_task → front_desk_agent"), - "handoffs are agent actions and must not render under the user's block") - require.Equal(t, "You", speakerOfLine(t, out, "Do you really need that?")) - require.Equal(t, "Agent", speakerOfLine(t, out, "Got it, you're all set.")) -}