diff --git a/pkg/agent/loop_execute.go b/pkg/agent/loop_execute.go index 416d06a..9ee6b2f 100644 --- a/pkg/agent/loop_execute.go +++ b/pkg/agent/loop_execute.go @@ -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 } } @@ -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) diff --git a/pkg/agent/tool_call_correlation_test.go b/pkg/agent/tool_call_correlation_test.go new file mode 100644 index 0000000..67a66d5 --- /dev/null +++ b/pkg/agent/tool_call_correlation_test.go @@ -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) + } +} diff --git a/pkg/history/types.go b/pkg/history/types.go index ca3f1bf..01a86df 100644 --- a/pkg/history/types.go +++ b/pkg/history/types.go @@ -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"}`