openai_delegation_bootstrap_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. package handler
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "github.com/stretchr/testify/require"
  6. "github.com/tidwall/gjson"
  7. )
  8. const delegationEnvelope = `<codex_delegation><source_thread_id>thread-1</source_thread_id><input>do the work</input></codex_delegation>`
  9. func TestNormalizeCodexDelegationBootstrapSupportedTools(t *testing.T) {
  10. tests := []struct {
  11. name string
  12. namespace string
  13. tool string
  14. callID string
  15. }{
  16. {name: "app create missing call id", namespace: "codex_app", tool: "create_thread"},
  17. {name: "app send empty call id", namespace: "codex_app", tool: "send_message_to_thread", callID: `,"call_id":""`},
  18. {name: "tui create whitespace call id", namespace: "codex_tui", tool: "create_thread", callID: `,"call_id":" "`},
  19. {name: "tui send missing call id", namespace: "codex_tui", tool: "send_message_to_thread"},
  20. }
  21. for _, tt := range tests {
  22. t.Run(tt.name, func(t *testing.T) {
  23. body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"` + tt.namespace + `","name":"` + tt.tool + `","output":"` + delegationEnvelope + `"` + tt.callID + `}]}`)
  24. got, changed := normalizeCodexDelegationBootstrap(body)
  25. require.True(t, changed)
  26. require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
  27. require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
  28. require.Equal(t, delegationEnvelope, gjson.GetBytes(got, "input.0.content.0.text").String())
  29. require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
  30. })
  31. }
  32. }
  33. func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) {
  34. tests := []struct {
  35. name string
  36. item string
  37. extra string
  38. }{
  39. {name: "ordinary missing call id", item: `{"type":"function_call_output","name":"other","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
  40. {name: "valid call id", item: `{"type":"function_call_output","call_id":"call-1","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
  41. {name: "ambiguous call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call"}`},
  42. {name: "ambiguous built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call"}`},
  43. {name: "mixed missing call id output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call_output","output":"done"}`},
  44. {name: "mixed tool search output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"tool_search_output","output":"done"}`},
  45. {name: "mixed invalid delegation output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"not an envelope"}`},
  46. {name: "non string call id", item: `{"type":"function_call_output","call_id":42,"name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
  47. {name: "padded type", item: `{"type":" function_call_output ","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
  48. {name: "padded name", item: `{"type":"function_call_output","name":" create_thread ","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
  49. {name: "padded namespace", item: `{"type":"function_call_output","name":"create_thread","namespace":" codex_app ","output":"` + delegationEnvelope + `"}`},
  50. }
  51. for _, tt := range tests {
  52. t.Run(tt.name, func(t *testing.T) {
  53. body := []byte(`{"model":"gpt-5","input":[` + tt.item + `]` + tt.extra + `}`)
  54. got, changed := normalizeCodexDelegationBootstrap(body)
  55. require.False(t, changed)
  56. require.Equal(t, body, got)
  57. })
  58. }
  59. }
  60. func TestNormalizeCodexDelegationBootstrapWithHistoricalContext(t *testing.T) {
  61. body := []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"},{"type":"function_call_output","call_id":"call-1","output":"done"},{"type":"item_reference","id":"item-1"},{"type":"computer_call","call_id":"call-2"}]}`)
  62. got, changed := normalizeCodexDelegationBootstrap(body)
  63. require.True(t, changed)
  64. require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
  65. require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
  66. require.Equal(t, "resp-1", gjson.GetBytes(got, "previous_response_id").String())
  67. require.Equal(t, "call-1", gjson.GetBytes(got, "input.2.call_id").String())
  68. require.Equal(t, "item-1", gjson.GetBytes(got, "input.3.id").String())
  69. }
  70. func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) {
  71. tests := []struct {
  72. name string
  73. value string
  74. changed bool
  75. }{
  76. {name: "absent", changed: true},
  77. {name: "blank string", value: `,"previous_response_id":" "`, changed: true},
  78. {name: "non-empty string", value: `,"previous_response_id":"resp-1"`, changed: true},
  79. {name: "null", value: `,"previous_response_id":null`},
  80. {name: "number", value: `,"previous_response_id":42`},
  81. {name: "boolean", value: `,"previous_response_id":false`},
  82. }
  83. for _, tt := range tests {
  84. t.Run(tt.name, func(t *testing.T) {
  85. body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]` + tt.value + `}`)
  86. got, changed := normalizeCodexDelegationBootstrap(body)
  87. require.Equal(t, tt.changed, changed)
  88. if !tt.changed {
  89. require.Equal(t, body, got)
  90. }
  91. })
  92. }
  93. }
  94. func TestNormalizeCodexDelegationBootstrapRejectsDuplicateMembers(t *testing.T) {
  95. tests := []struct {
  96. name string
  97. body string
  98. }{
  99. {
  100. name: "top-level previous response id",
  101. body: `{"model":"gpt-5","previous_response_id":"","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`,
  102. },
  103. {
  104. name: "candidate discriminator",
  105. body: `{"model":"gpt-5","input":[{"type":"message","type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`,
  106. },
  107. {
  108. name: "candidate call id",
  109. body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","call_id":"call-1","call_id":"","output":"` + delegationEnvelope + `"}]}`,
  110. },
  111. {
  112. name: "candidate output",
  113. body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"not a delegation","output":"` + delegationEnvelope + `"}]}`,
  114. },
  115. }
  116. for _, tt := range tests {
  117. t.Run(tt.name, func(t *testing.T) {
  118. body := []byte(tt.body)
  119. got, changed := normalizeCodexDelegationBootstrap(body)
  120. require.False(t, changed)
  121. require.Equal(t, body, got)
  122. })
  123. }
  124. }
  125. func TestNormalizeCodexDelegationBootstrapPreservesExactNumbers(t *testing.T) {
  126. body := []byte(`{"model":"gpt-5","metadata":{"integer":9007199254740993},"input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`)
  127. got, changed := normalizeCodexDelegationBootstrap(body)
  128. require.True(t, changed)
  129. require.Equal(t, "9007199254740993", gjson.GetBytes(got, "metadata.integer").Raw)
  130. }
  131. func TestNormalizeCodexDelegationBootstrapRequiresCompleteEnvelope(t *testing.T) {
  132. malformed := []string{
  133. `<codex_delegation><source_thread_id>thread-1</source_thread_id></codex_delegation>`,
  134. `<codex_delegation><source_thread_id></source_thread_id><input>work</input></codex_delegation>`,
  135. `<codex_delegation><source_thread_id>thread-1</source_thread_id><input> </input></codex_delegation>`,
  136. `prefix<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
  137. `<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>suffix`,
  138. `<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input><extra>x</extra></codex_delegation>`,
  139. `<codex_delegation><source_thread_id>thread-1</source_thread_id><input><nested>work</nested></input></codex_delegation>`,
  140. `<x:codex_delegation xmlns:x="urn:test"><source_thread_id>thread-1</source_thread_id><input>work</input></x:codex_delegation>`,
  141. `<x:codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></x:codex_delegation>`,
  142. `<codex_delegation xmlns="urn:test"><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
  143. `<codex_delegation xmlns:x="urn:test"><x:source_thread_id>thread-1</x:source_thread_id><input>work</input></codex_delegation>`,
  144. `<codex_delegation><x:source_thread_id>thread-1</x:source_thread_id><input>work</input></codex_delegation>`,
  145. `<codex_delegation xmlns=""><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
  146. }
  147. for _, envelope := range malformed {
  148. body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":` + mustJSON(t, envelope) + `}]}`)
  149. got, changed := normalizeCodexDelegationBootstrap(body)
  150. require.False(t, changed, envelope)
  151. require.Equal(t, body, got)
  152. }
  153. }
  154. func TestNormalizeCodexDelegationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) {
  155. body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_tui","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"message","role":"user","content":"after"}]}`)
  156. got, changed := normalizeCodexDelegationBootstrap(body)
  157. require.True(t, changed)
  158. require.Equal(t, "before", gjson.GetBytes(got, "input.0.content").String())
  159. require.Equal(t, delegationEnvelope, gjson.GetBytes(got, "input.1.content.0.text").String())
  160. require.Equal(t, "after", gjson.GetBytes(got, "input.2.content").String())
  161. again, changedAgain := normalizeCodexDelegationBootstrap(got)
  162. require.False(t, changedAgain)
  163. require.Equal(t, got, again)
  164. }
  165. func mustJSON(t *testing.T, value string) string {
  166. t.Helper()
  167. encoded, err := json.Marshal(value)
  168. require.NoError(t, err)
  169. return string(encoded)
  170. }