responses_client_tools_item_id_test.go 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. package apicompat
  2. import (
  3. "testing"
  4. "github.com/stretchr/testify/require"
  5. )
  6. // A function-only upstream answers a lowered custom tool with an fc_ item ID.
  7. // Emitting that ID on the restored custom_tool_call breaks the client the next
  8. // time the item is replayed to an upstream that validates item IDs:
  9. // "Invalid 'input[N].id': 'fc_...'. Expected an ID that begins with 'ctc'".
  10. func TestRestoreResponsesClientToolPayload_RetypesToolCallItemIDs(t *testing.T) {
  11. mapping := ResponsesClientToolMapping{
  12. CustomTools: map[string]bool{"exec": true}, ToolSearch: true,
  13. NamespaceTools: map[string]ResponsesNamespaceName{"team__send": {Namespace: "team", Name: "send"}},
  14. }
  15. payload := []byte(`{"id":"resp","output":[` +
  16. `{"type":"function_call","id":"fc_abc123","call_id":"call_1","name":"exec","arguments":"{\"input\":\"dir\"}"},` +
  17. `{"type":"function_call","id":"fc_def456","call_id":"call_2","name":"tool_search","arguments":"{\"query\":\"git\"}"},` +
  18. `{"type":"function_call","id":"fc_ghi789","call_id":"call_3","name":"team__send","arguments":"{}"}]}`)
  19. restored, changed, err := RestoreResponsesClientToolPayload(payload, mapping)
  20. require.NoError(t, err)
  21. require.True(t, changed)
  22. require.JSONEq(t, `{"id":"resp","output":[`+
  23. `{"type":"custom_tool_call","id":"ctc_abc123","call_id":"call_1","name":"exec","input":"dir"},`+
  24. `{"type":"tool_search_call","id":"tsc_def456","call_id":"call_2","execution":"client","arguments":{"query":"git"}},`+
  25. `{"type":"function_call","id":"fc_ghi789","call_id":"call_3","name":"send","namespace":"team","arguments":"{}"}]}`,
  26. string(restored))
  27. }
  28. func TestRestoreResponsesOutputClientTools_RetypesToolCallItemIDs(t *testing.T) {
  29. mapping := ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}, ToolSearch: true}
  30. outputs := []ResponsesOutput{
  31. {Type: "function_call", ID: "fc_abc123", CallID: "call_1", Name: "exec", Arguments: `{"input":"dir"}`},
  32. {Type: "function_call", ID: "fc_def456", CallID: "call_2", Name: toolSearchProxyName, Arguments: `{"query":"git"}`},
  33. }
  34. restoreResponsesOutputClientTools(outputs, &mapping)
  35. require.Equal(t, "custom_tool_call", outputs[0].Type)
  36. require.Equal(t, "ctc_abc123", outputs[0].ID)
  37. require.Equal(t, "call_1", outputs[0].CallID)
  38. require.Equal(t, "tool_search_call", outputs[1].Type)
  39. require.Equal(t, "tsc_def456", outputs[1].ID)
  40. require.Equal(t, "call_2", outputs[1].CallID)
  41. }
  42. func TestResponsesClientToolStreamRestorer_RetypesCustomToolCallItemID(t *testing.T) {
  43. const upstreamID = "fc_09f77ac43cf7db36016a8920e7934487"
  44. const clientID = "ctc_09f77ac43cf7db36016a8920e7934487"
  45. restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}})
  46. added := restorer.Restore(ResponsesStreamEvent{
  47. Type: "response.output_item.added", SequenceNumber: 0, OutputIndex: 0,
  48. Item: &ResponsesOutput{Type: "function_call", ID: upstreamID, CallID: "call_1", Name: "exec", Status: "in_progress"},
  49. })
  50. require.Len(t, added, 1)
  51. require.Equal(t, "custom_tool_call", added[0].Item.Type)
  52. require.Equal(t, clientID, added[0].Item.ID)
  53. // Later upstream events still address the item by its upstream ID.
  54. require.Empty(t, restorer.Restore(ResponsesStreamEvent{
  55. Type: "response.function_call_arguments.delta", SequenceNumber: 1, ItemID: upstreamID, Delta: `{"input":"di`,
  56. }))
  57. done := restorer.Restore(ResponsesStreamEvent{
  58. Type: "response.function_call_arguments.done", SequenceNumber: 2, ItemID: upstreamID,
  59. CallID: "call_1", Name: "exec", Arguments: `{"input":"dir"}`,
  60. })
  61. require.Len(t, done, 2)
  62. require.Equal(t, "response.custom_tool_call_input.delta", done[0].Type)
  63. require.Equal(t, clientID, done[0].ItemID)
  64. require.Equal(t, "response.custom_tool_call_input.done", done[1].Type)
  65. require.Equal(t, clientID, done[1].ItemID)
  66. require.Equal(t, "call_1", done[1].CallID)
  67. closed := restorer.Restore(ResponsesStreamEvent{
  68. Type: "response.output_item.done", SequenceNumber: 3, OutputIndex: 0,
  69. Item: &ResponsesOutput{Type: "function_call", ID: upstreamID, CallID: "call_1", Name: "exec", Arguments: `{"input":"dir"}`, Status: "completed"},
  70. })
  71. require.Len(t, closed, 1)
  72. require.Equal(t, "custom_tool_call", closed[0].Item.Type)
  73. require.Equal(t, clientID, closed[0].Item.ID)
  74. require.Equal(t, "dir", closed[0].Item.Input)
  75. }
  76. func TestResponsesClientToolStreamRestorer_RetypesToolSearchCallItemID(t *testing.T) {
  77. restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{ToolSearch: true})
  78. added := restorer.Restore(ResponsesStreamEvent{
  79. Type: "response.output_item.added", SequenceNumber: 0, OutputIndex: 0,
  80. Item: &ResponsesOutput{Type: "function_call", ID: "fc_search1", CallID: "call_1", Name: toolSearchProxyName, Status: "in_progress"},
  81. })
  82. require.Len(t, added, 1)
  83. require.Equal(t, "tool_search_call", added[0].Item.Type)
  84. require.Equal(t, "tsc_search1", added[0].Item.ID)
  85. }
  86. // The WS bridge replays restored items back to the upstream, so the ID we hand
  87. // the client has to map back to the upstream's own fc_ ID on the way down.
  88. func TestAdaptResponsesClientTools_RecoversRetypedToolCallItemID(t *testing.T) {
  89. req := map[string]any{
  90. "tools": []any{
  91. map[string]any{"type": "custom", "name": "exec"},
  92. map[string]any{"type": "tool_search"},
  93. },
  94. "input": []any{
  95. map[string]any{"type": "custom_tool_call", "id": "ctc_upstream1", "call_id": "call_1", "name": "exec", "input": "dir"},
  96. map[string]any{"type": "tool_search_call", "id": "tsc_upstream2", "call_id": "call_2", "arguments": map[string]any{"query": "git"}},
  97. map[string]any{"type": "custom_tool_call_output", "id": "ctco_client", "call_id": "call_1", "output": "ok"},
  98. },
  99. }
  100. _, changed, err := AdaptResponsesClientTools(req)
  101. require.NoError(t, err)
  102. require.True(t, changed)
  103. input := requireResponsesClientToolValue[[]any](t, req["input"])
  104. require.Len(t, input, 3)
  105. customCall := requireResponsesClientToolValue[map[string]any](t, input[0])
  106. require.Equal(t, "function_call", customCall["type"])
  107. require.Equal(t, "fc_upstream1", customCall["id"])
  108. searchCall := requireResponsesClientToolValue[map[string]any](t, input[1])
  109. require.Equal(t, "function_call", searchCall["type"])
  110. require.Equal(t, "fc_upstream2", searchCall["id"])
  111. // Output items have no function-protocol ID counterpart and stay dropped.
  112. customOutput := requireResponsesClientToolValue[map[string]any](t, input[2])
  113. require.Equal(t, "function_call_output", customOutput["type"])
  114. require.NotContains(t, customOutput, "id")
  115. }