Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app

import (
"cmp"
"context"
"encoding/base64"
"errors"
Expand Down Expand Up @@ -997,12 +998,24 @@ func (a *App) mergeEvents(events []tea.Msg) []tea.Msg {
result = append(result, merged)

case *runtime.PartialToolCallEvent:
// For PartialToolCallEvent, keep only the latest one per tool call ID
// Only merge consecutive events with the same ID
// For PartialToolCallEvent, merge consecutive events with the same tool call ID
// by concatenating argument deltas
latest := ev
for i+1 < len(events) {
if next, ok := events[i+1].(*runtime.PartialToolCallEvent); ok && next.ToolCall.ID == ev.ToolCall.ID {
latest = next
latest = &runtime.PartialToolCallEvent{
Type: ev.Type,
ToolCall: tools.ToolCall{
ID: ev.ToolCall.ID,
Type: ev.ToolCall.Type,
Function: tools.FunctionCall{
Name: cmp.Or(next.ToolCall.Function.Name, latest.ToolCall.Function.Name),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH: Function name lost during consecutive PartialToolCallEvent merges

The cmp.Or(next.ToolCall.Function.Name, latest.ToolCall.Function.Name) logic is incorrect for merging delta events.

The Problem:

  • The first event ev contains the full function name (accumulated from streaming.go)
  • Subsequent events next have empty names because they're just argument deltas
  • cmp.Or returns the first non-empty value, preferring next.ToolCall.Function.Name (empty) over latest.ToolCall.Function.Name
  • This causes the function name to be lost after the first merge iteration

The Fix:
Use ev.ToolCall.Function.Name (from the first event in the merge chain) instead:

Name: ev.ToolCall.Function.Name,

This preserves the name learned from the first event throughout all subsequent merges.

Arguments: latest.ToolCall.Function.Arguments + next.ToolCall.Function.Arguments,
},
},
ToolDefinition: next.ToolDefinition,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM: Inconsistent AgentContext and ToolDefinition sources in merged event

The merged event uses AgentContext from the first event but ToolDefinition from the last event, creating an inconsistency.

The Issue:

AgentContext:   ev.AgentContext,      // from first event
ToolDefinition: next.ToolDefinition,  // from last event

This means the timestamp/context metadata comes from when the tool call was initiated, but the tool definition comes from the last delta event. While tool definitions are unlikely to change mid-stream, this violates consistency and could cause subtle bugs if tool definitions are updated dynamically.

Recommendation:
For consistency, use the tool definition from the first event:

ToolDefinition: ev.ToolDefinition,

AgentContext: ev.AgentContext,
}
i++
} else {
break
Expand Down
15 changes: 13 additions & 2 deletions pkg/runtime/streaming.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,21 @@ func (r *LocalRuntime) handleStream(ctx context.Context, stream chat.MessageStre
tc.Function.Arguments += delta.Function.Arguments
}

// Emit PartialToolCall once we have a name, and on subsequent argument deltas
// Emit PartialToolCall once we have a name, and on subsequent argument deltas.
// Only the current token (delta.Function.Arguments) is sent, not the
// full accumulated arguments, to avoid re-transmitting the entire
// payload on every token.
if tc.Function.Name != "" && (learningName || delta.Function.Arguments != "") {
if !emittedPartial[delta.ID] || delta.Function.Arguments != "" {
events <- PartialToolCall(*tc, toolDefMap[tc.Function.Name], a.Name())
partial := tools.ToolCall{
ID: tc.ID,
Type: tc.Type,
Function: tools.FunctionCall{
Name: tc.Function.Name,
Arguments: delta.Function.Arguments,
},
}
events <- PartialToolCall(partial, toolDefMap[tc.Function.Name], a.Name())
emittedPartial[delta.ID] = true
}
}
Expand Down
6 changes: 5 additions & 1 deletion pkg/tui/components/messages/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -1301,7 +1301,11 @@ func (m *model) AddOrUpdateToolCall(agentName string, toolCall tools.ToolCall, t
if msg.Type == types.MessageTypeToolCall && msg.ToolCall.ID == toolCall.ID {
msg.ToolStatus = status
if toolCall.Function.Arguments != "" {
msg.ToolCall.Function.Arguments = toolCall.Function.Arguments
if status == types.ToolStatusPending {
msg.ToolCall.Function.Arguments += toolCall.Function.Arguments
} else {
msg.ToolCall.Function.Arguments = toolCall.Function.Arguments
}
}
m.invalidateItem(i)
return nil
Expand Down
6 changes: 5 additions & 1 deletion pkg/tui/components/reasoningblock/reasoningblock.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,11 @@ func (m *Model) UpdateToolCall(toolCallID string, status types.ToolStatus, args
}
entry.msg.ToolStatus = status
if args != "" {
entry.msg.ToolCall.Function.Arguments = args
if status == types.ToolStatusPending {
entry.msg.ToolCall.Function.Arguments += args
} else {
entry.msg.ToolCall.Function.Arguments = args
}
}
m.toolEntries[i] = entry
return
Expand Down
Loading