| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300 |
- package service
- import (
- "encoding/json"
- "testing"
- "github.com/stretchr/testify/require"
- )
- func TestNeedsToolContinuationSignals(t *testing.T) {
- // 覆盖所有触发续链的信号来源,确保判定逻辑完整。
- cases := []struct {
- name string
- body map[string]any
- want bool
- }{
- {name: "nil", body: nil, want: false},
- {name: "previous_response_id", body: map[string]any{"previous_response_id": "resp_1"}, want: true},
- {name: "previous_response_id_blank", body: map[string]any{"previous_response_id": " "}, want: false},
- {name: "function_call_output", body: map[string]any{"input": []any{map[string]any{"type": "function_call_output"}}}, want: true},
- {name: "tool_search_output", body: map[string]any{"input": []any{map[string]any{"type": "tool_search_output"}}}, want: true},
- {name: "custom_tool_call_output", body: map[string]any{"input": []any{map[string]any{"type": "custom_tool_call_output"}}}, want: true},
- {name: "mcp_tool_call_output", body: map[string]any{"input": []any{map[string]any{"type": "mcp_tool_call_output"}}}, want: true},
- {name: "item_reference", body: map[string]any{"input": []any{map[string]any{"type": "item_reference"}}}, want: true},
- {name: "tools", body: map[string]any{"tools": []any{map[string]any{"type": "function"}}}, want: true},
- {name: "tools_empty", body: map[string]any{"tools": []any{}}, want: false},
- {name: "tools_invalid", body: map[string]any{"tools": "bad"}, want: false},
- {name: "tool_choice", body: map[string]any{"tool_choice": "auto"}, want: true},
- {name: "tool_choice_object", body: map[string]any{"tool_choice": map[string]any{"type": "function"}}, want: true},
- {name: "tool_choice_empty_object", body: map[string]any{"tool_choice": map[string]any{}}, want: false},
- {name: "none", body: map[string]any{"input": []any{map[string]any{"type": "text", "text": "hi"}}}, want: false},
- }
- for _, tt := range cases {
- t.Run(tt.name, func(t *testing.T) {
- require.Equal(t, tt.want, NeedsToolContinuation(tt.body))
- })
- }
- }
- func TestHasFunctionCallOutput(t *testing.T) {
- // 所有 Codex 工具输出都应视为续链输出,避免 WS 续链时丢失 previous_response_id。
- require.False(t, HasFunctionCallOutput(nil))
- for _, typ := range []string{
- "function_call_output",
- "tool_search_output",
- "custom_tool_call_output",
- "mcp_tool_call_output",
- } {
- require.True(t, HasFunctionCallOutput(map[string]any{
- "input": []any{map[string]any{"type": typ}},
- }), typ)
- }
- require.False(t, HasFunctionCallOutput(map[string]any{
- "input": "text",
- }))
- }
- func TestHasToolCallContext(t *testing.T) {
- // 工具调用上下文必须包含 call_id,才能作为可关联上下文。
- require.False(t, HasToolCallContext(nil))
- for _, typ := range []string{
- "tool_call",
- "function_call",
- "local_shell_call",
- "tool_search_call",
- "custom_tool_call",
- "mcp_tool_call",
- } {
- require.True(t, HasToolCallContext(map[string]any{
- "input": []any{map[string]any{"type": typ, "call_id": "call_1"}},
- }), typ)
- }
- require.False(t, HasToolCallContext(map[string]any{
- "input": []any{map[string]any{"type": "tool_call"}},
- }))
- }
- func TestFunctionCallOutputCallIDs(t *testing.T) {
- // 仅提取工具输出的非空 call_id,去重后返回。
- require.Empty(t, FunctionCallOutputCallIDs(nil))
- callIDs := FunctionCallOutputCallIDs(map[string]any{
- "input": []any{
- map[string]any{"type": "function_call_output", "call_id": "call_1"},
- map[string]any{"type": "tool_search_output", "call_id": "call_search"},
- map[string]any{"type": "custom_tool_call_output", "call_id": "call_custom"},
- map[string]any{"type": "mcp_tool_call_output", "call_id": "call_mcp"},
- map[string]any{"type": "function_call_output", "call_id": ""},
- map[string]any{"type": "function_call_output", "call_id": "call_1"},
- },
- })
- require.ElementsMatch(t, []string{"call_1", "call_search", "call_custom", "call_mcp"}, callIDs)
- }
- func TestHasFunctionCallOutputMissingCallID(t *testing.T) {
- require.False(t, HasFunctionCallOutputMissingCallID(nil))
- require.True(t, HasFunctionCallOutputMissingCallID(map[string]any{
- "input": []any{map[string]any{"type": "function_call_output"}},
- }))
- require.True(t, HasFunctionCallOutputMissingCallID(map[string]any{
- "input": []any{map[string]any{"type": "tool_search_output"}},
- }))
- require.False(t, HasFunctionCallOutputMissingCallID(map[string]any{
- "input": []any{map[string]any{"type": "tool_search_output", "call_id": "call_1"}},
- }))
- }
- func TestHasItemReferenceForCallIDs(t *testing.T) {
- // item_reference 需要覆盖所有 call_id 才视为可关联上下文。
- require.False(t, HasItemReferenceForCallIDs(nil, []string{"call_1"}))
- require.False(t, HasItemReferenceForCallIDs(map[string]any{}, []string{"call_1"}))
- req := map[string]any{
- "input": []any{
- map[string]any{"type": "item_reference", "id": "call_1"},
- map[string]any{"type": "item_reference", "id": "call_2"},
- },
- }
- require.True(t, HasItemReferenceForCallIDs(req, []string{"call_1"}))
- require.True(t, HasItemReferenceForCallIDs(req, []string{"call_1", "call_2"}))
- require.False(t, HasItemReferenceForCallIDs(req, []string{"call_1", "call_3"}))
- }
- func TestValidateFunctionCallOutputContextBytesMatchesMapValidation(t *testing.T) {
- // handler 预校验走 raw JSON 扫描,语义必须与 service 内部 map 校验保持一致。
- cases := []struct {
- name string
- body map[string]any
- }{
- {
- name: "no_input",
- body: map[string]any{"model": "gpt-5.4"},
- },
- {
- name: "missing_call_id",
- body: map[string]any{"input": []any{map[string]any{"type": "function_call_output"}}},
- },
- {
- name: "call_id_without_reference",
- body: map[string]any{"input": []any{map[string]any{"type": "function_call_output", "call_id": "call_1"}}},
- },
- {
- name: "matching_reference",
- body: map[string]any{"input": []any{
- map[string]any{"type": "function_call_output", "call_id": "call_1"},
- map[string]any{"type": "item_reference", "id": "call_1"},
- }},
- },
- {
- name: "partial_reference",
- body: map[string]any{"input": []any{
- map[string]any{"type": "function_call_output", "call_id": "call_1"},
- map[string]any{"type": "tool_search_output", "call_id": "call_2"},
- map[string]any{"type": "item_reference", "id": "call_1"},
- }},
- },
- {
- name: "tool_context",
- body: map[string]any{"input": []any{
- map[string]any{"type": "function_call_output", "call_id": "call_1"},
- map[string]any{"type": "function_call", "call_id": "call_1"},
- }},
- },
- {
- name: "all_codex_tool_outputs",
- body: map[string]any{"input": []any{
- map[string]any{"type": "function_call_output", "call_id": "call_function"},
- map[string]any{"type": "tool_search_output", "call_id": "call_search"},
- map[string]any{"type": "custom_tool_call_output", "call_id": "call_custom"},
- map[string]any{"type": "mcp_tool_call_output", "call_id": "call_mcp"},
- map[string]any{"type": "item_reference", "id": "call_function"},
- map[string]any{"type": "item_reference", "id": "call_search"},
- map[string]any{"type": "item_reference", "id": "call_custom"},
- map[string]any{"type": "item_reference", "id": "call_mcp"},
- }},
- },
- }
- for _, tt := range cases {
- t.Run(tt.name, func(t *testing.T) {
- bodyBytes, err := json.Marshal(tt.body)
- require.NoError(t, err)
- require.Equal(t, ValidateFunctionCallOutputContext(tt.body), ValidateFunctionCallOutputContextBytes(bodyBytes))
- })
- }
- }
- func TestAnalyzeToolCallOutputContextCoverageBytes(t *testing.T) {
- cases := []struct {
- name string
- body map[string]any
- hasOutput bool
- coversAllIDs bool
- }{
- {
- name: "no_input",
- body: map[string]any{"model": "gpt-5.1"},
- hasOutput: false,
- coversAllIDs: false,
- },
- {
- name: "no_tool_output",
- body: map[string]any{"input": []any{
- map[string]any{"type": "message", "content": "hi"},
- }},
- hasOutput: false,
- coversAllIDs: false,
- },
- {
- name: "object_tool_output_requires_context_replay",
- body: map[string]any{"input": map[string]any{
- "type": "custom_tool_call_output", "call_id": "call_a",
- }},
- hasOutput: true,
- coversAllIDs: false,
- },
- {
- name: "all_outputs_covered_by_context",
- body: map[string]any{"input": []any{
- map[string]any{"type": "function_call", "call_id": "call_a"},
- map[string]any{"type": "function_call_output", "call_id": "call_a"},
- }},
- hasOutput: true,
- coversAllIDs: true,
- },
- {
- name: "all_outputs_covered_by_item_reference",
- body: map[string]any{"input": []any{
- map[string]any{"type": "function_call_output", "call_id": "call_a"},
- map[string]any{"type": "item_reference", "id": "call_a"},
- }},
- hasOutput: true,
- coversAllIDs: true,
- },
- {
- // 关键回归用例:input 内存在某一个上下文项,但另一个输出的 call_id
- // 只能由上游会话链(previous_response_id)解析——不可剥离。
- name: "partial_coverage_not_movable",
- body: map[string]any{"input": []any{
- map[string]any{"type": "function_call", "call_id": "call_a"},
- map[string]any{"type": "function_call_output", "call_id": "call_a"},
- map[string]any{"type": "function_call_output", "call_id": "call_b"},
- }},
- hasOutput: true,
- coversAllIDs: false,
- },
- {
- name: "unrelated_context_does_not_cover",
- body: map[string]any{"input": []any{
- map[string]any{"type": "function_call", "call_id": "call_x"},
- map[string]any{"type": "function_call_output", "call_id": "call_b"},
- }},
- hasOutput: true,
- coversAllIDs: false,
- },
- {
- name: "output_missing_call_id_not_movable",
- body: map[string]any{"input": []any{
- map[string]any{"type": "function_call", "call_id": "call_a"},
- map[string]any{"type": "function_call_output"},
- map[string]any{"type": "function_call_output", "call_id": "call_a"},
- }},
- hasOutput: true,
- coversAllIDs: false,
- },
- {
- name: "mixed_context_and_reference_cover_all",
- body: map[string]any{"input": []any{
- map[string]any{"type": "function_call", "call_id": "call_a"},
- map[string]any{"type": "function_call_output", "call_id": "call_a"},
- map[string]any{"type": "function_call_output", "call_id": "call_b"},
- map[string]any{"type": "item_reference", "id": "call_b"},
- }},
- hasOutput: true,
- coversAllIDs: true,
- },
- {
- name: "all_codex_output_types_covered",
- body: map[string]any{"input": []any{
- map[string]any{"type": "tool_search_output", "call_id": "call_s"},
- map[string]any{"type": "tool_search_call", "call_id": "call_s"},
- map[string]any{"type": "mcp_tool_call_output", "call_id": "call_m"},
- map[string]any{"type": "mcp_tool_call", "call_id": "call_m"},
- }},
- hasOutput: true,
- coversAllIDs: true,
- },
- }
- for _, tt := range cases {
- t.Run(tt.name, func(t *testing.T) {
- bodyBytes, err := json.Marshal(tt.body)
- require.NoError(t, err)
- coverage := AnalyzeToolCallOutputContextCoverageBytes(bodyBytes)
- require.Equal(t, tt.hasOutput, coverage.HasFunctionCallOutput, "HasFunctionCallOutput")
- require.Equal(t, tt.coversAllIDs, coverage.ContextCoversAllCallIDs, "ContextCoversAllCallIDs")
- })
- }
- }
|