Skip to content
Merged
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
3 changes: 2 additions & 1 deletion pkg/agent/loop_execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ func (al *AgentLoop) executeToolCall(ctx context.Context, st *iterationState, ws
if cacheOK {
if cached, hit := al.Cache.Get(cacheKey); hit {
al.emit(ctx, st.sessionKey, st.streamChan, Event(ThoughtEvent{Message: fmt.Sprintf("Cache hit for %s, skipping execution.", tCall.Name)}))
ws.recordToolMsg(tCall.ID, history.Message{Role: "tool", Content: cached, ToolCallID: tCall.ID}, true)
ws.recordToolMsg(tCall.ID, history.Message{Role: "tool", Content: cached, ToolCallID: tCall.ID, CorrelationID: callID}, true)
return
}
}
Expand Down Expand Up @@ -264,6 +264,7 @@ func (al *AgentLoop) executeToolCall(ctx context.Context, st *iterationState, ws
Role: "tool",
Content: content,
ToolCallID: tCall.ID,
CorrelationID: callID,
IsError: isToolErr,
IsInlineResult: isInlineResult,
}, !isToolErr)
Expand Down
57 changes: 57 additions & 0 deletions pkg/agent/tool_call_correlation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package agent

import (
"context"
"sync"
"testing"
)

// TestToolCall_PersistedRowCarriesCorrelationID asserts the live
// ToolCallEvent.ID (the opaque per-dispatch correlation ID) is persisted on
// the tool-result row as CorrelationID, so a consumer rendering an inline
// artifact card can re-match it after a session reload. Before this fix the
// live event carried newToolCallID() while the persisted row carried only
// the provider tool-call ID (tCall.ID) — two disjoint namespaces with no
// bridge, so reload-stable cards were impossible.
func TestToolCall_PersistedRowCarriesCorrelationID(t *testing.T) {
provider := &scriptProvider{turns: []LLMResult{
{ToolCalls: []PendingToolCall{{ID: "c1", Name: "plain", ArgsJSON: "{}"}}},
{Content: "final"},
}}
loop, sm := setup(provider, plainTool{})

var mu sync.Mutex
var liveID string
loop.EventHandlers = append(loop.EventHandlers, func(_ context.Context, _ string, ev StreamEvent) {
if tc, ok := ev.Payload.(ToolCallEvent); ok {
mu.Lock()
liveID = tc.ID
mu.Unlock()
}
})

if _, err := loop.RunIteration(context.Background(), "s1", "go"); err != nil {
t.Fatalf("unexpected error: %v", err)
}

mu.Lock()
gotLive := liveID
mu.Unlock()
if gotLive == "" {
t.Fatal("no ToolCallEvent captured during the turn")
}

stored, _ := sm.History(context.Background(), "s1")
var found bool
for _, m := range stored {
if m.Role == "tool" && m.ToolCallID == "c1" {
if m.CorrelationID != gotLive {
t.Fatalf("tool row CorrelationID=%q, want live ToolCallEvent.ID %q", m.CorrelationID, gotLive)
}
found = true
}
}
if !found {
t.Fatalf("no tool row recorded — stored=%+v", stored)
}
}
11 changes: 11 additions & 0 deletions pkg/history/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ type Message struct {
// across tool names; adopters no longer need a hand-curated allowlist.
IsInlineResult bool `json:"is_inline_result,omitempty"`

// CorrelationID is the opaque per-dispatch ID the agent loop generated
// for this tool call (newToolCallID) and streamed live on the matching
// ToolCallEvent.ID / ToolProgressEvent.ToolCallID. ToolCallID above holds
// the provider's tool-call ID, which is load-bearing for tool_use /
// tool_result adjacency but is NOT what live events expose (and which some
// providers reuse across parallel same-name calls). CorrelationID gives
// adopters a single stable handle to re-match a live-rendered artifact card
// to its tool row after a session reload. Empty for pre-correlation
// sessions and for non-tool messages.
CorrelationID string `json:"correlation_id,omitempty"`

// CacheHint requests that LLM adapters mark this message as a prompt-cache
// breakpoint when the underlying provider supports it. The Anthropic
// adapter translates CacheHint=true into `cache_control:{type:"ephemeral"}`
Expand Down
Loading