package schemas import ( "encoding/json" "math" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // These tests use schemas.Marshal/Unmarshal (sonic) to verify round-trip // behavior matches what the production pipeline actually does. // --- ChatToolChoiceStruct --- func TestSonic_ChatToolChoiceStruct_FunctionVariant(t *testing.T) { input := `{"type":"function","function":{"name":"AnswerResponseModel"}}` var s ChatToolChoiceStruct err := Unmarshal([]byte(input), &s) require.NoError(t, err) assert.Equal(t, ChatToolChoiceTypeFunction, s.Type) assert.NotNil(t, s.Function) assert.Equal(t, "AnswerResponseModel", s.Function.Name) assert.Nil(t, s.Custom, "Custom should be nil for function variant") assert.Nil(t, s.AllowedTools, "AllowedTools should be nil for function variant") output, err := Marshal(s) require.NoError(t, err) // Verify no extra fields assert.NotContains(t, string(output), `"custom"`) assert.NotContains(t, string(output), `"allowed_tools"`) // Verify type comes first typeIdx := strings.Index(string(output), `"type"`) funcIdx := strings.Index(string(output), `"function"`) assert.Greater(t, funcIdx, typeIdx, "type should come before function in output") } func TestSonic_ChatToolChoiceStruct_CustomVariant(t *testing.T) { input := `{"type":"custom","custom":{"name":"my_tool"}}` var s ChatToolChoiceStruct err := Unmarshal([]byte(input), &s) require.NoError(t, err) assert.Equal(t, ChatToolChoiceTypeCustom, s.Type) assert.NotNil(t, s.Custom) assert.Equal(t, "my_tool", s.Custom.Name) assert.Nil(t, s.Function, "Function should be nil for custom variant") assert.Nil(t, s.AllowedTools, "AllowedTools should be nil for custom variant") output, err := Marshal(s) require.NoError(t, err) assert.NotContains(t, string(output), `"function"`) assert.NotContains(t, string(output), `"allowed_tools"`) } func TestSonic_ChatToolChoiceStruct_AllowedToolsVariant(t *testing.T) { input := `{"type":"allowed_tools","allowed_tools":{"mode":"auto","tools":[{"type":"function","function":{"name":"search"}}]}}` var s ChatToolChoiceStruct err := Unmarshal([]byte(input), &s) require.NoError(t, err) assert.Equal(t, ChatToolChoiceTypeAllowedTools, s.Type) assert.NotNil(t, s.AllowedTools) assert.Equal(t, "auto", s.AllowedTools.Mode) assert.Nil(t, s.Function, "Function should be nil for allowed_tools variant") assert.Nil(t, s.Custom, "Custom should be nil for allowed_tools variant") output, err := Marshal(s) require.NoError(t, err) // Verify the top-level struct doesn't have "function" or "custom" as direct keys // (note: "function" does appear INSIDE the allowed_tools.tools array, which is expected) assert.NotContains(t, string(output), `"custom"`) // Check that "function" only appears inside the tools array, not as a top-level key outputStr := string(output) topLevelFuncIdx := strings.Index(outputStr, `{"type":"allowed_tools"`) require.NotEqual(t, -1, topLevelFuncIdx) // The output should start with {"type":"allowed_tools","allowed_tools":...} assert.True(t, strings.HasPrefix(outputStr, `{"type":"allowed_tools","allowed_tools":`), "output should only have type and allowed_tools keys, got: %s", outputStr) } func TestSonic_ChatToolChoice_UnionRoundTrip(t *testing.T) { // Test the ChatToolChoice union type (string or struct) tests := []struct { name string input string }{ {"string_auto", `"auto"`}, {"string_none", `"none"`}, {"string_required", `"required"`}, {"struct_function", `{"type":"function","function":{"name":"my_func"}}`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var tc ChatToolChoice err := Unmarshal([]byte(tt.input), &tc) require.NoError(t, err) output, err := Marshal(tc) require.NoError(t, err) if strings.HasPrefix(tt.input, `"`) { // String variant assert.Equal(t, tt.input, string(output)) } else { // Struct variant - verify no extra fields assert.NotContains(t, string(output), `"custom"`) assert.NotContains(t, string(output), `"allowed_tools"`) } }) } } // --- OrderedMap through sonic --- func TestSonic_OrderedMap_PreservesKeyOrder(t *testing.T) { input := `{"answer":"string","chain_of_thought":"string","citations":"array","is_unanswered":"boolean"}` var om OrderedMap err := Unmarshal([]byte(input), &om) require.NoError(t, err) assert.Equal(t, []string{"answer", "chain_of_thought", "citations", "is_unanswered"}, om.Keys()) output, err := Marshal(om) require.NoError(t, err) assert.Equal(t, input, string(output)) } func TestSonic_OrderedMap_NestedPreservesOrder(t *testing.T) { input := `{"z_outer":{"b_inner":1,"a_inner":2},"a_outer":"simple"}` var om OrderedMap err := Unmarshal([]byte(input), &om) require.NoError(t, err) assert.Equal(t, []string{"z_outer", "a_outer"}, om.Keys()) nested, ok := om.Get("z_outer") require.True(t, ok) nestedOM, ok := nested.(*OrderedMap) require.True(t, ok) assert.Equal(t, []string{"b_inner", "a_inner"}, nestedOM.Keys()) output, err := Marshal(om) require.NoError(t, err) assert.Equal(t, input, string(output)) } func TestSonic_EmbeddingStruct_PreservesFloat64Precision(t *testing.T) { const want = 0.12345678901234568 var embedding EmbeddingStruct err := embedding.UnmarshalJSON([]byte(`[0.12345678901234568]`)) require.NoError(t, err) require.Len(t, embedding.EmbeddingArray, 1) got := embedding.EmbeddingArray[0] assert.Equal(t, want, got) float32Rounded := float64(float32(want)) assert.NotEqual(t, float32Rounded, got) marshaled, err := embedding.MarshalJSON() require.NoError(t, err) var roundTrip []float64 err = Unmarshal(marshaled, &roundTrip) require.NoError(t, err) require.Len(t, roundTrip, 1) assert.Equal(t, math.Float64bits(got), math.Float64bits(roundTrip[0])) } // --- ToolFunctionParameters through sonic --- func TestSonic_ToolFunctionParameters_PreservesPropertyOrder(t *testing.T) { input := `{"type":"object","properties":{"answer":{"type":"string"},"chain_of_thought":{"type":"string"},"citations":{"type":"array"},"is_unanswered":{"type":"boolean"}},"required":["answer"]}` var params ToolFunctionParameters err := Unmarshal([]byte(input), ¶ms) require.NoError(t, err) require.NotNil(t, params.Properties) assert.Equal(t, []string{"answer", "chain_of_thought", "citations", "is_unanswered"}, params.Properties.Keys()) output, err := Marshal(params) require.NoError(t, err) // Re-parse to check properties order var roundTripped ToolFunctionParameters err = Unmarshal(output, &roundTripped) require.NoError(t, err) assert.Equal(t, params.Properties.Keys(), roundTripped.Properties.Keys()) } func TestSonic_ToolFunctionParameters_PreservesDefsPosition(t *testing.T) { // $defs at the TOP of the parameters object input := `{"$defs":{"Citation":{"type":"object","properties":{"url":{"type":"string"}}}},"type":"object","properties":{"answer":{"type":"string"}},"required":["answer"]}` var params ToolFunctionParameters err := Unmarshal([]byte(input), ¶ms) require.NoError(t, err) require.NotNil(t, params.Defs) output, err := Marshal(params) require.NoError(t, err) // Verify $defs comes first in output (as in input) keys := ExtractTopLevelKeyOrder(output) require.NotEmpty(t, keys) assert.Equal(t, "$defs", keys[0], "$defs should be first key in output, got keys: %v", keys) } func TestSonic_ToolFunctionParameters_FullSchemaRoundTrip(t *testing.T) { // A realistic tool schema with $defs at top, specific property order input := `{"$defs":{"Citation":{"type":"object","properties":{"url":{"type":"string"},"text":{"type":"string"}},"required":["url","text"]}},"properties":{"answer":{"type":"string","description":"The answer"},"chain_of_thought":{"type":"string","description":"Reasoning"},"citations":{"type":"array","items":{"$ref":"#/$defs/Citation"}},"is_unanswered":{"type":"boolean"}},"required":["answer","is_unanswered"],"type":"object"}` var params ToolFunctionParameters err := Unmarshal([]byte(input), ¶ms) require.NoError(t, err) output, err := Marshal(params) require.NoError(t, err) // Verify top-level key order matches input inputKeys := ExtractTopLevelKeyOrder([]byte(input)) outputKeys := ExtractTopLevelKeyOrder(output) assert.Equal(t, inputKeys, outputKeys, "top-level key order should be preserved") // Verify properties key order assert.Equal(t, []string{"answer", "chain_of_thought", "citations", "is_unanswered"}, params.Properties.Keys()) } // --- ChatTool end-to-end through sonic --- func TestSonic_ChatTool_ToolFunctionParametersPreservesOrder(t *testing.T) { // Test that ToolFunctionParameters within a ChatTool preserves order input := `{"type":"function","function":{"name":"AnswerResponseModel","parameters":{"$defs":{"Citation":{"type":"object"}},"type":"object","properties":{"answer":{"type":"string"},"chain_of_thought":{"type":"string"},"citations":{"type":"array"},"is_unanswered":{"type":"boolean"}},"required":["answer"]}}}` var tool ChatTool err := Unmarshal([]byte(input), &tool) require.NoError(t, err) require.NotNil(t, tool.Function) require.NotNil(t, tool.Function.Parameters) assert.Equal(t, []string{"answer", "chain_of_thought", "citations", "is_unanswered"}, tool.Function.Parameters.Properties.Keys()) output, err := Marshal(tool) require.NoError(t, err) // Re-parse and verify var roundTripped ChatTool err = Unmarshal(output, &roundTripped) require.NoError(t, err) assert.Equal(t, tool.Function.Parameters.Properties.Keys(), roundTripped.Function.Parameters.Properties.Keys()) // Verify $defs position in parameters paramKeys := ExtractTopLevelKeyOrder(output) // Find the parameters JSON within the output to check its key order var toolMap map[string]interface{} err = Unmarshal(output, &toolMap) require.NoError(t, err) _ = paramKeys // top-level tool keys don't need ordering check // Re-marshal just the parameters to check its key order paramOutput, err := Marshal(tool.Function.Parameters) require.NoError(t, err) paramOutputKeys := ExtractTopLevelKeyOrder(paramOutput) assert.Equal(t, "$defs", paramOutputKeys[0], "parameters should have $defs first") } // --- Normalized() property ordering tests --- func TestNormalized_PreservesPropertyOrder_CoTBeforeAnswer(t *testing.T) { // The exact customer schema: chain_of_thought before answer params := &ToolFunctionParameters{ Type: "object", Properties: NewOrderedMapFromPairs( KV("chain_of_thought", NewOrderedMapFromPairs( KV("description", "Step by step reasoning"), KV("type", "string"), KV("title", "Chain of Thought"), )), KV("answer", NewOrderedMapFromPairs( KV("description", "The detailed answer"), KV("type", "string"), KV("title", "Answer"), )), KV("citations", NewOrderedMapFromPairs( KV("description", "Supporting citations"), KV("type", "array"), )), KV("is_unanswered", NewOrderedMapFromPairs( KV("type", "boolean"), KV("title", "Is Unanswered"), )), ), Required: []string{"chain_of_thought", "answer", "citations", "is_unanswered"}, } normalized := params.Normalized() // CoT: property order preserved assert.Equal(t, []string{"chain_of_thought", "answer", "citations", "is_unanswered"}, normalized.Properties.Keys()) // Caching: structural keys within each property are sorted by JSON Schema priority cot, _ := normalized.Properties.Get("chain_of_thought") cotOM := cot.(*OrderedMap) assert.Equal(t, []string{"type", "description", "title"}, cotOM.Keys(), "structural keys within property should be sorted: type > description > others alpha") // Immutability: original unchanged assert.Equal(t, []string{"chain_of_thought", "answer", "citations", "is_unanswered"}, params.Properties.Keys()) } func TestNormalized_CachingDeterminism_DifferentStructuralOrder(t *testing.T) { // Two schemas with same properties but different structural key orders // Should produce identical JSON after normalization propsA := NewOrderedMapFromPairs( KV("reasoning", NewOrderedMapFromPairs( KV("type", "string"), KV("description", "Step by step"), )), KV("answer", NewOrderedMapFromPairs( KV("type", "string"), KV("description", "Final answer"), )), ) propsB := NewOrderedMapFromPairs( KV("reasoning", NewOrderedMapFromPairs( KV("description", "Step by step"), KV("type", "string"), )), KV("answer", NewOrderedMapFromPairs( KV("description", "Final answer"), KV("type", "string"), )), ) schemaA := &ToolFunctionParameters{Type: "object", Properties: propsA, Required: []string{"reasoning"}} schemaB := &ToolFunctionParameters{Type: "object", Properties: propsB, Required: []string{"reasoning"}} jsonA, err := Marshal(schemaA.Normalized()) require.NoError(t, err) jsonB, err := Marshal(schemaB.Normalized()) require.NoError(t, err) // Caching: identical JSON regardless of input structural key order assert.Equal(t, string(jsonA), string(jsonB), "same schema with different structural key order should produce identical JSON") // CoT: property order preserved in both normA := schemaA.Normalized() normB := schemaB.Normalized() assert.Equal(t, []string{"reasoning", "answer"}, normA.Properties.Keys()) assert.Equal(t, []string{"reasoning", "answer"}, normB.Properties.Keys()) } func TestNormalized_WithDefs_PropertiesPreserved(t *testing.T) { params := &ToolFunctionParameters{ Type: "object", Defs: NewOrderedMapFromPairs( KV("Citation", NewOrderedMapFromPairs( KV("type", "object"), KV("properties", NewOrderedMapFromPairs( KV("url", NewOrderedMapFromPairs(KV("type", "string"))), KV("text", NewOrderedMapFromPairs(KV("type", "string"))), )), )), ), Properties: NewOrderedMapFromPairs( KV("chain_of_thought", NewOrderedMapFromPairs(KV("type", "string"))), KV("answer", NewOrderedMapFromPairs(KV("type", "string"))), KV("citations", NewOrderedMapFromPairs(KV("type", "array"))), KV("is_unanswered", NewOrderedMapFromPairs(KV("type", "boolean"))), ), Required: []string{"answer", "is_unanswered"}, } normalized := params.Normalized() // CoT: properties order preserved assert.Equal(t, []string{"chain_of_thought", "answer", "citations", "is_unanswered"}, normalized.Properties.Keys()) // CoT: properties within $defs preserved citation, _ := normalized.Defs.Get("Citation") citOM := citation.(*OrderedMap) citProps, _ := citOM.Get("properties") citPropsOM := citProps.(*OrderedMap) assert.Equal(t, []string{"url", "text"}, citPropsOM.Keys()) } func TestNormalized_NestedObjectProperties_PreservedAtAllLevels(t *testing.T) { params := &ToolFunctionParameters{ Type: "object", Properties: NewOrderedMapFromPairs( KV("output", NewOrderedMapFromPairs( KV("type", "object"), KV("properties", NewOrderedMapFromPairs( KV("verdict", NewOrderedMapFromPairs(KV("type", "string"))), KV("metadata", NewOrderedMapFromPairs( KV("type", "object"), KV("properties", NewOrderedMapFromPairs( KV("timestamp", NewOrderedMapFromPairs(KV("type", "string"))), KV("source", NewOrderedMapFromPairs(KV("type", "string"))), KV("confidence", NewOrderedMapFromPairs(KV("type", "number"))), KV("author", NewOrderedMapFromPairs(KV("type", "string"))), )), )), KV("score", NewOrderedMapFromPairs(KV("type", "number"))), )), )), KV("chain_of_thought", NewOrderedMapFromPairs(KV("type", "string"))), KV("answer", NewOrderedMapFromPairs(KV("type", "string"))), ), } normalized := params.Normalized() // Level 1: top-level properties preserved assert.Equal(t, []string{"output", "chain_of_thought", "answer"}, normalized.Properties.Keys()) // Level 2: output.properties preserved output, _ := normalized.Properties.Get("output") outputOM := output.(*OrderedMap) outputProps, _ := outputOM.Get("properties") outputPropsOM := outputProps.(*OrderedMap) assert.Equal(t, []string{"verdict", "metadata", "score"}, outputPropsOM.Keys()) // Level 3: metadata.properties preserved meta, _ := outputPropsOM.Get("metadata") metaOM := meta.(*OrderedMap) metaProps, _ := metaOM.Get("properties") metaPropsOM := metaProps.(*OrderedMap) assert.Equal(t, []string{"timestamp", "source", "confidence", "author"}, metaPropsOM.Keys()) } func TestNormalized_OriginalNotMutated(t *testing.T) { params := &ToolFunctionParameters{ Type: "object", Properties: NewOrderedMapFromPairs( KV("zebra", NewOrderedMapFromPairs( KV("description", "last alpha"), KV("type", "string"), )), KV("alpha", NewOrderedMapFromPairs( KV("description", "first alpha"), KV("type", "number"), )), ), } _ = params.Normalized() // Original property order unchanged assert.Equal(t, []string{"zebra", "alpha"}, params.Properties.Keys()) // Original structural key order within properties unchanged zebra, _ := params.Properties.Get("zebra") zebraOM := zebra.(*OrderedMap) assert.Equal(t, []string{"description", "type"}, zebraOM.Keys()) } // --- Caching regression tests --- func TestNormalized_CachingRegression_PropertyOrderDoesNotAffectCache(t *testing.T) { // Three independently constructed schemas with the SAME properties and // SAME structural key order. All three must produce byte-identical JSON. // This proves normalization is deterministic (no Go map iteration randomness). makeSchema := func() *ToolFunctionParameters { return &ToolFunctionParameters{ Type: "object", Properties: NewOrderedMapFromPairs( KV("chain_of_thought", NewOrderedMapFromPairs( KV("type", "string"), KV("description", "Reasoning steps"), )), KV("answer", NewOrderedMapFromPairs( KV("type", "string"), KV("description", "The answer"), )), ), Required: []string{"chain_of_thought", "answer"}, } } jsonA, err := Marshal(makeSchema().Normalized()) require.NoError(t, err) jsonB, err := Marshal(makeSchema().Normalized()) require.NoError(t, err) jsonC, err := Marshal(makeSchema().Normalized()) require.NoError(t, err) assert.Equal(t, string(jsonA), string(jsonB), "first two normalizations must be identical") assert.Equal(t, string(jsonB), string(jsonC), "all three normalizations must be identical") } func TestNormalized_CachingRegression_FullToolMarshal(t *testing.T) { // Tests the complete serialization path: ChatTool → ToolFunctionParameters.MarshalJSON // This is what actually hits the wire and forms the cache key. tool := ChatTool{ Type: "function", Function: &ChatToolFunction{ Name: "AnswerResponseModel", Description: Ptr("Correctly extracted response model"), Parameters: &ToolFunctionParameters{ Type: "object", Properties: NewOrderedMapFromPairs( KV("chain_of_thought", NewOrderedMapFromPairs( KV("description", "Step by step chain of thought"), KV("title", "Chain of Thought"), KV("type", "string"), )), KV("answer", NewOrderedMapFromPairs( KV("description", "The detailed answer"), KV("title", "Answer"), KV("type", "string"), )), KV("is_unanswered", NewOrderedMapFromPairs( KV("title", "Is Unanswered"), KV("type", "boolean"), )), KV("citations", NewOrderedMapFromPairs( KV("description", "List of citations"), KV("type", "array"), )), ), Required: []string{"answer", "chain_of_thought", "citations", "is_unanswered"}, }, }, } // Normalize and marshal twice normalizedParams := tool.Function.Parameters.Normalized() toolCopy1 := tool funcCopy1 := *tool.Function funcCopy1.Parameters = normalizedParams toolCopy1.Function = &funcCopy1 normalizedParams2 := tool.Function.Parameters.Normalized() toolCopy2 := tool funcCopy2 := *tool.Function funcCopy2.Parameters = normalizedParams2 toolCopy2.Function = &funcCopy2 json1, err := Marshal(toolCopy1) require.NoError(t, err) json2, err := Marshal(toolCopy2) require.NoError(t, err) // Caching: full tool JSON is byte-identical assert.Equal(t, string(json1), string(json2), "full ChatTool marshal must be deterministic for prompt caching") // CoT: verify property order in the serialized JSON // Parse back and check properties key order var roundTripped ChatTool err = Unmarshal(json1, &roundTripped) require.NoError(t, err) keys := roundTripped.Function.Parameters.Properties.Keys() assert.Equal(t, []string{"chain_of_thought", "answer", "is_unanswered", "citations"}, keys, "property order must be preserved through full marshal round-trip") } // --- ResponsesTool deterministic serialization tests --- // TestResponsesTool_MarshalJSON_Deterministic verifies that marshaling the same // ResponsesTool struct produces byte-identical JSON every time. This is critical for // OpenAI's prefix-based prompt caching — non-deterministic tool serialization // would invalidate the cache on every other call. func TestResponsesTool_MarshalJSON_Deterministic(t *testing.T) { tools := []ResponsesTool{ { Type: ResponsesToolTypeFunction, Name: Ptr("weather"), Description: Ptr("Get current weather"), CacheControl: &CacheControl{ Type: CacheControlTypeEphemeral, }, ResponsesToolFunction: &ResponsesToolFunction{ Parameters: &ToolFunctionParameters{ Type: "object", Properties: NewOrderedMapFromPairs( KV("location", NewOrderedMapFromPairs( KV("type", "string"), KV("description", "City name"), )), KV("unit", NewOrderedMapFromPairs( KV("type", "string"), KV("enum", []string{"celsius", "fahrenheit"}), )), ), Required: []string{"location"}, }, Strict: Ptr(true), }, }, { Type: ResponsesToolTypeFileSearch, ResponsesToolFileSearch: &ResponsesToolFileSearch{VectorStoreIDs: []string{"vs_1", "vs_2"}}, }, { Type: ResponsesToolTypeWebSearch, Description: Ptr("Search the web"), ResponsesToolWebSearch: &ResponsesToolWebSearch{ SearchContextSize: Ptr("medium"), }, }, { Type: ResponsesToolTypeComputerUsePreview, ResponsesToolComputerUsePreview: &ResponsesToolComputerUsePreview{ DisplayWidth: 1024, DisplayHeight: 768, Environment: "browser", }, }, } for _, tool := range tools { t.Run(string(tool.Type), func(t *testing.T) { first, err := Marshal(tool) require.NoError(t, err, "first marshal should succeed") for i := 0; i < 100; i++ { got, err := Marshal(tool) require.NoError(t, err) require.Equal(t, string(first), string(got), "iteration %d: marshal produced different bytes.\nfirst: %s\ngot: %s", i, string(first), string(got)) } }) } } // TestResponsesTool_MarshalJSON_ContentPreservation verifies that the sjson-based // MarshalJSON produces JSON with all expected fields and values. func TestResponsesTool_MarshalJSON_ContentPreservation(t *testing.T) { tests := []struct { name string tool ResponsesTool wantContains []string // substrings that must appear in JSON }{ { name: "function_with_all_common_fields", tool: ResponsesTool{ Type: ResponsesToolTypeFunction, Name: Ptr("search_db"), Description: Ptr("Search database"), CacheControl: &CacheControl{ Type: CacheControlTypeEphemeral, }, ResponsesToolFunction: &ResponsesToolFunction{ Strict: Ptr(false), }, }, wantContains: []string{ `"type":"function"`, `"name":"search_db"`, `"description":"Search database"`, `"cache_control":{"type":"ephemeral"}`, `"strict":false`, }, }, { name: "function_with_parameters", tool: ResponsesTool{ Type: ResponsesToolTypeFunction, Name: Ptr("get_weather"), ResponsesToolFunction: &ResponsesToolFunction{ Parameters: &ToolFunctionParameters{ Type: "object", Properties: NewOrderedMapFromPairs( KV("location", NewOrderedMapFromPairs( KV("type", "string"), )), ), }, Strict: Ptr(true), }, }, wantContains: []string{ `"type":"function"`, `"name":"get_weather"`, `"parameters":{`, `"location":{`, `"strict":true`, }, }, { name: "file_search_tool", tool: ResponsesTool{ Type: ResponsesToolTypeFileSearch, ResponsesToolFileSearch: &ResponsesToolFileSearch{ VectorStoreIDs: []string{"vs_123"}, MaxNumResults: Ptr(10), }, }, wantContains: []string{ `"type":"file_search"`, `"vector_store_ids":["vs_123"]`, `"max_num_results":10`, }, }, { name: "web_search_tool", tool: ResponsesTool{ Type: ResponsesToolTypeWebSearch, Description: Ptr("Web search tool"), ResponsesToolWebSearch: &ResponsesToolWebSearch{ SearchContextSize: Ptr("high"), }, }, wantContains: []string{ `"type":"web_search"`, `"description":"Web search tool"`, `"search_context_size":"high"`, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { data, err := Marshal(tt.tool) require.NoError(t, err) jsonStr := string(data) for _, want := range tt.wantContains { assert.Contains(t, jsonStr, want, "JSON should contain %q, got: %s", want, jsonStr) } }) } } // TestResponsesTool_MarshalJSON_RoundTrip verifies that unmarshal→marshal→unmarshal // produces structurally identical results. func TestResponsesTool_MarshalJSON_RoundTrip(t *testing.T) { inputs := []string{ `{"type":"function","name":"get_weather","description":"Get weather","strict":true}`, `{"type":"function","name":"search_db","description":"Search database","cache_control":{"type":"ephemeral"},"strict":false}`, `{"type":"file_search","vector_store_ids":["vs_1"],"max_num_results":10}`, } for _, input := range inputs { name := input if len(name) > 50 { name = name[:50] } t.Run(name, func(t *testing.T) { // Round 1: unmarshal → marshal var tool1 ResponsesTool require.NoError(t, Unmarshal([]byte(input), &tool1)) data1, err := Marshal(tool1) require.NoError(t, err) // Round 2: unmarshal → marshal var tool2 ResponsesTool require.NoError(t, Unmarshal(data1, &tool2)) data2, err := Marshal(tool2) require.NoError(t, err) // Round-trip stability: second marshal must match first require.Equal(t, string(data1), string(data2), "round-trip produced different bytes.\nround1: %s\nround2: %s", string(data1), string(data2)) // Content equivalence with original input var original, roundTripped map[string]interface{} require.NoError(t, Unmarshal([]byte(input), &original)) require.NoError(t, Unmarshal(data1, &roundTripped)) assert.Equal(t, original, roundTripped, "content should match original input") }) } } // TestResponsesTool_RoundTrip_AnthropicFields ensures the Anthropic-native tool // flags promoted onto ResponsesTool (defer_loading, allowed_callers, // input_examples, eager_input_streaming) survive a full Marshal→Unmarshal→ // Marshal cycle. Before MarshalJSON/UnmarshalJSON were taught to handle these // keys, all four were silently dropped at the JSON boundary. func TestResponsesTool_RoundTrip_AnthropicFields(t *testing.T) { original := ResponsesTool{ Type: ResponsesToolTypeFunction, Name: Ptr("lookup"), Description: Ptr("lookup something"), DeferLoading: Ptr(true), AllowedCallers: []string{"direct", "agent"}, EagerInputStreaming: Ptr(false), InputExamples: []ChatToolInputExample{ {Input: json.RawMessage(`{"q":"hello"}`), Description: Ptr("basic")}, {Input: json.RawMessage(`{"q":"world"}`)}, }, ResponsesToolFunction: &ResponsesToolFunction{ Parameters: &ToolFunctionParameters{}, }, } data, err := Marshal(original) require.NoError(t, err) // All four keys must appear in the wire bytes. for _, key := range []string{`"defer_loading"`, `"allowed_callers"`, `"input_examples"`, `"eager_input_streaming"`} { assert.Contains(t, string(data), key, "%s must be emitted by MarshalJSON — otherwise it is silently dropped", key) } var decoded ResponsesTool require.NoError(t, Unmarshal(data, &decoded)) require.NotNil(t, decoded.DeferLoading) assert.True(t, *decoded.DeferLoading) assert.Equal(t, []string{"direct", "agent"}, decoded.AllowedCallers) require.NotNil(t, decoded.EagerInputStreaming) assert.False(t, *decoded.EagerInputStreaming) require.Len(t, decoded.InputExamples, 2) assert.JSONEq(t, `{"q":"hello"}`, string(decoded.InputExamples[0].Input)) require.NotNil(t, decoded.InputExamples[0].Description) assert.Equal(t, "basic", *decoded.InputExamples[0].Description) assert.JSONEq(t, `{"q":"world"}`, string(decoded.InputExamples[1].Input)) // Second-round marshal must be byte-stable. data2, err := Marshal(decoded) require.NoError(t, err) assert.Equal(t, string(data), string(data2), "round-trip must be stable") } // TestChatTool_MarshalJSON_EnforcesUnion verifies that the custom codec // canonicalizes mixed-state ChatTools on the wire, regardless of what the // caller populated in memory. Exactly one variant's fields survive marshal — // matching Type — so downstream provider converters can't misinterpret or // forward stray fields from a different shape. func TestChatTool_MarshalJSON_EnforcesUnion(t *testing.T) { t.Run("function_type_clears_custom_and_server_tool_fields", func(t *testing.T) { tool := ChatTool{ Type: ChatToolTypeFunction, Function: &ChatToolFunction{Name: "get_weather"}, // Mixed state: server-tool + custom fields also populated. Custom: &ChatToolCustom{}, Name: "leaked_name", MaxUses: Ptr(5), DisplayWidthPx: Ptr(1280), MCPServerName: "leaked_server", } data, err := Marshal(tool) require.NoError(t, err) raw := string(data) assert.Contains(t, raw, `"type":"function"`) assert.Contains(t, raw, `"get_weather"`) for _, leak := range []string{`"custom"`, `"leaked_name"`, `"max_uses"`, `"display_width_px"`, `"mcp_server_name"`} { assert.NotContains(t, raw, leak, "function-type wire must not carry %s", leak) } }) t.Run("custom_type_clears_function_and_server_tool_fields", func(t *testing.T) { tool := ChatTool{ Type: ChatToolTypeCustom, Custom: &ChatToolCustom{Format: &ChatToolCustomFormat{Type: "text"}}, Name: "my_custom", // Leaks Function: &ChatToolFunction{Name: "should_be_stripped"}, MaxUses: Ptr(5), } data, err := Marshal(tool) require.NoError(t, err) raw := string(data) assert.Contains(t, raw, `"type":"custom"`) assert.Contains(t, raw, `"my_custom"`) // custom tool retains top-level Name assert.Contains(t, raw, `"format"`) // custom's format field assert.NotContains(t, raw, `"function"`) assert.NotContains(t, raw, `"should_be_stripped"`) assert.NotContains(t, raw, `"max_uses"`) }) t.Run("server_tool_type_clears_function_and_custom", func(t *testing.T) { tool := ChatTool{ Type: "web_search_20260209", Name: "web_search", MaxUses: Ptr(5), AllowedCallers: []string{"direct"}, // Leaks Function: &ChatToolFunction{Name: "should_be_stripped"}, Custom: &ChatToolCustom{}, } data, err := Marshal(tool) require.NoError(t, err) raw := string(data) assert.Contains(t, raw, `"type":"web_search_20260209"`) assert.Contains(t, raw, `"web_search"`) assert.Contains(t, raw, `"max_uses":5`) assert.Contains(t, raw, `"allowed_callers":["direct"]`) assert.NotContains(t, raw, `"function"`) assert.NotContains(t, raw, `"custom"`) assert.NotContains(t, raw, `"should_be_stripped"`) }) } // TestChatTool_UnmarshalJSON_NormalizesMixedInput verifies that tolerant // decode of a mixed-shape payload produces a canonical single-variant struct // so downstream provider conversion code doesn't have to defend against // the untrusted shape. func TestChatTool_UnmarshalJSON_NormalizesMixedInput(t *testing.T) { t.Run("function_type_mixed_with_server_fields_normalizes", func(t *testing.T) { // Caller sends a function tool but also includes server-tool metadata. raw := []byte(`{ "type":"function", "function":{"name":"get_weather"}, "name":"stray_server_name", "max_uses":5, "display_width_px":1280 }`) var tool ChatTool require.NoError(t, Unmarshal(raw, &tool)) assert.Equal(t, ChatToolTypeFunction, tool.Type) require.NotNil(t, tool.Function) assert.Equal(t, "get_weather", tool.Function.Name) assert.Empty(t, tool.Name, "function-type must nil top-level Name (lives in Function.Name)") assert.Nil(t, tool.MaxUses) assert.Nil(t, tool.DisplayWidthPx) }) t.Run("server_tool_type_mixed_with_function_normalizes", func(t *testing.T) { // Caller sends a server-tool but also includes function. raw := []byte(`{ "type":"web_search_20260209", "name":"web_search", "max_uses":5, "function":{"name":"stray"} }`) var tool ChatTool require.NoError(t, Unmarshal(raw, &tool)) assert.Equal(t, ChatToolType("web_search_20260209"), tool.Type) assert.Equal(t, "web_search", tool.Name) require.NotNil(t, tool.MaxUses) assert.Equal(t, 5, *tool.MaxUses) assert.Nil(t, tool.Function, "server-tool must nil Function") assert.Nil(t, tool.Custom, "server-tool must nil Custom") }) } // TestChatTool_RoundTrip_SurvivesMixedInput verifies that a mixed-input // payload, once canonicalized by Unmarshal and re-emitted by Marshal, drops // the stray fields and produces a deterministic single-variant wire format. func TestChatTool_RoundTrip_SurvivesMixedInput(t *testing.T) { raw := []byte(`{ "type":"web_search_20260209", "name":"web_search", "max_uses":5, "function":{"name":"stray"}, "custom":{"format":{"type":"text"}} }`) var tool ChatTool require.NoError(t, Unmarshal(raw, &tool)) out, err := Marshal(tool) require.NoError(t, err) outStr := string(out) assert.NotContains(t, outStr, `"function"`) assert.NotContains(t, outStr, `"custom"`) assert.Contains(t, outStr, `"web_search_20260209"`) // Second pass must be byte-stable (critical for prompt caching keys). var tool2 ChatTool require.NoError(t, Unmarshal(out, &tool2)) out2, err := Marshal(tool2) require.NoError(t, err) assert.Equal(t, string(out), string(out2), "round-trip must be stable") } func TestToolFunctionParameters_ExplicitEmptyObjectPreserved(t *testing.T) { var params ToolFunctionParameters err := Unmarshal([]byte(`{}`), ¶ms) require.NoError(t, err) marshaled, err := Marshal(params) require.NoError(t, err) assert.Equal(t, `{}`, string(marshaled)) normalized, err := Marshal(params.Normalized()) require.NoError(t, err) assert.Equal(t, `{}`, string(normalized)) } func TestToolFunctionParameters_ExplicitEmptyObjectWhitespacePreserved(t *testing.T) { var params ToolFunctionParameters err := Unmarshal([]byte(` { } `), ¶ms) require.NoError(t, err) marshaled, err := Marshal(params) require.NoError(t, err) assert.Equal(t, `{}`, string(marshaled)) normalized, err := Marshal(params.Normalized()) require.NoError(t, err) assert.Equal(t, `{}`, string(normalized)) } func TestToolFunctionParameters_ExplicitObjectSchemaPreserved(t *testing.T) { var params ToolFunctionParameters err := Unmarshal([]byte(`{"type":"object","properties":{}}`), ¶ms) require.NoError(t, err) marshaled, err := Marshal(params) require.NoError(t, err) assert.JSONEq(t, `{"type":"object","properties":{}}`, string(marshaled)) normalized, err := Marshal(params.Normalized()) require.NoError(t, err) assert.JSONEq(t, `{"type":"object","properties":{}}`, string(normalized)) } // TestResponsesToolFileSearchFilter_MarshalJSON_Deterministic verifies deterministic // serialization for file search filters. func TestResponsesToolFileSearchFilter_MarshalJSON_Deterministic(t *testing.T) { filters := []*ResponsesToolFileSearchFilter{ { Type: "eq", ResponsesToolFileSearchComparisonFilter: &ResponsesToolFileSearchComparisonFilter{ Key: "status", Value: "active", }, }, { Type: "and", ResponsesToolFileSearchCompoundFilter: &ResponsesToolFileSearchCompoundFilter{ Filters: []ResponsesToolFileSearchFilter{ { Type: "eq", ResponsesToolFileSearchComparisonFilter: &ResponsesToolFileSearchComparisonFilter{ Key: "type", Value: "document", }, }, }, }, }, } for _, filter := range filters { t.Run(filter.Type, func(t *testing.T) { first, err := Marshal(filter) require.NoError(t, err) for i := 0; i < 100; i++ { got, err := Marshal(filter) require.NoError(t, err) require.Equal(t, string(first), string(got), "iteration %d: marshal produced different bytes", i) } }) } } // TestResponsesToolMCPApprovalSetting_MarshalJSON_Deterministic verifies deterministic // serialization for MCP approval settings. func TestResponsesToolMCPApprovalSetting_MarshalJSON_Deterministic(t *testing.T) { settings := []ResponsesToolMCPAllowedToolsApprovalSetting{ { Setting: Ptr("always"), }, { Always: &ResponsesToolMCPAllowedToolsApprovalFilter{ ToolNames: []string{"tool1", "tool2"}, }, }, { Never: &ResponsesToolMCPAllowedToolsApprovalFilter{ ToolNames: []string{"dangerous_tool"}, }, }, { Always: &ResponsesToolMCPAllowedToolsApprovalFilter{ ToolNames: []string{"safe_tool"}, }, Never: &ResponsesToolMCPAllowedToolsApprovalFilter{ ToolNames: []string{"risky_tool"}, }, }, } for i, setting := range settings { t.Run(strings.Repeat("_", i), func(t *testing.T) { first, err := Marshal(setting) require.NoError(t, err) for j := 0; j < 100; j++ { got, err := Marshal(setting) require.NoError(t, err) require.Equal(t, string(first), string(got), "iteration %d: marshal produced different bytes", j) } }) } } // TestNetworkConfig_TLSFieldsRoundTrip verifies that insecure_skip_verify and ca_cert_pem // round-trip correctly through JSON marshaling (used by config.json). func TestNetworkConfig_TLSFieldsRoundTrip(t *testing.T) { nc := NetworkConfig{ BaseURL: "https://example.com", DefaultRequestTimeoutInSeconds: 60, MaxRetries: 3, InsecureSkipVerify: true, CACertPEM: "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----", } data, err := json.Marshal(nc) require.NoError(t, err) var decoded NetworkConfig err = json.Unmarshal(data, &decoded) require.NoError(t, err) assert.Equal(t, nc.InsecureSkipVerify, decoded.InsecureSkipVerify, "insecure_skip_verify should round-trip") assert.Equal(t, nc.CACertPEM, decoded.CACertPEM, "ca_cert_pem should round-trip") assert.Contains(t, string(data), `"insecure_skip_verify":true`) assert.Contains(t, string(data), `"ca_cert_pem"`) } // TestNetworkConfig_StreamIdleTimeoutRoundTrip verifies that stream_idle_timeout_in_seconds // round-trips correctly through JSON marshaling. func TestNetworkConfig_StreamIdleTimeoutRoundTrip(t *testing.T) { nc := NetworkConfig{ DefaultRequestTimeoutInSeconds: 30, StreamIdleTimeoutInSeconds: 120, } data, err := json.Marshal(nc) require.NoError(t, err) var decoded NetworkConfig err = json.Unmarshal(data, &decoded) require.NoError(t, err) assert.Equal(t, 120, decoded.StreamIdleTimeoutInSeconds, "stream_idle_timeout_in_seconds should round-trip") assert.Contains(t, string(data), `"stream_idle_timeout_in_seconds":120`) } // TestNormalizeResponsesToolType verifies that versioned/provider-specific tool type // strings are normalized to their canonical ResponsesToolType values. func TestNormalizeResponsesToolType(t *testing.T) { tests := []struct { input ResponsesToolType want ResponsesToolType }{ // Already canonical — returned unchanged {ResponsesToolTypeWebSearch, ResponsesToolTypeWebSearch}, {ResponsesToolTypeWebSearchPreview, ResponsesToolTypeWebSearchPreview}, {ResponsesToolTypeWebFetch, ResponsesToolTypeWebFetch}, {ResponsesToolTypeComputerUsePreview, ResponsesToolTypeComputerUsePreview}, {ResponsesToolTypeCodeInterpreter, ResponsesToolTypeCodeInterpreter}, {ResponsesToolTypeMemory, ResponsesToolTypeMemory}, {ResponsesToolTypeFunction, ResponsesToolTypeFunction}, {ResponsesToolTypeCustom, ResponsesToolTypeCustom}, // web_search versioned aliases {"web_search_20250305", ResponsesToolTypeWebSearch}, {"web_search_20260209", ResponsesToolTypeWebSearch}, {"web_search_2025_08_26", ResponsesToolTypeWebSearch}, // web_search_preview versioned aliases (must not collide with web_search) {"web_search_preview_2025_03_11", ResponsesToolTypeWebSearchPreview}, // web_fetch versioned aliases {"web_fetch_20250910", ResponsesToolTypeWebFetch}, {"web_fetch_20260209", ResponsesToolTypeWebFetch}, {"web_fetch_20260309", ResponsesToolTypeWebFetch}, // computer versioned aliases {"computer_20250124", ResponsesToolTypeComputerUsePreview}, {"computer_20251124", ResponsesToolTypeComputerUsePreview}, // code_execution versioned aliases → code_interpreter {"code_execution_20250522", ResponsesToolTypeCodeInterpreter}, {"code_execution_20250825", ResponsesToolTypeCodeInterpreter}, {"code_execution_20260120", ResponsesToolTypeCodeInterpreter}, // memory versioned aliases {"memory_20250818", ResponsesToolTypeMemory}, // Unrecognized types pass through unchanged {"totally_unknown", "totally_unknown"}, {"mcp", ResponsesToolTypeMCP}, } for _, tt := range tests { t.Run(string(tt.input), func(t *testing.T) { got := normalizeResponsesToolType(tt.input) assert.Equal(t, tt.want, got) }) } } // TestResponsesTool_UnmarshalJSON_NormalizesVersionedToolTypes verifies that versioned // tool types sent in Responses API requests are normalized and their embedded structs populated. func TestResponsesTool_UnmarshalJSON_NormalizesVersionedToolTypes(t *testing.T) { tests := []struct { name string input string wantType ResponsesToolType wantWebSearch bool wantWebFetch bool wantComputer bool wantCodeInterp bool }{ // web_search variants {name: "web_search canonical", input: `{"type":"web_search"}`, wantType: ResponsesToolTypeWebSearch, wantWebSearch: true}, {name: "web_search_20250305", input: `{"type":"web_search_20250305"}`, wantType: ResponsesToolTypeWebSearch, wantWebSearch: true}, {name: "web_search_20260209", input: `{"type":"web_search_20260209"}`, wantType: ResponsesToolTypeWebSearch, wantWebSearch: true}, {name: "web_search_20250305 with max_uses", input: `{"type":"web_search_20250305","max_uses":1}`, wantType: ResponsesToolTypeWebSearch, wantWebSearch: true}, // web_search_preview variants {name: "web_search_preview canonical", input: `{"type":"web_search_preview"}`, wantType: ResponsesToolTypeWebSearchPreview}, {name: "web_search_preview_2025_03_11", input: `{"type":"web_search_preview_2025_03_11"}`, wantType: ResponsesToolTypeWebSearchPreview}, // web_fetch variants {name: "web_fetch canonical", input: `{"type":"web_fetch"}`, wantType: ResponsesToolTypeWebFetch, wantWebFetch: true}, {name: "web_fetch_20250910", input: `{"type":"web_fetch_20250910"}`, wantType: ResponsesToolTypeWebFetch, wantWebFetch: true}, {name: "web_fetch_20260309", input: `{"type":"web_fetch_20260309"}`, wantType: ResponsesToolTypeWebFetch, wantWebFetch: true}, // computer variants {name: "computer_use_preview canonical", input: `{"type":"computer_use_preview","display_width":1024,"display_height":768,"environment":"browser"}`, wantType: ResponsesToolTypeComputerUsePreview, wantComputer: true}, {name: "computer_20250124", input: `{"type":"computer_20250124","display_width":1024,"display_height":768,"environment":"browser"}`, wantType: ResponsesToolTypeComputerUsePreview, wantComputer: true}, {name: "computer_20251124", input: `{"type":"computer_20251124","display_width":1024,"display_height":768,"environment":"browser"}`, wantType: ResponsesToolTypeComputerUsePreview, wantComputer: true}, // code_execution variants → code_interpreter {name: "code_interpreter canonical", input: `{"type":"code_interpreter"}`, wantType: ResponsesToolTypeCodeInterpreter, wantCodeInterp: true}, {name: "code_execution_20250522", input: `{"type":"code_execution_20250522"}`, wantType: ResponsesToolTypeCodeInterpreter, wantCodeInterp: true}, {name: "code_execution_20250825", input: `{"type":"code_execution_20250825"}`, wantType: ResponsesToolTypeCodeInterpreter, wantCodeInterp: true}, // unrecognized types pass through unchanged {name: "function unchanged", input: `{"type":"function","name":"foo","strict":true}`, wantType: ResponsesToolTypeFunction}, {name: "custom unchanged", input: `{"type":"custom","name":"bar"}`, wantType: ResponsesToolTypeCustom}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var tool ResponsesTool err := Unmarshal([]byte(tt.input), &tool) require.NoError(t, err) assert.Equal(t, tt.wantType, tool.Type) if tt.wantWebSearch { assert.NotNil(t, tool.ResponsesToolWebSearch, "ResponsesToolWebSearch should be populated") } if tt.wantWebFetch { assert.NotNil(t, tool.ResponsesToolWebFetch, "ResponsesToolWebFetch should be populated") } if tt.wantComputer { assert.NotNil(t, tool.ResponsesToolComputerUsePreview, "ResponsesToolComputerUsePreview should be populated") } if tt.wantCodeInterp { assert.NotNil(t, tool.ResponsesToolCodeInterpreter, "ResponsesToolCodeInterpreter should be populated") } }) } } // TestSonic_ChatTool_AnnotationsNeverSerialized verifies that MCPToolAnnotations // (json:"-") are never included in the JSON payload sent to providers. func TestSonic_ChatTool_AnnotationsNeverSerialized(t *testing.T) { readOnly := true destructive := false tool := ChatTool{ Type: ChatToolTypeFunction, Function: &ChatToolFunction{ Name: "read_file", Description: Ptr("Reads a file from the filesystem"), Parameters: &ToolFunctionParameters{ Type: "object", Properties: NewOrderedMapFromPairs(KV("path", map[string]interface{}{"type": "string"})), Required: []string{"path"}, }, }, Annotations: &MCPToolAnnotations{ Title: "File Reader", ReadOnlyHint: &readOnly, DestructiveHint: &destructive, IdempotentHint: Ptr(true), }, } output, err := Marshal(tool) require.NoError(t, err) s := string(output) // Annotations must be absent — json:"-" must suppress the entire field assert.NotContains(t, s, "annotations", "annotations field must not appear in provider payload") assert.NotContains(t, s, "readOnlyHint", "readOnlyHint must not appear in provider payload") assert.NotContains(t, s, "destructiveHint", "destructiveHint must not appear in provider payload") assert.NotContains(t, s, "idempotentHint", "idempotentHint must not appear in provider payload") assert.NotContains(t, s, "File Reader", "annotation title must not appear in provider payload") // The function definition itself must still be present assert.Contains(t, s, "read_file", "function name must be in payload") assert.Contains(t, s, "path", "parameter must be in payload") } // TestSonic_ChatTool_DeepCopy_AnnotationsPreserved verifies that DeepCopyChatTool // correctly copies Annotations so they survive any clone-based flows. func TestSonic_ChatTool_DeepCopy_AnnotationsPreserved(t *testing.T) { readOnly := true idempotent := false original := ChatTool{ Type: ChatToolTypeFunction, Function: &ChatToolFunction{ Name: "query_db", }, Annotations: &MCPToolAnnotations{ Title: "DB Query", ReadOnlyHint: &readOnly, IdempotentHint: &idempotent, }, } copied := DeepCopyChatTool(original) require.NotNil(t, copied.Annotations) assert.Equal(t, "DB Query", copied.Annotations.Title) assert.Equal(t, true, *copied.Annotations.ReadOnlyHint) assert.Equal(t, false, *copied.Annotations.IdempotentHint) assert.Nil(t, copied.Annotations.DestructiveHint) assert.Nil(t, copied.Annotations.OpenWorldHint) // Verify it's a true deep copy — mutations don't bleed back *original.Annotations.ReadOnlyHint = false assert.True(t, *copied.Annotations.ReadOnlyHint, "copy must not share pointer with original") } // TestSonic_ChatTool_DeepCopy_NilAnnotationsStaysNil verifies that a tool // without annotations deep-copies cleanly with Annotations remaining nil. func TestSonic_ChatTool_DeepCopy_NilAnnotationsStaysNil(t *testing.T) { original := ChatTool{ Type: ChatToolTypeFunction, Function: &ChatToolFunction{Name: "plain_tool"}, } copied := DeepCopyChatTool(original) assert.Nil(t, copied.Annotations, "Annotations should stay nil when original has none") }