test: add regression tests for parallel tool_use ghost delta (#4193)

Three tests covering the CC→Responses→Anthropic finalize path:
- TestStreamingParallelToolUseNoGhostDelta: full end-to-end with two
  parallel tool_calls, asserts every content_block_delta targets a
  started block
- TestStreamingParallelToolUseSecondToolPackedArgsDone: focused test
  where tool 1 streams deltas and tool 2 has packed .done args
- TestStreamingThreeParallelToolsAllPackedDone: three tools all with
  packed .done, the most extreme case

All three fail on unfixed code (ghost deltas on wrong indices) and
pass after the fix.
This commit is contained in:
蔡及
2026-07-15 00:47:04 +08:00
parent 4f3b5110e2
commit 07732a5a35
@@ -0,0 +1,277 @@
package apicompat
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func intPtr(v int) *int { return &v }
func strPtr(s string) *string { return &s }
// TestStreamingParallelToolUseNoGhostDelta reproduces the bug from issue #4193:
// when the CC→Responses→Anthropic bridge finalizes parallel tool calls whose
// arguments arrived packed in function_call_arguments.done (no prior delta),
// resToAnthHandleFuncArgsDone used state.ContentBlockIndex directly instead of
// looking up OutputIndexToBlockIdx. After the first tool's block was closed
// (ContentBlockIndex++), the second tool's .done emitted a content_block_delta
// on an index that was never content_block_start'ed — Claude Code reports
// "Content block not found".
//
// This test drives the full finalize path: CC chunks with two parallel
// tool_calls → ChatCompletionsChunkToResponsesEvents → FinalizeChatCompletionsResponsesStream
// → ResponsesEventToAnthropicEvents, and asserts every content_block_delta
// targets a block that was previously content_block_start'ed.
func TestStreamingParallelToolUseNoGhostDelta(t *testing.T) {
ccState := NewChatCompletionsToResponsesStreamState("glm-5.2")
anthropicState := NewResponsesEventToAnthropicState()
anthropicState.Model = "glm-5.2"
// Chunk 1: first tool_call arrives with id + name + packed arguments.
chatChunk1 := &ChatCompletionsChunk{
ID: "chatcmpl-1",
Model: "glm-5.2",
Choices: []ChatChunkChoice{{
Index: 0,
Delta: ChatDelta{
ToolCalls: []ChatToolCall{{
Index: intPtr(0),
ID: "call_weather",
Type: "function",
Function: ChatFunctionCall{
Name: "get_weather",
Arguments: `{"city":"Tokyo"}`,
},
}},
},
}},
}
// Chunk 2: second tool_call arrives with id + name + packed arguments.
chatChunk2 := &ChatCompletionsChunk{
ID: "chatcmpl-1",
Model: "glm-5.2",
Choices: []ChatChunkChoice{{
Index: 0,
Delta: ChatDelta{
ToolCalls: []ChatToolCall{{
Index: intPtr(1),
ID: "call_time",
Type: "function",
Function: ChatFunctionCall{
Name: "get_time",
Arguments: `{}`,
},
}},
},
}},
}
// Chunk 3: finish.
chatChunk3 := &ChatCompletionsChunk{
ID: "chatcmpl-1",
Model: "glm-5.2",
Choices: []ChatChunkChoice{{
Index: 0,
Delta: ChatDelta{},
FinishReason: strPtr("tool_calls"),
}},
}
// Feed chunks through the CC→Responses bridge, then through Responses→Anthropic.
var allAnthropicEvents []AnthropicStreamEvent
for _, chunk := range []*ChatCompletionsChunk{chatChunk1, chatChunk2, chatChunk3} {
responsesEvents := ChatCompletionsChunkToResponsesEvents(chunk, ccState)
for _, rEvent := range responsesEvents {
allAnthropicEvents = append(allAnthropicEvents, ResponsesEventToAnthropicEvents(&rEvent, anthropicState)...)
}
}
// Finalize: closeChatToolItems emits function_call_arguments.done for each tool.
finalResponsesEvents := FinalizeChatCompletionsResponsesStream(ccState)
for _, rEvent := range finalResponsesEvents {
allAnthropicEvents = append(allAnthropicEvents, ResponsesEventToAnthropicEvents(&rEvent, anthropicState)...)
}
// Build the set of block indices that were content_block_start'ed.
startedBlocks := make(map[int]string) // index → block type
for _, e := range allAnthropicEvents {
if e.Type == "content_block_start" && e.ContentBlock != nil {
startedBlocks[*e.Index] = e.ContentBlock.Type
}
}
// Assert: every content_block_delta targets a started block (no ghost deltas).
for _, e := range allAnthropicEvents {
if e.Type != "content_block_delta" || e.Index == nil {
continue
}
idx := *e.Index
_, ok := startedBlocks[idx]
require.Truef(t, ok,
"content_block_delta on index %d which was never content_block_start'ed (ghost delta bug #4193)", idx)
}
// Assert: every content_block_stop targets a started block.
for _, e := range allAnthropicEvents {
if e.Type != "content_block_stop" || e.Index == nil {
continue
}
idx := *e.Index
_, ok := startedBlocks[idx]
require.Truef(t, ok,
"content_block_stop on index %d which was never content_block_start'ed", idx)
}
// Assert: both tool_use blocks were opened.
var toolUseBlocks []int
for idx, blockType := range startedBlocks {
if blockType == "tool_use" {
toolUseBlocks = append(toolUseBlocks, idx)
}
}
assert.Len(t, toolUseBlocks, 2, "both parallel tool_use blocks should be opened")
// Assert: stop_reason is tool_use.
var sawMessageDelta bool
for _, e := range allAnthropicEvents {
if e.Type == "message_delta" {
sawMessageDelta = true
assert.Equal(t, "tool_use", e.Delta.StopReason)
}
}
assert.True(t, sawMessageDelta, "message_delta should be emitted")
}
// TestStreamingParallelToolUseSecondToolPackedArgsDone is a focused unit test
// for the exact bug: two tools, the first streams its arguments via deltas
// (CurrentToolHadDelta=true → closeCurrentBlock), the second has arguments
// packed only in .done (CurrentToolHadDelta=false). Before the fix, the second
// tool's .done emitted content_block_delta on state.ContentBlockIndex which
// had already been incremented past the second tool's block.
func TestStreamingParallelToolUseSecondToolPackedArgsDone(t *testing.T) {
state := NewResponsesEventToAnthropicState()
// response.created
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.created",
Response: &ResponsesResponse{ID: "resp_par", Model: "glm-5.2"},
}, state)
// Tool 1: output_item.added (index 0)
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_a", Name: "tool_a"},
}, state)
// Tool 1: arguments streamed via delta (CurrentToolHadDelta = true)
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.function_call_arguments.delta",
OutputIndex: 0,
Delta: `{"x":1}`,
}, state)
// Tool 1: arguments.done → CurrentToolHadDelta=true → closeCurrentBlock
// ContentBlockIndex goes from 0 → 1
eventsTool1Done := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 0,
Arguments: `{"x":1}`,
}, state)
// Should only emit content_block_stop (delta already streamed)
for _, e := range eventsTool1Done {
assert.NotEqual(t, "content_block_delta", e.Type,
"tool 1 .done should not re-emit delta (args already streamed)")
}
// Tool 2: output_item.added (index 1) → opens block at ContentBlockIndex=1
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 1,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_b", Name: "tool_b"},
}, state)
// Tool 2: NO delta — arguments arrive packed in .done only.
// This is the exact scenario that triggered the ghost delta bug.
eventsTool2Done := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 1,
Arguments: `{"y":2}`,
}, state)
// The fix ensures the delta targets index 1 (the tool 2 block), not
// state.ContentBlockIndex (which would be 1 before close, matching by
// luck in this 2-tool case, but wrong in 3+ tool cases or when the
// block is already closed).
//
// The critical assertion: the delta must be on index 1 and must be
// followed by content_block_stop on the same index.
var sawDelta, sawStop bool
var deltaIndex, stopIndex int
for _, e := range eventsTool2Done {
if e.Type == "content_block_delta" && e.Index != nil {
sawDelta = true
deltaIndex = *e.Index
assert.Equal(t, "input_json_delta", e.Delta.Type)
assert.Equal(t, `{"y":2}`, e.Delta.PartialJSON)
}
if e.Type == "content_block_stop" && e.Index != nil {
sawStop = true
stopIndex = *e.Index
}
}
assert.True(t, sawDelta, "tool 2 .done with packed args should emit content_block_delta")
assert.True(t, sawStop, "tool 2 .done should close the block")
assert.Equal(t, 1, deltaIndex, "delta must target the tool 2 block (index 1)")
assert.Equal(t, deltaIndex, stopIndex, "delta and stop must be on the same block")
}
// TestStreamingThreeParallelToolsAllPackedDone tests the most extreme case:
// three parallel tools, ALL with arguments packed in .done (no deltas at all).
// Before the fix, tool 2 and tool 3 would emit ghost deltas on wrong indices.
func TestStreamingThreeParallelToolsAllPackedDone(t *testing.T) {
state := NewResponsesEventToAnthropicState()
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.created",
Response: &ResponsesResponse{ID: "resp_3par", Model: "glm-5.2"},
}, state)
// Open three tool blocks at indices 0, 1, 2.
for i, name := range []string{"tool_a", "tool_b", "tool_c"} {
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: i,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_" + name, Name: name},
}, state)
}
// Track started blocks.
started := map[int]bool{0: true, 1: true, 2: true}
// All three .done events with packed arguments (no prior delta).
for i, args := range []string{`{"a":1}`, `{"b":2}`, `{"c":3}`} {
events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: i,
Arguments: args,
}, state)
for _, e := range events {
if e.Type == "content_block_delta" && e.Index != nil {
idx := *e.Index
require.Truef(t, started[idx],
"ghost delta: tool %d .done emitted content_block_delta on index %d (never started)", i, idx)
require.Equal(t, i, idx,
"tool %d .done delta should target its own block index %d, got %d", i, i, idx)
}
if e.Type == "content_block_stop" && e.Index != nil {
idx := *e.Index
require.Truef(t, started[idx],
"ghost stop: tool %d .done emitted content_block_stop on index %d (never started)", i, idx)
}
}
}
}