responses_to_anthropic_tool_pairing_test.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. package apicompat
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "github.com/stretchr/testify/require"
  6. )
  7. // assertAnthropicPairing enforces the Anthropic Messages tool-pairing invariants
  8. // that, when violated, surface as upstream 400s.
  9. func assertAnthropicPairing(t *testing.T, messages []AnthropicMessage) {
  10. t.Helper()
  11. for i, m := range messages {
  12. blocks := parseContentBlocks(m.Content)
  13. // No two consecutive same-role messages.
  14. if i > 0 {
  15. require.NotEqualf(t, messages[i-1].Role, m.Role, "consecutive %s messages at %d", m.Role, i)
  16. }
  17. for _, b := range blocks {
  18. switch b.Type {
  19. case "tool_result":
  20. // Must have a matching tool_use in the immediately previous message.
  21. require.Positivef(t, i, "tool_result %s has no previous message", b.ToolUseID)
  22. prev := parseContentBlocks(messages[i-1].Content)
  23. require.Truef(t, hasToolUse(prev, b.ToolUseID),
  24. "tool_result %s has no corresponding tool_use in previous message", b.ToolUseID)
  25. case "tool_use":
  26. // Must be answered by a tool_result in the immediately next message.
  27. require.Lessf(t, i+1, len(messages), "tool_use %s has no following message", b.ID)
  28. next := parseContentBlocks(messages[i+1].Content)
  29. require.Truef(t, hasToolResult(next, b.ID),
  30. "tool_use %s is not answered in the next message", b.ID)
  31. }
  32. }
  33. }
  34. }
  35. func hasToolUse(blocks []AnthropicContentBlock, id string) bool {
  36. for _, b := range blocks {
  37. if b.Type == "tool_use" && b.ID == id {
  38. return true
  39. }
  40. }
  41. return false
  42. }
  43. func hasToolResult(blocks []AnthropicContentBlock, toolUseID string) bool {
  44. for _, b := range blocks {
  45. if b.Type == "tool_result" && b.ToolUseID == toolUseID {
  46. return true
  47. }
  48. }
  49. return false
  50. }
  51. func convertAnthropic(t *testing.T, input string) []AnthropicMessage {
  52. t.Helper()
  53. _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input))
  54. require.NoError(t, err)
  55. assertAnthropicPairing(t, messages)
  56. return messages
  57. }
  58. // Tests use call_-prefixed ids because fromResponsesCallIDToAnthropic passes
  59. // those through unchanged (matching codex's real call_00_... ids); bare ids
  60. // would be rewritten to toolu_<id>.
  61. // A developer/approval message injected between a function_call and its output
  62. // must be moved out of the tool_use→tool_result adjacency. This is the shape
  63. // that produced the production 400 "tool_result ... must have a corresponding
  64. // tool_use block in the previous message".
  65. func TestAnthropicPairing_DeveloperMessageBetween(t *testing.T) {
  66. msgs := convertAnthropic(t, `[
  67. {"type":"message","role":"user","content":[{"type":"input_text","text":"do it"}]},
  68. {"type":"function_call","call_id":"call_A","name":"exec","arguments":"{}"},
  69. {"type":"message","role":"developer","content":[{"type":"input_text","text":"Approved command prefix saved"}]},
  70. {"type":"function_call_output","call_id":"call_A","output":"ok"}
  71. ]`)
  72. // The assistant tool_use message is immediately followed by its tool_result.
  73. for i, m := range msgs {
  74. if hasToolUse(parseContentBlocks(m.Content), "call_A") {
  75. require.Equal(t, "user", msgs[i+1].Role)
  76. require.True(t, hasToolResult(parseContentBlocks(msgs[i+1].Content), "call_A"))
  77. }
  78. }
  79. }
  80. // Parallel tool calls where both outputs arrive stay grouped: one assistant
  81. // message with both tool_use blocks, the next user message with both results.
  82. func TestAnthropicPairing_ParallelBothAnswered(t *testing.T) {
  83. msgs := convertAnthropic(t, `[
  84. {"type":"message","role":"user","content":[{"type":"input_text","text":"features?"}]},
  85. {"type":"function_call","call_id":"call_c0","name":"exec","arguments":"{}"},
  86. {"type":"function_call","call_id":"call_c1","name":"exec","arguments":"{}"},
  87. {"type":"function_call_output","call_id":"call_c0","output":"log"},
  88. {"type":"function_call_output","call_id":"call_c1","output":"tags"}
  89. ]`)
  90. var sawGrouped bool
  91. for _, m := range msgs {
  92. blocks := parseContentBlocks(m.Content)
  93. if hasToolUse(blocks, "call_c0") && hasToolUse(blocks, "call_c1") {
  94. sawGrouped = true
  95. }
  96. }
  97. require.True(t, sawGrouped, "parallel tool_use blocks should share one assistant message")
  98. }
  99. // A parallel call whose sibling output never arrived must be dropped so every
  100. // remaining tool_use is answered.
  101. func TestAnthropicPairing_ParallelOneUnanswered(t *testing.T) {
  102. msgs := convertAnthropic(t, `[
  103. {"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},
  104. {"type":"function_call","call_id":"call_A","name":"exec","arguments":"{}"},
  105. {"type":"function_call","call_id":"call_B","name":"exec","arguments":"{}"},
  106. {"type":"function_call_output","call_id":"call_A","output":"oa"}
  107. ]`)
  108. for _, m := range msgs {
  109. require.Falsef(t, hasToolUse(parseContentBlocks(m.Content), "call_B"),
  110. "unanswered tool_use call_B should have been dropped")
  111. }
  112. }
  113. // An orphan tool_result whose tool_use was never announced must be dropped.
  114. func TestAnthropicPairing_OrphanToolResultDropped(t *testing.T) {
  115. msgs := convertAnthropic(t, `[
  116. {"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},
  117. {"type":"function_call_output","call_id":"call_ghost","output":"orphan"}
  118. ]`)
  119. for _, m := range msgs {
  120. require.Falsef(t, hasToolResult(parseContentBlocks(m.Content), "call_ghost"),
  121. "orphan tool_result should have been dropped")
  122. }
  123. }
  124. // A dangling tool_call at the end of the history (no output yet) drops the
  125. // assistant message holding only that call, leaving no tool_use behind.
  126. func TestAnthropicPairing_DanglingCallDropped(t *testing.T) {
  127. msgs := convertAnthropic(t, `[
  128. {"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},
  129. {"type":"function_call","call_id":"call_A","name":"exec","arguments":"{}"}
  130. ]`)
  131. for _, m := range msgs {
  132. require.Falsef(t, hasToolUse(parseContentBlocks(m.Content), "call_A"),
  133. "dangling tool_use call_A should have been dropped")
  134. }
  135. }
  136. // Baseline: a single answered call pairs correctly and preserves the surrounding
  137. // turns.
  138. func TestAnthropicPairing_SingleCall(t *testing.T) {
  139. msgs := convertAnthropic(t, `[
  140. {"type":"message","role":"user","content":[{"type":"input_text","text":"latest sha?"}]},
  141. {"type":"function_call","call_id":"call_A","name":"exec","arguments":"{\"cmd\":\"git rev-parse HEAD\"}"},
  142. {"type":"function_call_output","call_id":"call_A","output":"deadbeef"},
  143. {"type":"message","role":"assistant","content":[{"type":"output_text","text":"It is deadbeef."}]}
  144. ]`)
  145. // user, assistant(tool_use), user(tool_result), assistant(text)
  146. require.GreaterOrEqual(t, len(msgs), 4)
  147. require.Equal(t, "user", msgs[0].Role)
  148. require.True(t, hasToolUse(parseContentBlocks(msgs[1].Content), "call_A"))
  149. require.True(t, hasToolResult(parseContentBlocks(msgs[2].Content), "call_A"))
  150. }
  151. func TestResponsesToAnthropic_FunctionOutputContentArray(t *testing.T) {
  152. msgs := convertAnthropic(t, `[
  153. {"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"},
  154. {"type":"function_call_output","call_id":"call_A","output":[
  155. {"type":"input_text","text":"image loaded"},
  156. {"type":"input_image","image_url":"data:image/png;base64,YQ=="}
  157. ]}
  158. ]`)
  159. require.Len(t, msgs, 2)
  160. resultBlocks := parseContentBlocks(msgs[1].Content)
  161. require.Len(t, resultBlocks, 1)
  162. require.Equal(t, "tool_result", resultBlocks[0].Type)
  163. var content []AnthropicContentBlock
  164. require.NoError(t, json.Unmarshal(resultBlocks[0].Content, &content))
  165. require.Len(t, content, 2)
  166. require.Equal(t, "text", content[0].Type)
  167. require.Equal(t, "image loaded", content[0].Text)
  168. require.Equal(t, "image", content[1].Type)
  169. require.NotNil(t, content[1].Source)
  170. require.Equal(t, "image/png", content[1].Source.MediaType)
  171. require.Equal(t, "YQ==", content[1].Source.Data)
  172. }