responses_to_anthropic_parallel_tool_test.go 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. package apicompat
  2. import (
  3. "testing"
  4. "github.com/stretchr/testify/assert"
  5. "github.com/stretchr/testify/require"
  6. )
  7. func intPtr(v int) *int { return &v }
  8. func strPtr(s string) *string { return &s }
  9. // TestStreamingParallelToolUseNoGhostDelta reproduces the bug from issue #4193:
  10. // when the CC→Responses→Anthropic bridge finalizes parallel tool calls whose
  11. // arguments arrived packed in function_call_arguments.done (no prior delta),
  12. // resToAnthHandleFuncArgsDone used state.ContentBlockIndex directly instead of
  13. // looking up OutputIndexToBlockIdx. After the first tool's block was closed
  14. // (ContentBlockIndex++), the second tool's .done emitted a content_block_delta
  15. // on an index that was never content_block_start'ed — Claude Code reports
  16. // "Content block not found".
  17. //
  18. // This test drives the full finalize path: CC chunks with two parallel
  19. // tool_calls → ChatCompletionsChunkToResponsesEvents → FinalizeChatCompletionsResponsesStream
  20. // → ResponsesEventToAnthropicEvents, and asserts every content_block_delta
  21. // targets a block that was previously content_block_start'ed.
  22. func TestStreamingParallelToolUseNoGhostDelta(t *testing.T) {
  23. ccState := NewChatCompletionsToResponsesStreamState("glm-5.2")
  24. anthropicState := NewResponsesEventToAnthropicState()
  25. anthropicState.Model = "glm-5.2"
  26. // Chunk 1: first tool_call arrives with id + name + packed arguments.
  27. chatChunk1 := &ChatCompletionsChunk{
  28. ID: "chatcmpl-1",
  29. Model: "glm-5.2",
  30. Choices: []ChatChunkChoice{{
  31. Index: 0,
  32. Delta: ChatDelta{
  33. ToolCalls: []ChatToolCall{{
  34. Index: intPtr(0),
  35. ID: "call_weather",
  36. Type: "function",
  37. Function: ChatFunctionCall{
  38. Name: "get_weather",
  39. Arguments: `{"city":"Tokyo"}`,
  40. },
  41. }},
  42. },
  43. }},
  44. }
  45. // Chunk 2: second tool_call arrives with id + name + packed arguments.
  46. chatChunk2 := &ChatCompletionsChunk{
  47. ID: "chatcmpl-1",
  48. Model: "glm-5.2",
  49. Choices: []ChatChunkChoice{{
  50. Index: 0,
  51. Delta: ChatDelta{
  52. ToolCalls: []ChatToolCall{{
  53. Index: intPtr(1),
  54. ID: "call_time",
  55. Type: "function",
  56. Function: ChatFunctionCall{
  57. Name: "get_time",
  58. Arguments: `{}`,
  59. },
  60. }},
  61. },
  62. }},
  63. }
  64. // Chunk 3: finish.
  65. chatChunk3 := &ChatCompletionsChunk{
  66. ID: "chatcmpl-1",
  67. Model: "glm-5.2",
  68. Choices: []ChatChunkChoice{{
  69. Index: 0,
  70. Delta: ChatDelta{},
  71. FinishReason: strPtr("tool_calls"),
  72. }},
  73. }
  74. // Feed chunks through the CC→Responses bridge, then through Responses→Anthropic.
  75. var allAnthropicEvents []AnthropicStreamEvent
  76. for _, chunk := range []*ChatCompletionsChunk{chatChunk1, chatChunk2, chatChunk3} {
  77. responsesEvents := ChatCompletionsChunkToResponsesEvents(chunk, ccState)
  78. for _, rEvent := range responsesEvents {
  79. allAnthropicEvents = append(allAnthropicEvents, ResponsesEventToAnthropicEvents(&rEvent, anthropicState)...)
  80. }
  81. }
  82. // Finalize: closeChatToolItems emits function_call_arguments.done for each tool.
  83. finalResponsesEvents := FinalizeChatCompletionsResponsesStream(ccState)
  84. for _, rEvent := range finalResponsesEvents {
  85. allAnthropicEvents = append(allAnthropicEvents, ResponsesEventToAnthropicEvents(&rEvent, anthropicState)...)
  86. }
  87. // Build the set of block indices that were content_block_start'ed.
  88. startedBlocks := make(map[int]string) // index → block type
  89. for _, e := range allAnthropicEvents {
  90. if e.Type == "content_block_start" && e.ContentBlock != nil {
  91. startedBlocks[*e.Index] = e.ContentBlock.Type
  92. }
  93. }
  94. // Assert: every content_block_delta targets a started block (no ghost deltas).
  95. for _, e := range allAnthropicEvents {
  96. if e.Type != "content_block_delta" || e.Index == nil {
  97. continue
  98. }
  99. idx := *e.Index
  100. _, ok := startedBlocks[idx]
  101. require.Truef(t, ok,
  102. "content_block_delta on index %d which was never content_block_start'ed (ghost delta bug #4193)", idx)
  103. }
  104. // Assert: every content_block_stop targets a started block.
  105. for _, e := range allAnthropicEvents {
  106. if e.Type != "content_block_stop" || e.Index == nil {
  107. continue
  108. }
  109. idx := *e.Index
  110. _, ok := startedBlocks[idx]
  111. require.Truef(t, ok,
  112. "content_block_stop on index %d which was never content_block_start'ed", idx)
  113. }
  114. // Assert: both tool_use blocks were opened.
  115. var toolUseBlocks []int
  116. for idx, blockType := range startedBlocks {
  117. if blockType == "tool_use" {
  118. toolUseBlocks = append(toolUseBlocks, idx)
  119. }
  120. }
  121. assert.Len(t, toolUseBlocks, 2, "both parallel tool_use blocks should be opened")
  122. // Assert: stop_reason is tool_use.
  123. var sawMessageDelta bool
  124. for _, e := range allAnthropicEvents {
  125. if e.Type == "message_delta" {
  126. sawMessageDelta = true
  127. assert.Equal(t, "tool_use", e.Delta.StopReason)
  128. }
  129. }
  130. assert.True(t, sawMessageDelta, "message_delta should be emitted")
  131. }
  132. // TestStreamingParallelToolUseSecondToolPackedArgsDone is a focused unit test
  133. // for the exact bug: two tools, the first streams its arguments via deltas
  134. // (CurrentToolHadDelta=true → closeCurrentBlock), the second has arguments
  135. // packed only in .done (CurrentToolHadDelta=false). Before the fix, the second
  136. // tool's .done emitted content_block_delta on state.ContentBlockIndex which
  137. // had already been incremented past the second tool's block.
  138. func TestStreamingParallelToolUseSecondToolPackedArgsDone(t *testing.T) {
  139. state := NewResponsesEventToAnthropicState()
  140. // response.created
  141. ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
  142. Type: "response.created",
  143. Response: &ResponsesResponse{ID: "resp_par", Model: "glm-5.2"},
  144. }, state)
  145. // Tool 1: output_item.added (index 0)
  146. ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
  147. Type: "response.output_item.added",
  148. OutputIndex: 0,
  149. Item: &ResponsesOutput{Type: "function_call", CallID: "call_a", Name: "tool_a"},
  150. }, state)
  151. // Tool 1: arguments streamed via delta (CurrentToolHadDelta = true)
  152. ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
  153. Type: "response.function_call_arguments.delta",
  154. OutputIndex: 0,
  155. Delta: `{"x":1}`,
  156. }, state)
  157. // Tool 1: arguments.done → CurrentToolHadDelta=true → closeCurrentBlock
  158. // ContentBlockIndex goes from 0 → 1
  159. eventsTool1Done := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
  160. Type: "response.function_call_arguments.done",
  161. OutputIndex: 0,
  162. Arguments: `{"x":1}`,
  163. }, state)
  164. // Should only emit content_block_stop (delta already streamed)
  165. for _, e := range eventsTool1Done {
  166. assert.NotEqual(t, "content_block_delta", e.Type,
  167. "tool 1 .done should not re-emit delta (args already streamed)")
  168. }
  169. // Tool 2: output_item.added (index 1) → opens block at ContentBlockIndex=1
  170. ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
  171. Type: "response.output_item.added",
  172. OutputIndex: 1,
  173. Item: &ResponsesOutput{Type: "function_call", CallID: "call_b", Name: "tool_b"},
  174. }, state)
  175. // Tool 2: NO delta — arguments arrive packed in .done only.
  176. // This is the exact scenario that triggered the ghost delta bug.
  177. eventsTool2Done := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
  178. Type: "response.function_call_arguments.done",
  179. OutputIndex: 1,
  180. Arguments: `{"y":2}`,
  181. }, state)
  182. // The fix ensures the delta targets index 1 (the tool 2 block), not
  183. // state.ContentBlockIndex (which would be 1 before close, matching by
  184. // luck in this 2-tool case, but wrong in 3+ tool cases or when the
  185. // block is already closed).
  186. //
  187. // The critical assertion: the delta must be on index 1 and must be
  188. // followed by content_block_stop on the same index.
  189. var sawDelta, sawStop bool
  190. var deltaIndex, stopIndex int
  191. for _, e := range eventsTool2Done {
  192. if e.Type == "content_block_delta" && e.Index != nil {
  193. sawDelta = true
  194. deltaIndex = *e.Index
  195. assert.Equal(t, "input_json_delta", e.Delta.Type)
  196. assert.Equal(t, `{"y":2}`, e.Delta.PartialJSON)
  197. }
  198. if e.Type == "content_block_stop" && e.Index != nil {
  199. sawStop = true
  200. stopIndex = *e.Index
  201. }
  202. }
  203. assert.True(t, sawDelta, "tool 2 .done with packed args should emit content_block_delta")
  204. assert.True(t, sawStop, "tool 2 .done should close the block")
  205. assert.Equal(t, 1, deltaIndex, "delta must target the tool 2 block (index 1)")
  206. assert.Equal(t, deltaIndex, stopIndex, "delta and stop must be on the same block")
  207. }
  208. // TestStreamingThreeParallelToolsAllPackedDone tests the most extreme case:
  209. // three parallel tools, ALL with arguments packed in .done (no deltas at all).
  210. // Before the fix, tool 2 and tool 3 would emit ghost deltas on wrong indices.
  211. func TestStreamingThreeParallelToolsAllPackedDone(t *testing.T) {
  212. state := NewResponsesEventToAnthropicState()
  213. ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
  214. Type: "response.created",
  215. Response: &ResponsesResponse{ID: "resp_3par", Model: "glm-5.2"},
  216. }, state)
  217. // Open three tool blocks at indices 0, 1, 2.
  218. for i, name := range []string{"tool_a", "tool_b", "tool_c"} {
  219. ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
  220. Type: "response.output_item.added",
  221. OutputIndex: i,
  222. Item: &ResponsesOutput{Type: "function_call", CallID: "call_" + name, Name: name},
  223. }, state)
  224. }
  225. // Track started blocks.
  226. started := map[int]bool{0: true, 1: true, 2: true}
  227. // All three .done events with packed arguments (no prior delta).
  228. for i, args := range []string{`{"a":1}`, `{"b":2}`, `{"c":3}`} {
  229. events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
  230. Type: "response.function_call_arguments.done",
  231. OutputIndex: i,
  232. Arguments: args,
  233. }, state)
  234. for _, e := range events {
  235. if e.Type == "content_block_delta" && e.Index != nil {
  236. idx := *e.Index
  237. require.Truef(t, started[idx],
  238. "ghost delta: tool %d .done emitted content_block_delta on index %d (never started)", i, idx)
  239. require.Equal(t, i, idx,
  240. "tool %d .done delta should target its own block index %d, got %d", i, i, idx)
  241. }
  242. if e.Type == "content_block_stop" && e.Index != nil {
  243. idx := *e.Index
  244. require.Truef(t, started[idx],
  245. "ghost stop: tool %d .done emitted content_block_stop on index %d (never started)", i, idx)
  246. }
  247. }
  248. }
  249. }