openai_tool_continuation_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. package service
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "github.com/stretchr/testify/require"
  6. )
  7. func TestNeedsToolContinuationSignals(t *testing.T) {
  8. // 覆盖所有触发续链的信号来源,确保判定逻辑完整。
  9. cases := []struct {
  10. name string
  11. body map[string]any
  12. want bool
  13. }{
  14. {name: "nil", body: nil, want: false},
  15. {name: "previous_response_id", body: map[string]any{"previous_response_id": "resp_1"}, want: true},
  16. {name: "previous_response_id_blank", body: map[string]any{"previous_response_id": " "}, want: false},
  17. {name: "function_call_output", body: map[string]any{"input": []any{map[string]any{"type": "function_call_output"}}}, want: true},
  18. {name: "tool_search_output", body: map[string]any{"input": []any{map[string]any{"type": "tool_search_output"}}}, want: true},
  19. {name: "custom_tool_call_output", body: map[string]any{"input": []any{map[string]any{"type": "custom_tool_call_output"}}}, want: true},
  20. {name: "mcp_tool_call_output", body: map[string]any{"input": []any{map[string]any{"type": "mcp_tool_call_output"}}}, want: true},
  21. {name: "item_reference", body: map[string]any{"input": []any{map[string]any{"type": "item_reference"}}}, want: true},
  22. {name: "tools", body: map[string]any{"tools": []any{map[string]any{"type": "function"}}}, want: true},
  23. {name: "tools_empty", body: map[string]any{"tools": []any{}}, want: false},
  24. {name: "tools_invalid", body: map[string]any{"tools": "bad"}, want: false},
  25. {name: "tool_choice", body: map[string]any{"tool_choice": "auto"}, want: true},
  26. {name: "tool_choice_object", body: map[string]any{"tool_choice": map[string]any{"type": "function"}}, want: true},
  27. {name: "tool_choice_empty_object", body: map[string]any{"tool_choice": map[string]any{}}, want: false},
  28. {name: "none", body: map[string]any{"input": []any{map[string]any{"type": "text", "text": "hi"}}}, want: false},
  29. }
  30. for _, tt := range cases {
  31. t.Run(tt.name, func(t *testing.T) {
  32. require.Equal(t, tt.want, NeedsToolContinuation(tt.body))
  33. })
  34. }
  35. }
  36. func TestHasFunctionCallOutput(t *testing.T) {
  37. // 所有 Codex 工具输出都应视为续链输出,避免 WS 续链时丢失 previous_response_id。
  38. require.False(t, HasFunctionCallOutput(nil))
  39. for _, typ := range []string{
  40. "function_call_output",
  41. "tool_search_output",
  42. "custom_tool_call_output",
  43. "mcp_tool_call_output",
  44. } {
  45. require.True(t, HasFunctionCallOutput(map[string]any{
  46. "input": []any{map[string]any{"type": typ}},
  47. }), typ)
  48. }
  49. require.False(t, HasFunctionCallOutput(map[string]any{
  50. "input": "text",
  51. }))
  52. }
  53. func TestHasToolCallContext(t *testing.T) {
  54. // 工具调用上下文必须包含 call_id,才能作为可关联上下文。
  55. require.False(t, HasToolCallContext(nil))
  56. for _, typ := range []string{
  57. "tool_call",
  58. "function_call",
  59. "local_shell_call",
  60. "tool_search_call",
  61. "custom_tool_call",
  62. "mcp_tool_call",
  63. } {
  64. require.True(t, HasToolCallContext(map[string]any{
  65. "input": []any{map[string]any{"type": typ, "call_id": "call_1"}},
  66. }), typ)
  67. }
  68. require.False(t, HasToolCallContext(map[string]any{
  69. "input": []any{map[string]any{"type": "tool_call"}},
  70. }))
  71. }
  72. func TestFunctionCallOutputCallIDs(t *testing.T) {
  73. // 仅提取工具输出的非空 call_id,去重后返回。
  74. require.Empty(t, FunctionCallOutputCallIDs(nil))
  75. callIDs := FunctionCallOutputCallIDs(map[string]any{
  76. "input": []any{
  77. map[string]any{"type": "function_call_output", "call_id": "call_1"},
  78. map[string]any{"type": "tool_search_output", "call_id": "call_search"},
  79. map[string]any{"type": "custom_tool_call_output", "call_id": "call_custom"},
  80. map[string]any{"type": "mcp_tool_call_output", "call_id": "call_mcp"},
  81. map[string]any{"type": "function_call_output", "call_id": ""},
  82. map[string]any{"type": "function_call_output", "call_id": "call_1"},
  83. },
  84. })
  85. require.ElementsMatch(t, []string{"call_1", "call_search", "call_custom", "call_mcp"}, callIDs)
  86. }
  87. func TestHasFunctionCallOutputMissingCallID(t *testing.T) {
  88. require.False(t, HasFunctionCallOutputMissingCallID(nil))
  89. require.True(t, HasFunctionCallOutputMissingCallID(map[string]any{
  90. "input": []any{map[string]any{"type": "function_call_output"}},
  91. }))
  92. require.True(t, HasFunctionCallOutputMissingCallID(map[string]any{
  93. "input": []any{map[string]any{"type": "tool_search_output"}},
  94. }))
  95. require.False(t, HasFunctionCallOutputMissingCallID(map[string]any{
  96. "input": []any{map[string]any{"type": "tool_search_output", "call_id": "call_1"}},
  97. }))
  98. }
  99. func TestHasItemReferenceForCallIDs(t *testing.T) {
  100. // item_reference 需要覆盖所有 call_id 才视为可关联上下文。
  101. require.False(t, HasItemReferenceForCallIDs(nil, []string{"call_1"}))
  102. require.False(t, HasItemReferenceForCallIDs(map[string]any{}, []string{"call_1"}))
  103. req := map[string]any{
  104. "input": []any{
  105. map[string]any{"type": "item_reference", "id": "call_1"},
  106. map[string]any{"type": "item_reference", "id": "call_2"},
  107. },
  108. }
  109. require.True(t, HasItemReferenceForCallIDs(req, []string{"call_1"}))
  110. require.True(t, HasItemReferenceForCallIDs(req, []string{"call_1", "call_2"}))
  111. require.False(t, HasItemReferenceForCallIDs(req, []string{"call_1", "call_3"}))
  112. }
  113. func TestValidateFunctionCallOutputContextBytesMatchesMapValidation(t *testing.T) {
  114. // handler 预校验走 raw JSON 扫描,语义必须与 service 内部 map 校验保持一致。
  115. cases := []struct {
  116. name string
  117. body map[string]any
  118. }{
  119. {
  120. name: "no_input",
  121. body: map[string]any{"model": "gpt-5.4"},
  122. },
  123. {
  124. name: "missing_call_id",
  125. body: map[string]any{"input": []any{map[string]any{"type": "function_call_output"}}},
  126. },
  127. {
  128. name: "call_id_without_reference",
  129. body: map[string]any{"input": []any{map[string]any{"type": "function_call_output", "call_id": "call_1"}}},
  130. },
  131. {
  132. name: "matching_reference",
  133. body: map[string]any{"input": []any{
  134. map[string]any{"type": "function_call_output", "call_id": "call_1"},
  135. map[string]any{"type": "item_reference", "id": "call_1"},
  136. }},
  137. },
  138. {
  139. name: "partial_reference",
  140. body: map[string]any{"input": []any{
  141. map[string]any{"type": "function_call_output", "call_id": "call_1"},
  142. map[string]any{"type": "tool_search_output", "call_id": "call_2"},
  143. map[string]any{"type": "item_reference", "id": "call_1"},
  144. }},
  145. },
  146. {
  147. name: "tool_context",
  148. body: map[string]any{"input": []any{
  149. map[string]any{"type": "function_call_output", "call_id": "call_1"},
  150. map[string]any{"type": "function_call", "call_id": "call_1"},
  151. }},
  152. },
  153. {
  154. name: "all_codex_tool_outputs",
  155. body: map[string]any{"input": []any{
  156. map[string]any{"type": "function_call_output", "call_id": "call_function"},
  157. map[string]any{"type": "tool_search_output", "call_id": "call_search"},
  158. map[string]any{"type": "custom_tool_call_output", "call_id": "call_custom"},
  159. map[string]any{"type": "mcp_tool_call_output", "call_id": "call_mcp"},
  160. map[string]any{"type": "item_reference", "id": "call_function"},
  161. map[string]any{"type": "item_reference", "id": "call_search"},
  162. map[string]any{"type": "item_reference", "id": "call_custom"},
  163. map[string]any{"type": "item_reference", "id": "call_mcp"},
  164. }},
  165. },
  166. }
  167. for _, tt := range cases {
  168. t.Run(tt.name, func(t *testing.T) {
  169. bodyBytes, err := json.Marshal(tt.body)
  170. require.NoError(t, err)
  171. require.Equal(t, ValidateFunctionCallOutputContext(tt.body), ValidateFunctionCallOutputContextBytes(bodyBytes))
  172. })
  173. }
  174. }
  175. func TestAnalyzeToolCallOutputContextCoverageBytes(t *testing.T) {
  176. cases := []struct {
  177. name string
  178. body map[string]any
  179. hasOutput bool
  180. coversAllIDs bool
  181. }{
  182. {
  183. name: "no_input",
  184. body: map[string]any{"model": "gpt-5.1"},
  185. hasOutput: false,
  186. coversAllIDs: false,
  187. },
  188. {
  189. name: "no_tool_output",
  190. body: map[string]any{"input": []any{
  191. map[string]any{"type": "message", "content": "hi"},
  192. }},
  193. hasOutput: false,
  194. coversAllIDs: false,
  195. },
  196. {
  197. name: "object_tool_output_requires_context_replay",
  198. body: map[string]any{"input": map[string]any{
  199. "type": "custom_tool_call_output", "call_id": "call_a",
  200. }},
  201. hasOutput: true,
  202. coversAllIDs: false,
  203. },
  204. {
  205. name: "all_outputs_covered_by_context",
  206. body: map[string]any{"input": []any{
  207. map[string]any{"type": "function_call", "call_id": "call_a"},
  208. map[string]any{"type": "function_call_output", "call_id": "call_a"},
  209. }},
  210. hasOutput: true,
  211. coversAllIDs: true,
  212. },
  213. {
  214. name: "all_outputs_covered_by_item_reference",
  215. body: map[string]any{"input": []any{
  216. map[string]any{"type": "function_call_output", "call_id": "call_a"},
  217. map[string]any{"type": "item_reference", "id": "call_a"},
  218. }},
  219. hasOutput: true,
  220. coversAllIDs: true,
  221. },
  222. {
  223. // 关键回归用例:input 内存在某一个上下文项,但另一个输出的 call_id
  224. // 只能由上游会话链(previous_response_id)解析——不可剥离。
  225. name: "partial_coverage_not_movable",
  226. body: map[string]any{"input": []any{
  227. map[string]any{"type": "function_call", "call_id": "call_a"},
  228. map[string]any{"type": "function_call_output", "call_id": "call_a"},
  229. map[string]any{"type": "function_call_output", "call_id": "call_b"},
  230. }},
  231. hasOutput: true,
  232. coversAllIDs: false,
  233. },
  234. {
  235. name: "unrelated_context_does_not_cover",
  236. body: map[string]any{"input": []any{
  237. map[string]any{"type": "function_call", "call_id": "call_x"},
  238. map[string]any{"type": "function_call_output", "call_id": "call_b"},
  239. }},
  240. hasOutput: true,
  241. coversAllIDs: false,
  242. },
  243. {
  244. name: "output_missing_call_id_not_movable",
  245. body: map[string]any{"input": []any{
  246. map[string]any{"type": "function_call", "call_id": "call_a"},
  247. map[string]any{"type": "function_call_output"},
  248. map[string]any{"type": "function_call_output", "call_id": "call_a"},
  249. }},
  250. hasOutput: true,
  251. coversAllIDs: false,
  252. },
  253. {
  254. name: "mixed_context_and_reference_cover_all",
  255. body: map[string]any{"input": []any{
  256. map[string]any{"type": "function_call", "call_id": "call_a"},
  257. map[string]any{"type": "function_call_output", "call_id": "call_a"},
  258. map[string]any{"type": "function_call_output", "call_id": "call_b"},
  259. map[string]any{"type": "item_reference", "id": "call_b"},
  260. }},
  261. hasOutput: true,
  262. coversAllIDs: true,
  263. },
  264. {
  265. name: "all_codex_output_types_covered",
  266. body: map[string]any{"input": []any{
  267. map[string]any{"type": "tool_search_output", "call_id": "call_s"},
  268. map[string]any{"type": "tool_search_call", "call_id": "call_s"},
  269. map[string]any{"type": "mcp_tool_call_output", "call_id": "call_m"},
  270. map[string]any{"type": "mcp_tool_call", "call_id": "call_m"},
  271. }},
  272. hasOutput: true,
  273. coversAllIDs: true,
  274. },
  275. }
  276. for _, tt := range cases {
  277. t.Run(tt.name, func(t *testing.T) {
  278. bodyBytes, err := json.Marshal(tt.body)
  279. require.NoError(t, err)
  280. coverage := AnalyzeToolCallOutputContextCoverageBytes(bodyBytes)
  281. require.Equal(t, tt.hasOutput, coverage.HasFunctionCallOutput, "HasFunctionCallOutput")
  282. require.Equal(t, tt.coversAllIDs, coverage.ContextCoversAllCallIDs, "ContextCoversAllCallIDs")
  283. })
  284. }
  285. }