responses_client_tools_test.go 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  1. package apicompat
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "github.com/stretchr/testify/require"
  6. "github.com/tidwall/gjson"
  7. )
  8. func TestAdaptResponsesClientTools_LowersDeclarationsHistoryChoiceAndNamespaces(t *testing.T) {
  9. req := map[string]any{
  10. "tools": []any{
  11. map[string]any{"type": "custom", "name": "exec", "format": map[string]any{"type": "grammar"}},
  12. map[string]any{"type": "tool_search"},
  13. map[string]any{"type": "namespace", "name": "team", "tools": []any{map[string]any{"type": "function", "name": "send"}}},
  14. },
  15. "tool_choice": map[string]any{"type": "custom", "name": "exec"},
  16. "input": []any{
  17. map[string]any{"type": "custom_tool_call", "id": "ctc_client", "call_id": "c1", "name": "exec", "input": "dir"},
  18. map[string]any{"type": "custom_tool_call_output", "id": "ctco_client", "call_id": "c1", "output": "ok"},
  19. map[string]any{"type": "tool_search_call", "id": "tsc_client", "call_id": "s1", "arguments": map[string]any{"query": "git"}},
  20. map[string]any{"type": "tool_search_output", "id": "tso_client", "call_id": "s1", "output": map[string]any{"groups": []string{"git"}}},
  21. map[string]any{"type": "function_call", "call_id": "n1", "namespace": "team", "name": "send", "arguments": "{}"},
  22. },
  23. }
  24. mapping, changed, err := AdaptResponsesClientTools(req)
  25. require.NoError(t, err)
  26. require.True(t, changed)
  27. require.True(t, mapping.CustomTools["exec"])
  28. require.True(t, mapping.ToolSearch)
  29. require.Equal(t, ResponsesNamespaceName{Namespace: "team", Name: "send"}, mapping.NamespaceTools["team__send"])
  30. tools := requireResponsesClientToolValue[[]any](t, req["tools"])
  31. require.Len(t, tools, 3)
  32. exec := requireResponsesClientToolValue[map[string]any](t, tools[0])
  33. require.Equal(t, "function", exec["type"])
  34. parameters := requireResponsesClientToolValue[json.RawMessage](t, exec["parameters"])
  35. require.JSONEq(t, customToolInputSchema, string(parameters))
  36. search := requireResponsesClientToolValue[map[string]any](t, tools[1])
  37. require.Equal(t, toolSearchProxyName, search["name"])
  38. namespaceTool := requireResponsesClientToolValue[map[string]any](t, tools[2])
  39. require.Equal(t, "team__send", namespaceTool["name"])
  40. choice := requireResponsesClientToolValue[map[string]any](t, req["tool_choice"])
  41. require.Equal(t, "function", choice["type"])
  42. input := requireResponsesClientToolValue[[]any](t, req["input"])
  43. customCall := requireResponsesClientToolValue[map[string]any](t, input[0])
  44. require.Equal(t, "function_call", customCall["type"])
  45. require.Equal(t, "fc_client", customCall["id"])
  46. require.JSONEq(t, `{"input":"dir"}`, requireResponsesClientToolValue[string](t, customCall["arguments"]))
  47. customOutput := requireResponsesClientToolValue[map[string]any](t, input[1])
  48. require.Equal(t, "function_call_output", customOutput["type"])
  49. require.NotContains(t, customOutput, "id")
  50. searchCall := requireResponsesClientToolValue[map[string]any](t, input[2])
  51. require.Equal(t, "function_call", searchCall["type"])
  52. require.Equal(t, "fc_client", searchCall["id"])
  53. require.Equal(t, toolSearchProxyName, searchCall["name"])
  54. require.JSONEq(t, `{"query":"git"}`, requireResponsesClientToolValue[string](t, searchCall["arguments"]))
  55. searchOutput := requireResponsesClientToolValue[map[string]any](t, input[3])
  56. require.Equal(t, "function_call_output", searchOutput["type"])
  57. require.NotContains(t, searchOutput, "id")
  58. require.JSONEq(t, `{"groups":["git"]}`, requireResponsesClientToolValue[string](t, searchOutput["output"]))
  59. namespaceCall := requireResponsesClientToolValue[map[string]any](t, input[4])
  60. require.Equal(t, "team__send", namespaceCall["name"])
  61. }
  62. func TestAdaptResponsesClientTools_RemovesDeferredFlagsWhenToolSearchIsLowered(t *testing.T) {
  63. req := map[string]any{
  64. "tools": []any{
  65. map[string]any{"type": "tool_search"},
  66. map[string]any{"type": "function", "name": "shell", "defer_loading": true},
  67. map[string]any{"type": "function", "name": "apply_patch"},
  68. },
  69. }
  70. _, changed, err := AdaptResponsesClientTools(req)
  71. require.NoError(t, err)
  72. require.True(t, changed)
  73. tools := requireResponsesClientToolValue[[]any](t, req["tools"])
  74. require.Equal(t, toolSearchProxyName, requireResponsesClientToolValue[map[string]any](t, tools[0])["name"])
  75. require.NotContains(t, requireResponsesClientToolValue[map[string]any](t, tools[1]), "defer_loading")
  76. }
  77. func TestStripResponsesDeferredToolFlags_PreservesFlagsWithBuiltInToolSearch(t *testing.T) {
  78. tools := []any{
  79. map[string]any{"type": "tool_search"},
  80. map[string]any{"type": "function", "name": "shell", "defer_loading": true},
  81. }
  82. require.False(t, stripResponsesDeferredToolFlags(tools))
  83. require.Equal(t, true, requireResponsesClientToolValue[map[string]any](t, tools[1])["defer_loading"])
  84. }
  85. func TestAdaptResponsesClientTools_LowersDiscoveredToolSearchOutput(t *testing.T) {
  86. requestJSON := `{
  87. "tools":[{"type":"tool_search"}],
  88. "input":[
  89. {"type":"tool_search_call","id":"tsc_client","call_id":"call_search","arguments":{"query":"codex app"},"execution":"client","status":"completed"},
  90. {"type":"tool_search_output","id":"tso_client","call_id":"call_search","execution":"client","status":"completed","tools":[
  91. {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","description":"Load workspace dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]},
  92. {"type":"namespace","name":"multi_agent_v1","tools":[
  93. {"type":"function","name":"spawn_agent","description":"Spawn an agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}},
  94. {"type":"function","name":"wait_agent","description":"Wait for agents","parameters":{"type":"object","properties":{},"additionalProperties":false}}
  95. ]}
  96. ]}
  97. ]
  98. }`
  99. type adaptedRequest struct {
  100. req map[string]any
  101. mapping ResponsesClientToolMapping
  102. }
  103. adapt := func() adaptedRequest {
  104. var req map[string]any
  105. require.NoError(t, json.Unmarshal([]byte(requestJSON), &req))
  106. mapping, changed, err := AdaptResponsesClientTools(req)
  107. require.NoError(t, err)
  108. require.True(t, changed)
  109. return adaptedRequest{req: req, mapping: mapping}
  110. }
  111. first := adapt()
  112. second := adapt()
  113. firstInput := requireResponsesClientToolValue[[]any](t, first.req["input"])
  114. secondInput := requireResponsesClientToolValue[[]any](t, second.req["input"])
  115. tools := requireResponsesClientToolValue[[]any](t, first.req["tools"])
  116. require.Len(t, tools, 4)
  117. require.Equal(t, []string{
  118. "tool_search",
  119. "codex_app__load_workspace_dependencies",
  120. "multi_agent_v1__spawn_agent",
  121. "multi_agent_v1__wait_agent",
  122. }, responsesClientToolNames(t, tools))
  123. require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, first.mapping.NamespaceTools["multi_agent_v1__spawn_agent"])
  124. require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "wait_agent"}, first.mapping.NamespaceTools["multi_agent_v1__wait_agent"])
  125. call := requireResponsesClientToolValue[map[string]any](t, firstInput[0])
  126. require.Equal(t, "function_call", call["type"])
  127. require.Equal(t, toolSearchProxyName, call["name"])
  128. require.JSONEq(t, `{"query":"codex app"}`, requireResponsesClientToolValue[string](t, call["arguments"]))
  129. require.NotContains(t, call, "execution")
  130. output := requireResponsesClientToolValue[map[string]any](t, firstInput[1])
  131. require.Equal(t, map[string]any{
  132. "type": "function_call_output",
  133. "call_id": "call_search",
  134. "output": output["output"],
  135. }, output)
  136. outputText := requireResponsesClientToolValue[string](t, output["output"])
  137. require.JSONEq(t, `[
  138. {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","description":"Load workspace dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]},
  139. {"type":"namespace","name":"multi_agent_v1","tools":[
  140. {"type":"function","name":"spawn_agent","description":"Spawn an agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}},
  141. {"type":"function","name":"wait_agent","description":"Wait for agents","parameters":{"type":"object","properties":{},"additionalProperties":false}}
  142. ]}
  143. ]`, outputText)
  144. secondOutput := requireResponsesClientToolValue[map[string]any](t, secondInput[1])
  145. require.Equal(t, outputText, secondOutput["output"], "tool discovery output encoding must be deterministic")
  146. restored, changed, err := RestoreResponsesClientToolPayload(
  147. []byte(`{"output":[{"type":"function_call","name":"multi_agent_v1__spawn_agent","call_id":"call_spawn","arguments":"{\"message\":\"work\"}"}]}`),
  148. first.mapping,
  149. )
  150. require.NoError(t, err)
  151. require.True(t, changed)
  152. require.JSONEq(t, `{"output":[{"type":"function_call","name":"spawn_agent","namespace":"multi_agent_v1","call_id":"call_spawn","arguments":"{\"message\":\"work\"}"}]}`, string(restored))
  153. }
  154. func TestAdaptResponsesClientTools_PromotesDirectDiscoveryAndDeduplicatesIdenticalDeclarations(t *testing.T) {
  155. direct := map[string]any{
  156. "type": "function", "name": "inspect_result", "description": "Inspect a result",
  157. "parameters": map[string]any{"type": "object", "properties": map[string]any{}},
  158. }
  159. custom := map[string]any{
  160. "type": "custom", "name": "run_script", "description": "Run a script",
  161. "format": map[string]any{"type": "grammar"},
  162. }
  163. namespace := map[string]any{
  164. "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{
  165. "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"},
  166. }},
  167. }
  168. req := map[string]any{
  169. "tools": []any{
  170. map[string]any{"type": "function", "name": "static_first", "parameters": map[string]any{"type": "object"}},
  171. map[string]any{"type": "tool_search"},
  172. },
  173. "input": []any{
  174. map[string]any{"type": "tool_search_output", "status": "completed", "call_id": "search_1", "tools": []any{direct, custom, namespace}},
  175. map[string]any{"type": "tool_search_output", "status": "completed", "call_id": "search_2", "tools": []any{copyClientTool(direct), copyClientTool(custom), copyClientTool(namespace)}},
  176. },
  177. }
  178. mapping, changed, err := AdaptResponsesClientTools(req)
  179. require.NoError(t, err)
  180. require.True(t, changed)
  181. require.True(t, mapping.CustomTools["run_script"])
  182. require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"])
  183. tools := requireResponsesClientToolValue[[]any](t, req["tools"])
  184. require.Equal(t, []string{"static_first", "tool_search", "inspect_result", "run_script", "multi_agent_v1__spawn_agent"}, responsesClientToolNames(t, tools))
  185. customTool := requireResponsesClientToolValue[map[string]any](t, tools[3])
  186. require.Equal(t, "function", customTool["type"])
  187. require.NotContains(t, customTool, "format")
  188. for _, raw := range requireResponsesClientToolValue[[]any](t, req["input"]) {
  189. item := requireResponsesClientToolValue[map[string]any](t, raw)
  190. require.Equal(t, "function_call_output", item["type"])
  191. require.NotContains(t, item, "tools")
  192. require.NotContains(t, item, "status")
  193. }
  194. }
  195. func TestAdaptResponsesClientTools_RejectsDiscoveredSchemaAndNamespaceCollisions(t *testing.T) {
  196. tests := []struct {
  197. name string
  198. staticTools []any
  199. discovered []any
  200. }{
  201. {
  202. name: "direct schema collision",
  203. staticTools: []any{map[string]any{
  204. "type": "function", "name": "inspect", "parameters": map[string]any{"type": "object"},
  205. }},
  206. discovered: []any{map[string]any{
  207. "type": "function", "name": "inspect", "parameters": map[string]any{"type": "string"},
  208. }},
  209. },
  210. {
  211. name: "namespace schema collision",
  212. staticTools: []any{map[string]any{
  213. "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{
  214. "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"},
  215. }},
  216. }},
  217. discovered: []any{map[string]any{
  218. "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{
  219. "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "string"},
  220. }},
  221. }},
  222. },
  223. {
  224. name: "flattened namespace collision",
  225. staticTools: []any{map[string]any{"type": "function", "name": "multi_agent_v1__spawn_agent"}},
  226. discovered: []any{map[string]any{
  227. "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{"type": "function", "name": "spawn_agent"}},
  228. }},
  229. },
  230. }
  231. for _, tt := range tests {
  232. t.Run(tt.name, func(t *testing.T) {
  233. req := map[string]any{
  234. "tools": append(tt.staticTools, map[string]any{"type": "tool_search"}),
  235. "input": []any{map[string]any{
  236. "type": "tool_search_output", "status": "completed", "tools": tt.discovered,
  237. }},
  238. }
  239. _, _, err := AdaptResponsesClientTools(req)
  240. require.ErrorContains(t, err, "conflicts")
  241. })
  242. }
  243. }
  244. func TestAdaptResponsesClientTools_DoesNotPromoteUnusableDiscoveries(t *testing.T) {
  245. req := map[string]any{
  246. "tools": []any{map[string]any{"type": "tool_search"}},
  247. "input": []any{
  248. map[string]any{"type": "tool_search_output", "call_id": "search_in_progress", "status": "in_progress", "tools": []any{map[string]any{"type": "function", "name": "not_ready"}}},
  249. map[string]any{"type": "tool_search_output", "call_id": "search_malformed", "status": "completed", "tools": []any{map[string]any{"type": "function"}}},
  250. },
  251. }
  252. _, changed, err := AdaptResponsesClientTools(req)
  253. require.NoError(t, err)
  254. require.True(t, changed, "the static tool_search declaration is still lowered")
  255. tools := requireResponsesClientToolValue[[]any](t, req["tools"])
  256. require.Equal(t, []string{"tool_search"}, responsesClientToolNames(t, tools))
  257. }
  258. func responsesClientToolNames(t *testing.T, tools []any) []string {
  259. t.Helper()
  260. names := make([]string, 0, len(tools))
  261. for _, raw := range tools {
  262. tool := requireResponsesClientToolValue[map[string]any](t, raw)
  263. names = append(names, requireResponsesClientToolValue[string](t, tool["name"]))
  264. }
  265. return names
  266. }
  267. func TestAdaptResponsesClientTools_ToolSearchOutputEdgeCases(t *testing.T) {
  268. unencodableOutput := make(chan struct{})
  269. tests := []struct {
  270. name string
  271. item map[string]any
  272. wantOutput any
  273. wantOutputExists bool
  274. wantPrivateKeys []string
  275. wantExactOutput bool
  276. wantErr bool
  277. }{
  278. {
  279. name: "absent tools and output is rejected",
  280. item: map[string]any{"type": "tool_search_output", "call_id": "call_empty", "status": "completed"},
  281. wantOutputExists: false,
  282. wantErr: true,
  283. },
  284. {
  285. name: "preexisting string output wins",
  286. item: map[string]any{
  287. "type": "tool_search_output", "call_id": "call_legacy", "output": "legacy",
  288. "tools": []any{map[string]any{"type": "function", "name": "ignored"}}, "execution": "client",
  289. },
  290. wantOutput: "legacy",
  291. wantOutputExists: true,
  292. wantExactOutput: true,
  293. },
  294. {
  295. name: "preexisting object output remains legacy representation",
  296. item: map[string]any{
  297. "type": "tool_search_output", "call_id": "call_object", "output": map[string]any{"groups": []any{"github"}},
  298. "tools": []any{map[string]any{"type": "function", "name": "ignored"}},
  299. },
  300. wantOutput: `{"groups":["github"]}`,
  301. wantOutputExists: true,
  302. wantExactOutput: true,
  303. },
  304. {
  305. name: "unencodable preexisting output is rejected",
  306. item: map[string]any{
  307. "type": "tool_search_output", "call_id": "call_bad_output", "output": unencodableOutput,
  308. "tools": []any{map[string]any{"type": "function", "name": "retained"}}, "status": "completed", "execution": "client",
  309. },
  310. wantOutput: unencodableOutput,
  311. wantErr: true,
  312. },
  313. {
  314. name: "empty tools array is a valid empty output",
  315. item: map[string]any{
  316. "type": "tool_search_output", "call_id": "call_empty_tools",
  317. "tools": []any{}, "status": "completed", "execution": "client",
  318. },
  319. wantOutput: `[]`,
  320. wantOutputExists: true,
  321. wantExactOutput: true,
  322. },
  323. {
  324. name: "non-array tools value is serialized directly",
  325. item: map[string]any{
  326. "type": "tool_search_output", "call_id": "call_malformed",
  327. "tools": map[string]any{"unexpected": true}, "status": "completed", "execution": "client",
  328. },
  329. wantOutput: `{"unexpected":true}`,
  330. wantOutputExists: true,
  331. wantExactOutput: true,
  332. },
  333. {
  334. name: "unencodable tools is rejected",
  335. item: map[string]any{
  336. "type": "tool_search_output", "call_id": "call_unencodable", "tools": make(chan struct{}), "status": "completed",
  337. },
  338. wantErr: true,
  339. },
  340. {
  341. name: "missing call id is rejected",
  342. item: map[string]any{
  343. "type": "tool_search_output", "tools": []any{}, "status": "completed",
  344. },
  345. wantErr: true,
  346. },
  347. }
  348. for _, tt := range tests {
  349. t.Run(tt.name, func(t *testing.T) {
  350. req := map[string]any{
  351. "tools": []any{map[string]any{"type": "tool_search"}},
  352. "input": []any{tt.item},
  353. }
  354. _, changed, err := AdaptResponsesClientTools(req)
  355. if tt.wantErr {
  356. require.Error(t, err)
  357. return
  358. }
  359. require.NoError(t, err)
  360. require.True(t, changed)
  361. input := requireResponsesClientToolValue[[]any](t, req["input"])
  362. output := requireResponsesClientToolValue[map[string]any](t, input[0])
  363. require.Equal(t, "function_call_output", output["type"])
  364. actualOutput, outputExists := output["output"]
  365. require.Equal(t, tt.wantOutputExists, outputExists)
  366. if tt.wantOutputExists {
  367. require.Equal(t, tt.wantOutput, actualOutput)
  368. }
  369. if tt.wantExactOutput {
  370. require.Equal(t, map[string]any{
  371. "type": "function_call_output",
  372. "call_id": output["call_id"],
  373. "output": tt.wantOutput,
  374. }, output)
  375. }
  376. if len(tt.wantPrivateKeys) > 0 {
  377. for _, key := range tt.wantPrivateKeys {
  378. require.Contains(t, output, key)
  379. }
  380. } else {
  381. require.NotContains(t, output, "tools")
  382. require.NotContains(t, output, "status")
  383. require.NotContains(t, output, "execution")
  384. }
  385. })
  386. }
  387. }
  388. func requireResponsesClientToolValue[T any](t *testing.T, value any) T {
  389. t.Helper()
  390. typed, ok := value.(T)
  391. require.True(t, ok, "unexpected value type %T", value)
  392. return typed
  393. }
  394. func TestAdaptResponsesClientTools_RejectsAmbiguousNames(t *testing.T) {
  395. cases := []map[string]any{
  396. {"tools": []any{map[string]any{"type": "custom", "name": "same"}, map[string]any{"type": "function", "name": "same"}}},
  397. {"tools": []any{map[string]any{"type": "tool_search"}, map[string]any{"type": "function", "name": "tool_search"}}},
  398. {"tools": []any{map[string]any{"type": "function", "name": "team__send"}, map[string]any{"type": "namespace", "name": "team", "tools": []any{map[string]any{"type": "function", "name": "send"}}}}},
  399. }
  400. for _, req := range cases {
  401. _, _, err := AdaptResponsesClientTools(req)
  402. require.Error(t, err)
  403. }
  404. }
  405. func TestAdaptResponsesClientToolsWithInheritedMapping_LowersFollowupHistoryWithoutTools(t *testing.T) {
  406. req := map[string]any{
  407. "input": []any{
  408. map[string]any{
  409. "type": "custom_tool_call", "name": "exec",
  410. "call_id": "call_1", "input": "pwd",
  411. },
  412. map[string]any{
  413. "type": "custom_tool_call_output", "call_id": "call_1",
  414. "id": "ctco_client_output_1",
  415. "output": []any{map[string]any{"type": "input_text", "text": "ok"}},
  416. },
  417. },
  418. }
  419. inherited := ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}}
  420. mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(req, inherited)
  421. require.NoError(t, err)
  422. require.True(t, changed)
  423. require.Equal(t, inherited, mapping)
  424. items := requireResponsesClientToolValue[[]any](t, req["input"])
  425. call := requireResponsesClientToolValue[map[string]any](t, items[0])
  426. require.Equal(t, "function_call", call["type"])
  427. require.JSONEq(t, `{"input":"pwd"}`, requireResponsesClientToolValue[string](t, call["arguments"]))
  428. require.NotContains(t, call, "input")
  429. output := requireResponsesClientToolValue[map[string]any](t, items[1])
  430. require.Equal(t, "function_call_output", output["type"])
  431. require.NotContains(t, output, "id")
  432. require.Equal(t, []any{map[string]any{"type": "input_text", "text": "ok"}}, output["output"])
  433. }
  434. func TestAdaptResponsesClientTools_NormalizesCustomToolOutput(t *testing.T) {
  435. tests := []struct {
  436. name string
  437. output any
  438. wantOutput any
  439. }{
  440. {
  441. name: "supported content parts remain an array",
  442. output: []any{
  443. map[string]any{"type": "input_text", "text": "ok"},
  444. map[string]any{"type": "input_image", "image_url": "https://example.com/image.png"},
  445. map[string]any{"type": "input_file", "file_id": "file_123"},
  446. },
  447. wantOutput: []any{
  448. map[string]any{"type": "input_text", "text": "ok"},
  449. map[string]any{"type": "input_image", "image_url": "https://example.com/image.png"},
  450. map[string]any{"type": "input_file", "file_id": "file_123"},
  451. },
  452. },
  453. {name: "ordinary object is stringified", output: map[string]any{"ok": true}, wantOutput: `{"ok":true}`},
  454. {name: "arbitrary array is stringified", output: []any{"ok"}, wantOutput: `["ok"]`},
  455. {name: "empty array is stringified", output: []any{}, wantOutput: `[]`},
  456. {name: "mixed array is stringified", output: []any{map[string]any{"type": "input_text", "text": "ok"}, "bad"}, wantOutput: `[{"text":"ok","type":"input_text"},"bad"]`},
  457. {name: "unknown content type is stringified", output: []any{map[string]any{"type": "output_text", "text": "bad"}}, wantOutput: `[{"text":"bad","type":"output_text"}]`},
  458. {name: "whitespace-padded content type is stringified", output: []any{map[string]any{"type": " input_text ", "text": "bad"}}, wantOutput: `[{"text":"bad","type":" input_text "}]`},
  459. {name: "missing content type is stringified", output: []any{map[string]any{"text": "bad"}}, wantOutput: `[{"text":"bad"}]`},
  460. {name: "non-string content type is stringified", output: []any{map[string]any{"type": 1}}, wantOutput: `[{"type":1}]`},
  461. }
  462. for _, tc := range tests {
  463. t.Run(tc.name, func(t *testing.T) {
  464. req := map[string]any{
  465. "tools": []any{map[string]any{"type": "custom", "name": "exec"}},
  466. "input": []any{map[string]any{
  467. "type": "custom_tool_call_output", "call_id": "call_1", "output": tc.output,
  468. }},
  469. }
  470. _, changed, err := AdaptResponsesClientTools(req)
  471. require.NoError(t, err)
  472. require.True(t, changed)
  473. item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0])
  474. require.Equal(t, "function_call_output", item["type"])
  475. require.Equal(t, tc.wantOutput, item["output"])
  476. })
  477. }
  478. }
  479. func TestAdaptResponsesClientToolsWithInheritedMapping_PromotesOmittedToolsDiscoveryIntoEffectiveDeclarations(t *testing.T) {
  480. req := map[string]any{
  481. "input": []any{map[string]any{
  482. "type": "tool_search_output", "call_id": "call_search", "status": "completed", "execution": "client",
  483. "tools": []any{map[string]any{
  484. "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{
  485. "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"},
  486. }},
  487. }},
  488. }},
  489. }
  490. inherited := ResponsesClientToolMapping{
  491. ToolSearch: true,
  492. NamespaceTools: map[string]ResponsesNamespaceName{
  493. "codex_app__read_resource": {Namespace: "codex_app", Name: "read_resource"},
  494. },
  495. }
  496. lowered := []any{
  497. map[string]any{"type": "function", "name": "static_first", "parameters": map[string]any{"type": "object"}},
  498. map[string]any{"type": "function", "name": "tool_search", "parameters": json.RawMessage(toolSearchProxySchema)},
  499. map[string]any{"type": "function", "name": "codex_app__read_resource", "parameters": map[string]any{"type": "object"}},
  500. }
  501. mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(req, inherited, lowered)
  502. require.NoError(t, err)
  503. require.True(t, changed)
  504. require.True(t, mapping.ToolSearch)
  505. require.Equal(t, ResponsesNamespaceName{Namespace: "codex_app", Name: "read_resource"}, mapping.NamespaceTools["codex_app__read_resource"])
  506. require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"])
  507. tools := requireResponsesClientToolValue[[]any](t, req["tools"])
  508. require.Equal(t, []string{
  509. "static_first", "tool_search", "codex_app__read_resource", "multi_agent_v1__spawn_agent",
  510. }, responsesClientToolNames(t, tools))
  511. output := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0])
  512. require.Equal(t, "function_call_output", output["type"])
  513. require.IsType(t, "", output["output"])
  514. require.NotContains(t, output, "tools")
  515. require.NotContains(t, output, "status")
  516. require.NotContains(t, output, "execution")
  517. }
  518. func TestAdaptResponsesClientToolsWithInheritedMapping_ExplicitToolsReplaceInheritedMapping(t *testing.T) {
  519. req := map[string]any{
  520. "tools": []any{},
  521. "input": []any{map[string]any{
  522. "type": "custom_tool_call", "name": "exec", "input": "pwd",
  523. }},
  524. }
  525. mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(
  526. req,
  527. ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}},
  528. )
  529. require.NoError(t, err)
  530. require.False(t, changed)
  531. require.Empty(t, mapping)
  532. items := requireResponsesClientToolValue[[]any](t, req["input"])
  533. call := requireResponsesClientToolValue[map[string]any](t, items[0])
  534. require.Equal(t, "custom_tool_call", call["type"])
  535. }
  536. func TestAdaptResponsesClientToolsWithInheritedMapping_ExplicitToolResetDoesNotPromoteDiscovery(t *testing.T) {
  537. for _, reset := range []any{nil, []any{}} {
  538. req := map[string]any{
  539. "tools": reset,
  540. "input": []any{map[string]any{
  541. "type": "tool_search_output", "call_id": "call_reset", "status": "completed",
  542. "tools": []any{map[string]any{"type": "function", "name": "must_not_promote"}},
  543. }},
  544. }
  545. mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(
  546. req,
  547. ResponsesClientToolMapping{ToolSearch: true},
  548. []any{map[string]any{"type": "function", "name": "tool_search"}},
  549. )
  550. require.NoError(t, err)
  551. require.False(t, changed)
  552. require.Empty(t, mapping)
  553. item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0])
  554. require.Equal(t, "tool_search_output", item["type"])
  555. }
  556. }
  557. func TestRestoreResponsesClientToolPayload_RestoresClientAndNamespaceCalls(t *testing.T) {
  558. mapping := ResponsesClientToolMapping{
  559. CustomTools: map[string]bool{"exec": true}, ToolSearch: true,
  560. NamespaceTools: map[string]ResponsesNamespaceName{"team__send": {Namespace: "team", Name: "send"}},
  561. }
  562. payload := []byte(`{"id":"resp","output":[{"type":"function_call","id":"i1","call_id":"c1","name":"exec","arguments":"{\"input\":\"dir\"}","namespace":"ignore"},{"type":"function_call","id":"i2","call_id":"s1","name":"tool_search","arguments":"{\"query\":\"git\"}"},{"type":"function_call","id":"i3","call_id":"n1","name":"team__send","arguments":"{}"}]}`)
  563. restored, changed, err := RestoreResponsesClientToolPayload(payload, mapping)
  564. require.NoError(t, err)
  565. require.True(t, changed)
  566. require.JSONEq(t, `{"id":"resp","output":[{"type":"custom_tool_call","id":"i1","call_id":"c1","name":"exec","input":"dir"},{"type":"tool_search_call","id":"i2","call_id":"s1","execution":"client","arguments":{"query":"git"}},{"type":"function_call","id":"i3","call_id":"n1","name":"send","namespace":"team","arguments":"{}"}]}`, string(restored))
  567. }
  568. func TestResponsesClientToolStreamRestorer_CustomToolBuffersWrapperAndSequences(t *testing.T) {
  569. restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}})
  570. added := restorer.Restore(ResponsesStreamEvent{Type: "response.output_item.added", SequenceNumber: 7, OutputIndex: 0, Item: &ResponsesOutput{Type: "function_call", ID: "i1", CallID: "c1", Name: "exec", Status: "in_progress"}})
  571. require.Len(t, added, 1)
  572. require.Equal(t, 7, added[0].SequenceNumber)
  573. require.Equal(t, "custom_tool_call", added[0].Item.Type)
  574. require.Empty(t, restorer.Restore(ResponsesStreamEvent{Type: "response.function_call_arguments.delta", SequenceNumber: 8, ItemID: "i1", Delta: `{"input":"di`}))
  575. done := restorer.Restore(ResponsesStreamEvent{Type: "response.function_call_arguments.done", SequenceNumber: 9, ItemID: "i1", CallID: "c1", Name: "exec", Arguments: `{"input":"dir"}`})
  576. require.Len(t, done, 2)
  577. require.Equal(t, 8, done[0].SequenceNumber)
  578. require.Equal(t, "response.custom_tool_call_input.delta", done[0].Type)
  579. require.Equal(t, "dir", done[0].Delta)
  580. require.Equal(t, 9, done[1].SequenceNumber)
  581. require.Equal(t, "response.custom_tool_call_input.done", done[1].Type)
  582. require.Equal(t, "dir", done[1].Input)
  583. closed := restorer.Restore(ResponsesStreamEvent{Type: "response.output_item.done", SequenceNumber: 10, OutputIndex: 0, Item: &ResponsesOutput{Type: "function_call", ID: "i1", CallID: "c1", Name: "exec", Arguments: `{"input":"dir"}`, Status: "completed"}})
  584. require.Equal(t, 10, closed[0].SequenceNumber)
  585. require.Equal(t, "custom_tool_call", closed[0].Item.Type)
  586. require.Equal(t, "dir", closed[0].Item.Input)
  587. }
  588. func TestResponsesClientToolStreamRestorer_ToolSearchAndFunction(t *testing.T) {
  589. restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{ToolSearch: true})
  590. search := restorer.Restore(ResponsesStreamEvent{Type: "response.output_item.added", SequenceNumber: 0, OutputIndex: 0, Item: &ResponsesOutput{Type: "function_call", ID: "s1", CallID: "c1", Name: "tool_search", Status: "in_progress"}})
  591. require.Equal(t, "tool_search_call", search[0].Item.Type)
  592. require.Empty(t, restorer.Restore(ResponsesStreamEvent{Type: "response.function_call_arguments.delta", SequenceNumber: 1, ItemID: "s1", Delta: `{"query":"git"}`}))
  593. require.Empty(t, restorer.Restore(ResponsesStreamEvent{Type: "response.function_call_arguments.done", SequenceNumber: 2, ItemID: "s1", Arguments: `{"query":"git"}`}))
  594. closed := restorer.Restore(ResponsesStreamEvent{Type: "response.output_item.done", SequenceNumber: 3, OutputIndex: 0, Item: &ResponsesOutput{Type: "function_call", ID: "s1", CallID: "c1", Name: "tool_search", Status: "completed"}})
  595. require.Equal(t, 1, closed[0].SequenceNumber)
  596. require.Equal(t, "tool_search_call", closed[0].Item.Type)
  597. require.JSONEq(t, `{"query":"git"}`, string(toolSearchCallArgumentsJSON(closed[0].Item.Arguments)))
  598. function := restorer.Restore(ResponsesStreamEvent{Type: "response.function_call_arguments.done", SequenceNumber: 4, ItemID: "plain", Name: "plain", Arguments: "{}"})
  599. require.Len(t, function, 1)
  600. require.Equal(t, "response.function_call_arguments.done", function[0].Type)
  601. require.Equal(t, 2, function[0].SequenceNumber)
  602. }
  603. func TestResponsesClientToolStreamRestorer_RestoresNamespaceLifecycle(t *testing.T) {
  604. restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{
  605. NamespaceTools: map[string]ResponsesNamespaceName{
  606. "browser__open": {Namespace: "browser", Name: "open"},
  607. },
  608. })
  609. added, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.output_item.added","sequence_number":4,"output_index":0,"item":{"type":"function_call","id":"i1","call_id":"c1","name":"browser__open","arguments":"","status":"in_progress"}}`))
  610. require.NoError(t, err)
  611. require.True(t, changed)
  612. require.Len(t, added, 1)
  613. require.Equal(t, "open", gjson.GetBytes(added[0], "item.name").String())
  614. require.Equal(t, "browser", gjson.GetBytes(added[0], "item.namespace").String())
  615. delta, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.function_call_arguments.delta","sequence_number":5,"output_index":0,"item_id":"i1","name":"browser__open","delta":"{\"url\":"}`))
  616. require.NoError(t, err)
  617. require.True(t, changed)
  618. require.Len(t, delta, 1)
  619. require.Equal(t, "open", gjson.GetBytes(delta[0], "name").String())
  620. done, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.function_call_arguments.done","sequence_number":6,"output_index":0,"item_id":"i1","name":"browser__open","arguments":"{}"}`))
  621. require.NoError(t, err)
  622. require.True(t, changed)
  623. require.Len(t, done, 1)
  624. require.Equal(t, "open", gjson.GetBytes(done[0], "name").String())
  625. }
  626. func TestResponsesClientToolStreamRestorer_RawEventsPreserveUnknownFieldsAndOutputFallback(t *testing.T) {
  627. restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}})
  628. passthrough, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.created","sequence_number":4,"response":{"id":"r"},"upstream_extension":{"keep":true}}`))
  629. require.NoError(t, err)
  630. require.False(t, changed)
  631. require.Len(t, passthrough, 1)
  632. require.Contains(t, string(passthrough[0]), `"upstream_extension":{"keep":true}`)
  633. restorer.Restore(ResponsesStreamEvent{Type: "response.output_item.added", SequenceNumber: 5, OutputIndex: 9, Item: &ResponsesOutput{Type: "function_call", ID: "item", CallID: "call", Name: "exec"}})
  634. // Some upstreams omit every tool identity field on later argument chunks.
  635. require.Empty(t, restorer.Restore(ResponsesStreamEvent{Type: "response.function_call_arguments.delta", SequenceNumber: 6, OutputIndex: 9, Delta: `{"input":"pwd"}`}))
  636. done := restorer.Restore(ResponsesStreamEvent{Type: "response.function_call_arguments.done", SequenceNumber: 7, OutputIndex: 9})
  637. require.Len(t, done, 2)
  638. require.Equal(t, "pwd", done[1].Input)
  639. }
  640. func TestResponsesClientToolStreamRestorer_RestoresAllTerminalEvents(t *testing.T) {
  641. for _, eventType := range []string{
  642. "response.completed",
  643. "response.done",
  644. "response.incomplete",
  645. "response.failed",
  646. "response.cancelled",
  647. "response.canceled",
  648. } {
  649. t.Run(eventType, func(t *testing.T) {
  650. restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}})
  651. payload := []byte(`{"type":"` + eventType + `","sequence_number":7,"response":{"id":"resp_tools","output":[{"type":"function_call","id":"item_exec","call_id":"call_exec","name":"exec","arguments":"{\"input\":\"pwd\"}"}]}}`)
  652. restored, changed, err := restorer.RestoreEvent(payload)
  653. require.NoError(t, err)
  654. require.True(t, changed)
  655. require.Len(t, restored, 1)
  656. require.Equal(t, eventType, gjson.GetBytes(restored[0], "type").String())
  657. require.Equal(t, int64(7), gjson.GetBytes(restored[0], "sequence_number").Int())
  658. require.Equal(t, "custom_tool_call", gjson.GetBytes(restored[0], "response.output.0.type").String())
  659. require.Equal(t, "pwd", gjson.GetBytes(restored[0], "response.output.0.input").String())
  660. require.False(t, gjson.GetBytes(restored[0], "response.output.0.arguments").Exists())
  661. })
  662. }
  663. }