chatcompletions_responses_request_invariants_test.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. package apicompat
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "github.com/stretchr/testify/require"
  6. )
  7. // assertChatInvariants enforces the DeepSeek / OpenAI Chat Completions message
  8. // invariants that, when violated, surface as upstream 400s. Used to validate the
  9. // request-direction converter against golden codex request shapes.
  10. func assertChatInvariants(t *testing.T, messages []ChatMessage) {
  11. t.Helper()
  12. for i, m := range messages {
  13. // Every assistant tool_calls message must be immediately followed by one
  14. // tool message per tool_call_id, in order.
  15. if len(m.ToolCalls) > 0 {
  16. for j, tc := range m.ToolCalls {
  17. k := i + 1 + j
  18. require.Lessf(t, k, len(messages), "tool_call %s has no following tool message", tc.ID)
  19. require.Equalf(t, "tool", messages[k].Role, "tool_call %s not followed by a tool message", tc.ID)
  20. require.Equalf(t, tc.ID, messages[k].ToolCallID, "tool reply order mismatch for %s", tc.ID)
  21. }
  22. }
  23. // No two consecutive assistant messages.
  24. if i > 0 && m.Role == "assistant" && messages[i-1].Role == "assistant" {
  25. t.Fatalf("consecutive assistant messages at %d", i)
  26. }
  27. // No orphan tool replies.
  28. if m.Role == "tool" {
  29. require.NotEmptyf(t, m.ToolCallID, "tool message without tool_call_id at %d", i)
  30. }
  31. }
  32. }
  33. func convertGolden(t *testing.T, input string) []ChatMessage {
  34. t.Helper()
  35. msgs, err := responsesInputToChatMessages("You are a helpful assistant.", json.RawMessage(input))
  36. require.NoError(t, err)
  37. return msgs
  38. }
  39. // Golden sample: a single tool-call turn (codex runs one shell/curl command),
  40. // the shape that produced the original "no response" / 400.
  41. func TestGolden_SingleToolCall(t *testing.T) {
  42. msgs := convertGolden(t, `[
  43. {"type":"message","role":"user","content":[{"type":"input_text","text":"latest sha?"}]},
  44. {"type":"reasoning","summary":[{"type":"summary_text","text":"need to run curl"}]},
  45. {"type":"function_call","call_id":"call_a","name":"exec_command","arguments":"{\"cmd\":\"curl x\"}"},
  46. {"type":"function_call_output","call_id":"call_a","output":"deadbeef"}
  47. ]`)
  48. assertChatInvariants(t, msgs)
  49. // reasoning_content must ride on the assistant tool-call message.
  50. var asst *ChatMessage
  51. for i := range msgs {
  52. if len(msgs[i].ToolCalls) > 0 {
  53. asst = &msgs[i]
  54. }
  55. }
  56. require.NotNil(t, asst)
  57. require.Equal(t, "need to run curl", asst.ReasoningContent)
  58. }
  59. // Golden sample: parallel tool calls (codex runs git log + git tag at once).
  60. func TestGolden_ParallelToolCalls(t *testing.T) {
  61. msgs := convertGolden(t, `[
  62. {"type":"message","role":"user","content":[{"type":"input_text","text":"features?"}]},
  63. {"type":"reasoning","summary":[{"type":"summary_text","text":"inspect repo"}]},
  64. {"type":"function_call","call_id":"c0","name":"exec_command","arguments":"{\"cmd\":\"git log\"}"},
  65. {"type":"function_call","call_id":"c1","name":"exec_command","arguments":"{\"cmd\":\"git tag\"}"},
  66. {"type":"function_call_output","call_id":"c0","output":"log"},
  67. {"type":"function_call_output","call_id":"c1","output":"tags"}
  68. ]`)
  69. assertChatInvariants(t, msgs)
  70. // Both parallel calls share ONE assistant message.
  71. var toolMsgs int
  72. for _, m := range msgs {
  73. if len(m.ToolCalls) == 2 {
  74. require.Equal(t, "c0", m.ToolCalls[0].ID)
  75. require.Equal(t, "c1", m.ToolCalls[1].ID)
  76. }
  77. if m.Role == "tool" {
  78. toolMsgs++
  79. }
  80. }
  81. require.Equal(t, 2, toolMsgs)
  82. }
  83. // Golden sample: an unknown item type (web_search_call from a 联网查询) sitting
  84. // between a function_call and its output must not break tool↔reply adjacency.
  85. func TestGolden_UnknownItemBetweenToolCallAndOutput(t *testing.T) {
  86. msgs := convertGolden(t, `[
  87. {"type":"message","role":"user","content":[{"type":"input_text","text":"search"}]},
  88. {"type":"reasoning","summary":[{"type":"summary_text","text":"let me search"}]},
  89. {"type":"function_call","call_id":"c0","name":"exec_command","arguments":"{}"},
  90. {"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"x"}},
  91. {"type":"function_call_output","call_id":"c0","output":"result"}
  92. ]`)
  93. assertChatInvariants(t, msgs)
  94. }
  95. // Sequential tool calls (a tool reply between two calls) must stay in distinct
  96. // assistant messages.
  97. func TestRequest_SequentialToolCallsStaySeparate(t *testing.T) {
  98. msgs := convertGolden(t, `[
  99. {"type":"function_call","call_id":"c1","name":"exec","arguments":"{}"},
  100. {"type":"function_call_output","call_id":"c1","output":"r1"},
  101. {"type":"function_call","call_id":"c2","name":"exec","arguments":"{}"},
  102. {"type":"function_call_output","call_id":"c2","output":"r2"}
  103. ]`)
  104. assertChatInvariants(t, msgs)
  105. assistants := 0
  106. for _, m := range msgs {
  107. if len(m.ToolCalls) == 1 {
  108. assistants++
  109. }
  110. }
  111. require.Equal(t, 2, assistants)
  112. }
  113. // Golden sample: codex injects a message (e.g. an "Approved command prefix
  114. // saved" notice) between a function_call and its output. The intervening message
  115. // must be moved after the tool reply so the assistant tool_calls is immediately
  116. // followed by its reply.
  117. func TestGolden_MessageBetweenToolCallAndOutput(t *testing.T) {
  118. msgs := convertGolden(t, `[
  119. {"type":"message","role":"user","content":[{"type":"input_text","text":"do it"}]},
  120. {"type":"reasoning","summary":[{"type":"summary_text","text":"run cmd"}]},
  121. {"type":"function_call","call_id":"A","name":"exec","arguments":"{}"},
  122. {"type":"message","role":"developer","content":[{"type":"input_text","text":"Approved command prefix saved"}]},
  123. {"type":"function_call_output","call_id":"A","output":"ok"}
  124. ]`)
  125. assertChatInvariants(t, msgs)
  126. // The assistant tool_calls message is immediately followed by its tool reply.
  127. for i, m := range msgs {
  128. if len(m.ToolCalls) > 0 {
  129. require.Equal(t, "tool", msgs[i+1].Role)
  130. require.Equal(t, "A", msgs[i+1].ToolCallID)
  131. }
  132. }
  133. }
  134. // Golden sample: a parallel tool call where one sibling's output is missing
  135. // (codex interrupted/reconnected mid-execution). The unanswered tool_call must
  136. // be dropped so the remaining assistant tool_calls are all answered.
  137. func TestGolden_PartialParallelDropsUnansweredCall(t *testing.T) {
  138. msgs := convertGolden(t, `[
  139. {"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},
  140. {"type":"reasoning","summary":[{"type":"summary_text","text":"r"}]},
  141. {"type":"function_call","call_id":"A","name":"exec","arguments":"{}"},
  142. {"type":"function_call","call_id":"B","name":"exec","arguments":"{}"},
  143. {"type":"function_call_output","call_id":"A","output":"oa"}
  144. ]`)
  145. assertChatInvariants(t, msgs)
  146. for _, m := range msgs {
  147. for _, tc := range m.ToolCalls {
  148. require.NotEqual(t, "B", tc.ID, "unanswered tool_call B should have been dropped")
  149. }
  150. }
  151. }
  152. // Golden sample: a dangling tool_call at the end of the history (no output yet).
  153. // The assistant message holding only that call must be dropped entirely.
  154. func TestGolden_DanglingToolCallDropped(t *testing.T) {
  155. msgs := convertGolden(t, `[
  156. {"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},
  157. {"type":"reasoning","summary":[{"type":"summary_text","text":"r"}]},
  158. {"type":"function_call","call_id":"A","name":"exec","arguments":"{}"}
  159. ]`)
  160. assertChatInvariants(t, msgs)
  161. for _, m := range msgs {
  162. require.Empty(t, m.ToolCalls, "dangling unanswered tool_call should have been dropped")
  163. }
  164. }
  165. // normalizeChatMessages drops an orphan tool reply whose tool_call was never
  166. // announced.
  167. func TestNormalize_DropsOrphanToolReply(t *testing.T) {
  168. msgs := convertGolden(t, `[
  169. {"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},
  170. {"type":"function_call_output","call_id":"ghost","output":"orphan"}
  171. ]`)
  172. for _, m := range msgs {
  173. require.NotEqualf(t, "tool", m.Role, "orphan tool reply should have been dropped")
  174. }
  175. }