| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277 |
- 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)
- }
- }
- }
- }
|