From e86e74bc36185c8531df161eb57666b5dad72bc9 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 02:22:34 +0000 Subject: [PATCH] Preserve streamed tool delta identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Backend emitted tool deltas from a separate goroutine, racing with serialized run events and occasionally losing a UI delta. Evidence: Backend race detector reproduced concurrent event writes; Agent-Native identity test RED→GREEN; full vet and tests pass. Impact: runtime tool delta events now carry provider slot and call ID for single-owner downstream projection. Refs: runtime/loop.go; runtime/event_vocabulary_test.go --- runtime/event_vocabulary_test.go | 21 ++++++++++++++++++++- runtime/loop.go | 7 +++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/runtime/event_vocabulary_test.go b/runtime/event_vocabulary_test.go index ad2a279..bad9e2d 100644 --- a/runtime/event_vocabulary_test.go +++ b/runtime/event_vocabulary_test.go @@ -39,7 +39,7 @@ func vocabScript() llm.StreamFunc { step++ ch <- llm.Event{Kind: llm.KindReasoningDelta, ReasoningDelta: "思考"} ch <- llm.Event{Kind: llm.KindTextDelta, Text: "正文"} - ch <- llm.Event{Kind: llm.KindToolCallDelta, ToolName: "list_dir", ArgumentsDelta: `{"path":"."}`} + ch <- llm.Event{Kind: llm.KindToolCallDelta, ToolIndex: 2, ToolID: "c1", ToolName: "list_dir", ArgumentsDelta: `{"path":"."}`} ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{ Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "list_dir", Arguments: `{"path":"."}`}}, @@ -54,6 +54,25 @@ func vocabScript() llm.StreamFunc { } } +func TestLoopPreservesToolCallDeltaIdentity(t *testing.T) { + reg := tools.WorkspaceTools(t.TempDir()) + var got Event + _, err := Run(context.Background(), Config{ + Model: "fake", Tools: reg, Stream: vocabScript(), MaxTurns: 4, + OnEvent: func(ev Event) { + if ev.Type == "tool_call_delta" { + got = ev + } + }, + }, llm.Message{Role: llm.RoleUser, Content: "run"}) + if err != nil { + t.Fatal(err) + } + if got.ToolIndex != 2 || got.CallID != "c1" || got.Tool != "list_dir" || got.Detail != `{"path":"."}` { + t.Fatalf("tool delta lost its provider identity: %#v", got) + } +} + func TestLoopEmitsProcessVocabulary(t *testing.T) { reg := tools.WorkspaceTools(t.TempDir()) var observed []string diff --git a/runtime/loop.go b/runtime/loop.go index f4b45fc..8caab97 100644 --- a/runtime/loop.go +++ b/runtime/loop.go @@ -38,8 +38,10 @@ type Event struct { Message llm.Message Tool string Text string + // ToolIndex preserves the provider's call slot for tool_call_delta. + ToolIndex int // CallID 在 tool_execution_start / tool_execution_end 时携带工具调用的 - // ID:并行执行下同回合多个调用并发进出,消费方按 ID 配对而非按顺序。 + // ID;tool_call_delta 也携带同一 ID。消费方按 ID 配对而非按顺序。 CallID string // Detail carries tool arguments/results or reasoning deltas for // process-visualization consumers; log writers may ignore it. @@ -535,7 +537,8 @@ func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event case llm.KindReasoningDelta: emit(cfg, Event{Type: "reasoning_delta", Text: ev.ReasoningDelta}) case llm.KindToolCallDelta: - emit(cfg, Event{Type: "tool_call_delta", Tool: ev.ToolName, Detail: ev.ArgumentsDelta}) + emit(cfg, Event{Type: "tool_call_delta", ToolIndex: ev.ToolIndex, + CallID: ev.ToolID, Tool: ev.ToolName, Detail: ev.ArgumentsDelta}) case llm.KindError: return ev, ev.Err case llm.KindMessageEnd: