package util import ( "encoding/json" "reflect" "strconv" "strings" "testing" "github.com/MoeForge/moego/ojson" ) func TestCleanJSONSchemaForAntigravity_ConstToEnum(t *testing.T) { input := `{ "type": "object", "properties": { "kind": { "type": "string", "const": "InsightVizNode" } } }` expected := `{ "type": "object", "properties": { "kind": { "type": "string", "description": "Allowed: InsightVizNode" } } }` result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable(t *testing.T) { input := `{ "type": "object", "properties": { "name": { "type": ["string", "null"] }, "other": { "type": "string" } }, "required": ["name", "other"] }` expected := `{ "type": "object", "properties": { "name": { "type": "string", "nullable": true, "description": "(nullable)" }, "other": { "type": "string" } }, "required": ["name", "other"] }` result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } func TestCleanJSONSchemaForAntigravity_ConstraintsToDescription(t *testing.T) { input := `{ "type": "object", "properties": { "tags": { "type": "array", "description": "List of tags", "minItems": 1 }, "name": { "type": "string", "description": "User name", "minLength": 3 } } }` result := CleanJSONSchemaForAntigravity(input) // minItems should be REMOVED and moved to description if strings.Contains(result, `"minItems"`) { t.Errorf("minItems keyword should be removed") } if !strings.Contains(result, "minItems: 1") { t.Errorf("minItems hint missing in description") } // minLength should be moved to description if !strings.Contains(result, "minLength: 3") { t.Errorf("minLength hint missing in description") } if strings.Contains(result, `"minLength":`) || strings.Contains(result, `"minLength" :`) { t.Errorf("minLength keyword should be removed") } } func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_SmartSelection(t *testing.T) { input := `{ "type": "object", "properties": { "query": { "anyOf": [ { "type": "null" }, { "type": "object", "properties": { "kind": { "type": "string" } } } ] } } }` expected := `{ "type": "object", "properties": { "query": { "type": "object", "nullable": true, "description": "Accepts: null | object", "properties": { "_": { "type": "boolean" }, "kind": { "type": "string" } }, "required": ["_"] } } }` result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } func TestCleanJSONSchemaForAntigravity_OneOfFlattening(t *testing.T) { input := `{ "type": "object", "properties": { "config": { "oneOf": [ { "type": "string" }, { "type": "integer" } ] } } }` expected := `{ "type": "object", "properties": { "config": { "anyOf": [ { "type": "string" }, { "type": "integer" } ] } } }` result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } func TestCleanJSONSchemaForAntigravity_AllOfMerging(t *testing.T) { input := `{ "type": "object", "allOf": [ { "properties": { "a": { "type": "string" } }, "required": ["a"] }, { "properties": { "b": { "type": "integer" } }, "required": ["b"] } ] }` expected := `{ "type": "object", "properties": { "a": { "type": "string" }, "b": { "type": "integer" } }, "required": ["a", "b"] }` result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } func TestCleanJSONSchemaForAntigravity_RefHandling(t *testing.T) { input := `{ "definitions": { "User": { "type": "object", "properties": { "name": { "type": "string" } } } }, "type": "object", "properties": { "customer": { "$ref": "#/definitions/User" } } }` // The local reference is expanded before definitions are removed. Claude VALIDATED mode adds // only its optional-object placeholder; the referenced property definition remains intact. expected := `{ "type": "object", "properties": { "customer": { "type": "object", "properties": { "name": { "type": "string" }, "_": { "type": "boolean" } }, "required": ["_"] } } }` result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } func TestCleanJSONSchemaForAntigravity_RefHandling_DescriptionEscaping(t *testing.T) { input := `{ "definitions": { "User": { "type": "object", "properties": { "name": { "type": "string" } } } }, "type": "object", "properties": { "customer": { "description": "He said \"hi\"\\nsecond line", "$ref": "#/definitions/User" } } }` expected := `{ "type": "object", "properties": { "customer": { "type": "object", "description": "He said \"hi\"\\nsecond line", "properties": { "name": { "type": "string" }, "_": { "type": "boolean" } }, "required": ["_"] } } }` result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } func TestCleanJSONSchemaForAntigravity_CyclicRefDefaults(t *testing.T) { input := `{ "definitions": { "Node": { "type": "object", "properties": { "child": { "$ref": "#/definitions/Node" } } } }, "$ref": "#/definitions/Node" }` result := CleanJSONSchemaForAntigravity(input) var resMap map[string]interface{} json.Unmarshal([]byte(result), &resMap) if resMap["type"] != "object" { t.Errorf("Expected type: object, got: %v", resMap["type"]) } child := ojson.Get(result, "properties.child") if child.Get("type").String() != "object" || !strings.Contains(child.Get("description").String(), "Node") { t.Errorf("Expected typed cycle hint containing Node, got: %s", result) } } func TestCleanJSONSchemaForAntigravity_RequiredCleanup(t *testing.T) { input := `{ "type": "object", "properties": { "a": {"type": "string"}, "b": {"type": "string"} }, "required": ["a", "b", "c"] }` expected := `{ "type": "object", "properties": { "a": {"type": "string"}, "b": {"type": "string"} }, "required": ["a", "b"] }` result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } func TestCleanJSONSchemaForAntigravity_AllOfMerging_DotKeys(t *testing.T) { input := `{ "type": "object", "allOf": [ { "properties": { "my.param": { "type": "string" } }, "required": ["my.param"] }, { "properties": { "b": { "type": "integer" } }, "required": ["b"] } ] }` expected := `{ "type": "object", "properties": { "my.param": { "type": "string" }, "b": { "type": "integer" } }, "required": ["my.param", "b"] }` result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } func TestCleanJSONSchemaForAntigravity_PropertyNameCollision(t *testing.T) { // A tool has an argument named "pattern" - should NOT be treated as a constraint input := `{ "type": "object", "properties": { "pattern": { "type": "string", "description": "The regex pattern" } }, "required": ["pattern"] }` expected := `{ "type": "object", "properties": { "pattern": { "type": "string", "description": "The regex pattern" } }, "required": ["pattern"] }` result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) var resMap map[string]interface{} json.Unmarshal([]byte(result), &resMap) props, _ := resMap["properties"].(map[string]interface{}) if _, ok := props["description"]; ok { t.Errorf("Invalid 'description' property injected into properties map") } } func TestCleanJSONSchemaForAntigravity_DotKeys(t *testing.T) { input := `{ "type": "object", "properties": { "my.param": { "type": "string", "$ref": "#/definitions/MyType" } }, "definitions": { "MyType": { "type": "string" } } }` result := CleanJSONSchemaForAntigravity(input) var resMap map[string]interface{} if err := json.Unmarshal([]byte(result), &resMap); err != nil { t.Fatalf("Failed to unmarshal result: %v", err) } props, ok := resMap["properties"].(map[string]interface{}) if !ok { t.Fatalf("properties missing") } if val, ok := props["my.param"]; !ok { t.Fatalf("Key 'my.param' is missing. Result: %s", result) } else { valMap, _ := val.(map[string]interface{}) if _, hasRef := valMap["$ref"]; hasRef { t.Errorf("Key 'my.param' still contains $ref") } if _, ok := props["my"]; ok { t.Errorf("Artifact key 'my' created by sjson splitting") } } } func TestCleanJSONSchemaForAntigravity_AnyOfAlternativeHints(t *testing.T) { input := `{ "type": "object", "properties": { "value": { "anyOf": [ { "type": "string" }, { "type": "integer" }, { "type": "null" } ] } } }` expected := `{ "type": "object", "properties": { "value": { "anyOf": [ { "type": "string" }, { "type": "integer" } ], "nullable": true, "description": "(nullable)" } } }` result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } func TestCleanJSONSchemaForAntigravity_NullableHint(t *testing.T) { input := `{ "type": "object", "properties": { "name": { "type": ["string", "null"], "description": "User name" } }, "required": ["name"] }` result := CleanJSONSchemaForAntigravity(input) if !strings.Contains(result, "(nullable)") { t.Errorf("Expected nullable hint, got: %s", result) } if !strings.Contains(result, "User name") { t.Errorf("Expected original description to be preserved, got: %s", result) } } func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable_DotKey(t *testing.T) { input := `{ "type": "object", "properties": { "my.param": { "type": ["string", "null"] }, "other": { "type": "string" } }, "required": ["my.param", "other"] }` expected := `{ "type": "object", "properties": { "my.param": { "type": "string", "nullable": true, "description": "(nullable)" }, "other": { "type": "string" } }, "required": ["my.param", "other"] }` result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } func TestCleanJSONSchemaForAntigravity_EnumHint(t *testing.T) { input := `{ "type": "object", "properties": { "status": { "type": "string", "enum": ["active", "inactive", "pending"], "description": "Current status" } } }` result := CleanJSONSchemaForAntigravity(input) if !strings.Contains(result, "Allowed:") { t.Errorf("Expected enum values hint, got: %s", result) } if !strings.Contains(result, "active") || !strings.Contains(result, "inactive") { t.Errorf("Expected enum values in hint, got: %s", result) } } func TestCleanJSONSchemaForAntigravity_AdditionalPropertiesHint(t *testing.T) { input := `{ "type": "object", "properties": { "name": { "type": "string" } }, "additionalProperties": false }` result := CleanJSONSchemaForAntigravity(input) if !strings.Contains(result, "No extra properties allowed") { t.Errorf("Expected additionalProperties hint, got: %s", result) } } func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *testing.T) { nullableInput := `{ "type": "object", "properties": { "config": { "description": "Parent desc", "anyOf": [ { "type": "string", "description": "Child desc" }, { "type": "null" } ] } } }` expectedNullable := `{ "type": "object", "properties": { "config": { "type": "string", "nullable": true, "description": "Parent desc (Child desc) (Accepts: string | null)" } } }` resultNullable := CleanJSONSchemaForAntigravity(nullableInput) compareJSON(t, expectedNullable, resultNullable) multiBranchInput := `{ "type": "object", "properties": { "config": { "description": "Parent desc", "anyOf": [ { "type": "string", "description": "Child desc" }, { "type": "integer" } ] } } }` resultMulti := CleanJSONSchemaForAntigravity(multiBranchInput) compareJSON(t, multiBranchInput, resultMulti) } func TestCleanJSONSchemaForAntigravity_SingleEnumBecomesHint(t *testing.T) { input := `{ "type": "object", "properties": { "kind": { "type": "string", "enum": ["fixed"] } } }` result := CleanJSONSchemaForAntigravity(input) if !strings.Contains(result, "Allowed: fixed") || ojson.Get(result, "properties.kind.enum").Exists() { t.Errorf("Ignored tool enum should become a hint, got: %s", result) } } func TestCleanJSONSchemaForAntigravity_MultipleNonNullTypes(t *testing.T) { input := `{ "type": "object", "properties": { "value": { "type": ["string", "integer", "boolean"] } } }` result := CleanJSONSchemaForAntigravity(input) if !strings.Contains(result, "Accepts:") { t.Errorf("Expected multiple types hint, got: %s", result) } if !strings.Contains(result, "string") || !strings.Contains(result, "integer") || !strings.Contains(result, "boolean") { t.Errorf("Expected all types in hint, got: %s", result) } } func compareJSON(t *testing.T, expectedJSON, actualJSON string) { var expMap, actMap map[string]interface{} errExp := json.Unmarshal([]byte(expectedJSON), &expMap) errAct := json.Unmarshal([]byte(actualJSON), &actMap) if errExp != nil || errAct != nil { t.Fatalf("JSON Unmarshal error. Exp: %v, Act: %v", errExp, errAct) } if !reflect.DeepEqual(expMap, actMap) { expBytes, _ := json.MarshalIndent(expMap, "", " ") actBytes, _ := json.MarshalIndent(actMap, "", " ") t.Errorf("JSON mismatch:\nExpected:\n%s\n\nActual:\n%s", string(expBytes), string(actBytes)) } } // ============================================================================ // Empty Schema Placeholder Tests // ============================================================================ func TestCleanJSONSchemaForAntigravity_EmptySchemaPlaceholder(t *testing.T) { // Empty object schema with no properties should get a placeholder input := `{ "type": "object" }` result := CleanJSONSchemaForAntigravity(input) // Should have placeholder property added if !strings.Contains(result, `"reason"`) { t.Errorf("Empty schema should have 'reason' placeholder property, got: %s", result) } if !strings.Contains(result, `"required"`) { t.Errorf("Empty schema should have 'required' with 'reason', got: %s", result) } } func TestCleanJSONSchemaForAntigravity_EmptyPropertiesPlaceholder(t *testing.T) { // Object with empty properties object input := `{ "type": "object", "properties": {} }` result := CleanJSONSchemaForAntigravity(input) // Should have placeholder property added if !strings.Contains(result, `"reason"`) { t.Errorf("Empty properties should have 'reason' placeholder, got: %s", result) } } func TestCleanJSONSchemaForAntigravity_NonEmptySchemaUnchanged(t *testing.T) { // Schema with properties should NOT get placeholder input := `{ "type": "object", "properties": { "name": {"type": "string"} }, "required": ["name"] }` result := CleanJSONSchemaForAntigravity(input) // Should NOT have placeholder property if strings.Contains(result, `"reason"`) { t.Errorf("Non-empty schema should NOT have 'reason' placeholder, got: %s", result) } // Original properties should be preserved if !strings.Contains(result, `"name"`) { t.Errorf("Original property 'name' should be preserved, got: %s", result) } } func TestCleanJSONSchemaForAntigravity_NestedEmptySchema(t *testing.T) { // Nested empty object in items should also get placeholder input := `{ "type": "object", "properties": { "items": { "type": "array", "items": { "type": "object" } } } }` result := CleanJSONSchemaForAntigravity(input) // Nested empty object should also get placeholder // Check that the nested object has a reason property parsed := ojson.Parse(result) nestedProps := parsed.Get("properties.items.items.properties") if !nestedProps.Exists() || !nestedProps.Get("reason").Exists() { t.Errorf("Nested empty object should have 'reason' placeholder, got: %s", result) } } func TestCleanJSONSchemaForAntigravity_EmptySchemaWithDescription(t *testing.T) { // Empty schema with description should preserve description and add placeholder input := `{ "type": "object", "description": "An empty object" }` result := CleanJSONSchemaForAntigravity(input) // Should have both description and placeholder if !strings.Contains(result, `"An empty object"`) { t.Errorf("Description should be preserved, got: %s", result) } if !strings.Contains(result, `"reason"`) { t.Errorf("Empty schema should have 'reason' placeholder, got: %s", result) } } func TestCleanJSONSchemaForAntigravityResponseDoesNotAddToolPlaceholders(t *testing.T) { bare := ojson.Parse(CleanJSONSchemaForAntigravityResponse(`{"type":"object"}`)) if bare.Get("properties.reason").Exists() || bare.Get("required").Exists() { t.Fatalf("bare response schema gained tool placeholders: %s", bare.Raw) } input := `{ "type":"object", "title":"Response", "nullable":true, "properties":{ "empty":{"type":"object"}, "optional":{"type":"object","properties":{"value":{"type":"string"}}} } }` result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input)) for _, path := range []string{ "properties.empty.properties.reason", "properties.empty.required", "properties.optional.properties._", "properties.optional.required", } { if result.Get(path).Exists() { t.Errorf("response schema gained tool-only field %s: %s", path, result.Raw) } } if result.Get("title").String() != "Response" || !result.Get("nullable").Bool() { t.Errorf("Antigravity response metadata was removed: %s", result.Raw) } } func TestCleanJSONSchemaForAntigravityResponseProjectsIgnoredUnions(t *testing.T) { input := `{ "type":"object", "properties":{ "action":{"anyOf":[ {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}, {"type":"null"} ]}, "label":{"oneOf":[{"type":"string"},{"type":"null"}]} } }` result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input)) for _, path := range []string{"properties.action.anyOf", "properties.label.oneOf"} { if result.Get(path).Exists() { t.Errorf("ignored response union %s survived: %s", path, result.Raw) } } for _, testCase := range []struct{ path, wantType string }{ {path: "properties.action", wantType: "object"}, {path: "properties.label", wantType: "string"}, } { schema := result.Get(testCase.path) if schema.Get("type").String() != testCase.wantType || !schema.Get("nullable").Bool() { t.Errorf("%s was not projected to nullable %s: %s", testCase.path, testCase.wantType, result.Raw) } } } func TestCleanJSONSchemaForAntigravityResponsePreservesAdditionalPropertiesFalse(t *testing.T) { input := `{ "type":"object", "properties":{ "name":{"type":"string"}, "nested":{ "type":"object", "properties":{ "age":{"type":"integer"} }, "additionalProperties":false } }, "additionalProperties":false }` result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input)) // Root additionalProperties should be preserved as false rootAP := result.Get("additionalProperties") if !rootAP.Exists() || rootAP.Type != ojson.False { t.Errorf("root additionalProperties = %v, want false; cleaned: %s", rootAP, result.Raw) } // Nested additionalProperties should be preserved as false nestedAP := result.Get("properties.nested.additionalProperties") if !nestedAP.Exists() || nestedAP.Type != ojson.False { t.Errorf("nested additionalProperties = %v, want false; cleaned: %s", nestedAP, result.Raw) } // Should not have converted additionalProperties into description hints if strings.Contains(result.Raw, "No extra properties allowed") { t.Errorf("expected no description hint for additionalProperties:false, got: %s", result.Raw) } // But CleanJSONSchemaForAntigravity (tool path) must still strip it and add hint toolResult := CleanJSONSchemaForAntigravity(input) if strings.Contains(toolResult, `"additionalProperties"`) { t.Errorf("tool schema should not have additionalProperties: %s", toolResult) } if !strings.Contains(toolResult, "No extra properties allowed") { t.Errorf("tool schema should have description hint: %s", toolResult) } // Non-false additionalProperties (e.g. true or schema-valued) should still be stripped in response schemas nonFalseInput := `{ "type":"object", "properties":{ "map":{"type":"object","additionalProperties":{"type":"string"}} }, "additionalProperties":true }` nonFalseResult := CleanJSONSchemaForAntigravityResponse(nonFalseInput) if strings.Contains(nonFalseResult, `"additionalProperties"`) { t.Errorf("non-false additionalProperties should be stripped in response schema: %s", nonFalseResult) } } func TestCleanJSONSchemaForAntigravityResponsePreservesEnumType(t *testing.T) { input := `{ "type":"object", "properties":{ "conviction":{"type":"number","enum":[0.25,0.5,1]}, "count":{"type":"integer","enum":[1,2]} } }` result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input)) for _, testCase := range []struct { path string wantType string wantValues []string }{ {path: "properties.conviction", wantType: "number", wantValues: []string{"0.25", "0.5", "1"}}, {path: "properties.count", wantType: "integer", wantValues: []string{"1", "2"}}, } { schema := result.Get(testCase.path) if gotType := schema.Get("type").String(); gotType != testCase.wantType { t.Errorf("%s type = %q, want %q: %s", testCase.path, gotType, testCase.wantType, result.Raw) } var gotValues []string for _, enumValue := range schema.Get("enum").Array() { if enumValue.Type != ojson.String { t.Errorf("%s enum value is not a string: %s", testCase.path, enumValue.Raw) } gotValues = append(gotValues, enumValue.String()) } if !reflect.DeepEqual(gotValues, testCase.wantValues) { t.Errorf("%s enum values = %v, want %v: %s", testCase.path, gotValues, testCase.wantValues, result.Raw) } } } // ============================================================================ // Format field handling (ad-hoc patch removal) // ============================================================================ func TestCleanJSONSchemaForAntigravity_FormatFieldRemoval(t *testing.T) { // format:"uri" should be removed and added as hint input := `{ "type": "object", "properties": { "url": { "type": "string", "format": "uri", "description": "A URL" } } }` result := CleanJSONSchemaForAntigravity(input) // format should be removed if strings.Contains(result, `"format"`) { t.Errorf("format field should be removed, got: %s", result) } // hint should be added to description if !strings.Contains(result, "format: uri") { t.Errorf("format hint should be added to description, got: %s", result) } // original description should be preserved if !strings.Contains(result, "A URL") { t.Errorf("Original description should be preserved, got: %s", result) } } func TestCleanJSONSchemaForAntigravity_FormatFieldNoDescription(t *testing.T) { // format without description should create description with hint input := `{ "type": "object", "properties": { "email": { "type": "string", "format": "email" } } }` result := CleanJSONSchemaForAntigravity(input) // format should be removed if strings.Contains(result, `"format"`) { t.Errorf("format field should be removed, got: %s", result) } // hint should be added if !strings.Contains(result, "format: email") { t.Errorf("format hint should be added, got: %s", result) } } func TestCleanJSONSchemaForAntigravity_MultipleFormats(t *testing.T) { // Multiple format fields should all be handled input := `{ "type": "object", "properties": { "url": {"type": "string", "format": "uri"}, "email": {"type": "string", "format": "email"}, "date": {"type": "string", "format": "date-time"} } }` result := CleanJSONSchemaForAntigravity(input) // All format fields should be removed if strings.Contains(result, `"format"`) { t.Errorf("All format fields should be removed, got: %s", result) } // All hints should be added if !strings.Contains(result, "format: uri") { t.Errorf("uri format hint should be added, got: %s", result) } if !strings.Contains(result, "format: email") { t.Errorf("email format hint should be added, got: %s", result) } if !strings.Contains(result, "format: date-time") { t.Errorf("date-time format hint should be added, got: %s", result) } } func TestCleanJSONSchemaForAntigravity_ToolEnumsBecomeHints(t *testing.T) { input := `{ "type": "object", "properties": { "priority": {"type": "integer", "enum": [0, 1, 2]}, "level": {"type": "number", "enum": [1.5, 2.5, 3.5]}, "status": {"type": "string", "enum": ["active", "inactive"]} } }` result := CleanJSONSchemaForAntigravity(input) parsed := ojson.Parse(result) // Antigravity ignores function-argument enum but still uses the declared type to choose the // emitted JSON type. Preserve types and convert enum values to advisory hints. for path, wantType := range map[string]string{ "properties.priority": "integer", "properties.level": "number", "properties.status": "string", } { if gotType := parsed.Get(path + ".type").String(); gotType != wantType { t.Errorf("Tool enum type at %s = %q, want %s: %s", path, gotType, wantType, result) } if parsed.Get(path+".enum").Exists() || !strings.Contains(parsed.Get(path+".description").String(), "Allowed:") { t.Errorf("Tool enum at %s was not projected to a hint: %s", path, result) } } } func TestCleanJSONSchemaForAntigravity_BooleanToolEnumBecomesHint(t *testing.T) { input := `{ "type": "object", "properties": { "enabled": {"type": "boolean", "enum": [true, false]} } }` result := CleanJSONSchemaForAntigravity(input) value := ojson.Get(result, "properties.enabled") if value.Get("enum").Exists() || value.Get("type").String() != "boolean" || !strings.Contains(value.Get("description").String(), "Allowed: true, false") { t.Errorf("Boolean tool enum should become a typed hint, got: %s", result) } } func TestCleanJSONSchemaForGemini_RemovesGeminiUnsupportedMetadataFields(t *testing.T) { input := `{ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "root-schema", "$comment": "root comment should be removed", "type": "object", "properties": { "payload": { "type": "object", "$comment": "nested comment should be removed", "prefill": "hello", "properties": { "mode": { "type": "string", "enum": ["a", "b"], "enumDescriptions": ["Alpha", "Beta"], "enumTitles": ["A", "B"] } }, "patternProperties": { "^x-": {"type": "string"} } }, "$id": { "type": "string", "description": "property name should not be removed" }, "$comment": { "type": "string", "description": "property name should not be removed" }, "enumDescriptions": { "type": "array", "description": "property name should not be removed" } } }` expected := `{ "type": "object", "properties": { "payload": { "type": "object", "properties": { "mode": { "type": "string", "enum": ["a", "b"], "description": "Allowed: a, b" } } }, "$id": { "type": "string", "description": "property name should not be removed" }, "$comment": { "type": "string", "description": "property name should not be removed" }, "enumDescriptions": { "type": "array", "items": {"type": "string"}, "description": "property name should not be removed" } } }` result := CleanJSONSchemaForGemini(input) compareJSON(t, expected, result) } func TestRemoveExtensionFields(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "removes x- fields at root", input: `{ "type": "object", "x-custom-meta": "value", "properties": { "foo": { "type": "string" } } }`, expected: `{ "type": "object", "properties": { "foo": { "type": "string" } } }`, }, { name: "removes x- fields in nested properties", input: `{ "type": "object", "properties": { "foo": { "type": "string", "x-internal-id": 123 } } }`, expected: `{ "type": "object", "properties": { "foo": { "type": "string" } } }`, }, { name: "does NOT remove properties named x-", input: `{ "type": "object", "properties": { "x-data": { "type": "string" }, "normal": { "type": "number", "x-meta": "remove" } }, "required": ["x-data"] }`, expected: `{ "type": "object", "properties": { "x-data": { "type": "string" }, "normal": { "type": "number" } }, "required": ["x-data"] }`, }, { name: "does NOT remove $schema and other meta fields (as requested)", input: `{ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "test", "type": "object", "properties": { "foo": { "type": "string" } } }`, expected: `{ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "test", "type": "object", "properties": { "foo": { "type": "string" } } }`, }, { name: "handles properties named $schema", input: `{ "type": "object", "properties": { "$schema": { "type": "string" } } }`, expected: `{ "type": "object", "properties": { "$schema": { "type": "string" } } }`, }, { name: "handles escaping in paths", input: `{ "type": "object", "properties": { "foo.bar": { "type": "string", "x-meta": "remove" } }, "x-root.meta": "remove" }`, expected: `{ "type": "object", "properties": { "foo.bar": { "type": "string" } } }`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := removeExtensionFields(tt.input) compareJSON(t, tt.expected, actual) }) } } // uniqueItems should be stripped and moved to description hint (#2123). func TestCleanJSONSchemaForAntigravity_UniqueItemsStripped(t *testing.T) { input := `{ "type": "object", "properties": { "ids": { "type": "array", "description": "Unique identifiers", "items": {"type": "string"}, "uniqueItems": true } } }` result := CleanJSONSchemaForAntigravity(input) if strings.Contains(result, `"uniqueItems"`) { t.Errorf("uniqueItems should be removed from schema") } if !strings.Contains(result, "uniqueItems: true") { t.Errorf("uniqueItems hint missing in description") } } // TestIsPropertyDefinitionDistinguishesPropertyNamedProperties covers the classification that // decides whether a key spelled like a schema keyword is a keyword or an author-chosen name. // Matching a trailing ".properties" alone mistook the schema of a property named "properties" for // a property map, which disabled cleaning inside it. func TestIsPropertyDefinitionDistinguishesPropertyNamedProperties(t *testing.T) { for path, want := range map[string]bool{ "": false, "properties": true, "properties.properties": false, "properties.properties.properties": true, "properties.records.items.properties": true, "properties.records.items": false, // Any prefix the caller nests the schema under must not change the answer. "schema.properties": true, "request.tools.0.functionDeclarations.0.parameters": false, "request.tools.0.functionDeclarations.0.parameters.properties": true, "request.tools.0.functionDeclarations.0.parameters.properties.properties": false, // $defs and patternProperties are name maps for the same reason as properties. "$defs": true, "$defs.properties": false, "properties.$defs": false, "properties.a.patternProperties": true, "properties.patternProperties": false, } { if got := isPropertyDefinition(path); got != want { t.Errorf("isPropertyDefinition(%q) = %v, want %v", path, got, want) } } } // TestCleanJSONSchemaStripsPropertyNamesUnderPropertyNamedProperties covers the reported failure: // the private Gemini backend rejects "propertyNames" with an unknown-field 400, and MCP tool // schemas place it inside a property that is itself named "properties". func TestCleanJSONSchemaStripsPropertyNamesUnderPropertyNamedProperties(t *testing.T) { shapes := map[string]string{ // Nested in an array item, alongside the item's own properties map. "arrayItem": `{"type":"object","properties":{"records":{"type":"array","items":{"type":"object",` + `"properties":{"name":{"type":"string"}},"propertyNames":{"type":"string"}}}}}`, // A dynamic map declared by a property named "properties". "propertyNamedProperties": `{"type":"object","properties":{"properties":{"type":"object",` + `"propertyNames":{"type":"string"}}}}`, // Both shapes combined, as the reported tool schemas did. "combined": `{"type":"object","properties":{"pages":{"type":"array","items":{"type":"object",` + `"properties":{"properties":{"type":"object","propertyNames":{"type":"string"},` + `"additionalProperties":true}},"propertyNames":{"type":"string"}}}}}`, } for name, schema := range shapes { for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "gemini": CleanJSONSchemaForGemini, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, } { got := clean(schema) if strings.Contains(got, `"propertyNames"`) { t.Errorf("%s/%s: propertyNames survived cleaning: %s", name, cleaner, got) } if strings.Contains(got, `"additionalProperties"`) { t.Errorf("%s/%s: additionalProperties survived cleaning: %s", name, cleaner, got) } } } } // TestCleanJSONSchemaKeepsPropertiesNamedLikeKeywords guards the other half of the rule: a schema // may legitimately declare properties named after schema keywords, and those must survive. func TestCleanJSONSchemaKeepsPropertiesNamedLikeKeywords(t *testing.T) { input := `{"type":"object","properties":{ "propertyNames":{"type":"string"}, "patternProperties":{"type":"string"}, "properties":{"type":"object","properties":{"propertyNames":{"type":"string"}}} }}` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "gemini": CleanJSONSchemaForGemini, } { got := ojson.Parse(clean(input)) for _, path := range []string{ "properties.propertyNames", "properties.patternProperties", "properties.properties.properties.propertyNames", } { if !got.Get(path).Exists() { t.Errorf("%s: property %s was removed: %s", cleaner, path, got.Raw) } } } } func TestCleanJSONSchema_ConditionalKeywords(t *testing.T) { // 1. Root-level if/then/else rootInput := `{ "type": "object", "properties": { "kind": { "type": "string", "enum": ["buy", "sell"] } }, "required": ["kind"], "if": { "properties": { "kind": { "const": "sell" } } }, "then": { "properties": { "sell_reason": { "type": "string", "description": "why the position is being sold" } }, "required": ["sell_reason"] }, "else": { "properties": { "buy_reason": { "type": "string" } } } }` for name, clean := range map[string]func(string) string{ "AntigravityResponse": CleanJSONSchemaForAntigravityResponse, "Antigravity": CleanJSONSchemaForAntigravity, "Gemini": CleanJSONSchemaForGemini, } { res := ojson.Parse(clean(rootInput)) if res.Get("if").Exists() { t.Errorf("[%s] root 'if' was not removed: %s", name, res.Raw) } if res.Get("then").Exists() { t.Errorf("[%s] root 'then' was not removed: %s", name, res.Raw) } if res.Get("else").Exists() { t.Errorf("[%s] root 'else' was not removed: %s", name, res.Raw) } if !res.Get("properties.sell_reason").Exists() { t.Errorf("[%s] then.properties.sell_reason was lost: %s", name, res.Raw) } if !res.Get("properties.buy_reason").Exists() { t.Errorf("[%s] else.properties.buy_reason was lost: %s", name, res.Raw) } if res.Get("properties.sell_reason.description").String() != "why the position is being sold" { t.Errorf("[%s] sell_reason description mismatch: %s", name, res.Raw) } } // 2. allOf with if/then allOfInput := `{ "type": "object", "properties": { "kind": { "type": "string", "enum": ["buy", "sell"] } }, "required": ["kind"], "allOf": [ { "if": { "properties": { "kind": { "const": "sell" } } }, "then": { "properties": { "sell_reason": { "type": "string", "description": "why the position is being sold" } }, "required": ["sell_reason"] } } ] }` for name, clean := range map[string]func(string) string{ "AntigravityResponse": CleanJSONSchemaForAntigravityResponse, "Antigravity": CleanJSONSchemaForAntigravity, "Gemini": CleanJSONSchemaForGemini, } { res := ojson.Parse(clean(allOfInput)) if res.Get("allOf").Exists() { t.Errorf("[%s] 'allOf' was not removed: %s", name, res.Raw) } if res.Get("if").Exists() || strings.Contains(res.Raw, `"if":`) { t.Errorf("[%s] 'if' keyword present: %s", name, res.Raw) } if !res.Get("properties.sell_reason").Exists() { t.Errorf("[%s] allOf.then.properties.sell_reason was lost: %s", name, res.Raw) } if res.Get("properties.sell_reason.description").String() != "why the position is being sold" { t.Errorf("[%s] sell_reason description mismatch: %s", name, res.Raw) } } // 3. Nested property with if/then nestedInput := `{ "type": "object", "properties": { "trade": { "type": "object", "properties": { "kind": { "type": "string" } }, "if": { "properties": { "kind": { "const": "sell" } } }, "then": { "properties": { "sell_reason": { "type": "string" } } } } } }` for name, clean := range map[string]func(string) string{ "AntigravityResponse": CleanJSONSchemaForAntigravityResponse, "Antigravity": CleanJSONSchemaForAntigravity, "Gemini": CleanJSONSchemaForGemini, } { res := ojson.Parse(clean(nestedInput)) if res.Get("properties.trade.if").Exists() { t.Errorf("[%s] nested 'if' was not removed: %s", name, res.Raw) } if res.Get("properties.trade.then").Exists() { t.Errorf("[%s] nested 'then' was not removed: %s", name, res.Raw) } if !res.Get("properties.trade.properties.sell_reason").Exists() { t.Errorf("[%s] nested then.properties.sell_reason was lost: %s", name, res.Raw) } } } func TestCleanJSONSchemaForAntigravityResponseConditionalCannotOverwriteParent(t *testing.T) { input := `{ "type":"object", "properties":{ "kind":{"type":"string"}, "action":{"type":"object","properties":{"full":{"type":"string"}},"required":["full"]} }, "required":["kind","action"], "allOf":[{ "if":{"properties":{"kind":{"const":"skip"}}}, "then":{"properties":{ "action":{"type":"null"}, "branch_only":{"type":"integer"} }} }] }` result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input)) action := result.Get("properties.action") if action.Get("type").String() != "object" || !action.Get("properties.full").Exists() { t.Fatalf("conditional branch replaced canonical action: %s", result.Raw) } if action.Get("required.0").String() != "full" || !result.Get("properties.branch_only").Exists() { t.Fatalf("conditional merge lost parent or branch-only information: %s", result.Raw) } if result.Get("allOf").Exists() || strings.Contains(result.Raw, `"if"`) || strings.Contains(result.Raw, `"then"`) { t.Fatalf("unsupported conditional keywords survived: %s", result.Raw) } } func TestCleanJSONSchemaForAntigravityResponseInlinesLocalRef(t *testing.T) { input := `{ "$defs":{"Payload":{"type":"object","properties":{"id":{"type":"integer"}},"required":["id"]}}, "type":"object", "properties":{"payload":{"$ref":"#/$defs/Payload"}}, "required":["payload"] }` result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input)) if result.Get(`\$defs`).Exists() || strings.Contains(result.Raw, `"$ref"`) { t.Fatalf("local reference metadata survived: %s", result.Raw) } payload := result.Get("properties.payload") if payload.Get("type").String() != "object" || payload.Get("properties.id.type").String() != "integer" || payload.Get("required.0").String() != "id" { t.Fatalf("local reference definition was not inlined: %s", result.Raw) } } func TestCleanJSONSchemaForAntigravityResponseTypeArrayUsesNativeNullable(t *testing.T) { input := `{"type":"object","properties":{"value":{"type":["number","null"]}},"required":["value"]}` result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input)) value := result.Get("properties.value") if value.Get("type").String() != "number" || !value.Get("nullable").Bool() { t.Fatalf("type array was not projected to native nullable: %s", result.Raw) } if result.Get("required.0").String() != "value" { t.Fatalf("nullable required property became optional: %s", result.Raw) } } func TestCleanJSONSchemaForAntigravityToolKeepsNumericEnumType(t *testing.T) { input := `{"type":"object","properties":{"value":{"type":"number","enum":[1,2]}},"required":["value"]}` result := ojson.Parse(CleanJSONSchemaForAntigravityTool(input, false)) value := result.Get("properties.value") if value.Get("type").String() != "number" { t.Fatalf("numeric tool enum changed argument JSON type: %s", result.Raw) } if value.Get("enum").Exists() || !strings.Contains(value.Get("description").String(), "Allowed: 1, 2") { t.Fatalf("ignored tool enum was not projected to a hint: %s", result.Raw) } } func TestCleanJSONSchemaForAntigravityResponseDropsIgnoredBooleanEnum(t *testing.T) { input := `{"type":"object","properties":{"value":{"type":"boolean","enum":["true"]}},"required":["value"]}` result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input)) value := result.Get("properties.value") if value.Get("enum").Exists() || value.Get("type").String() != "boolean" || !strings.Contains(value.Get("description").String(), "Allowed: true") { t.Fatalf("ignored boolean response enum was not projected to a hint: %s", result.Raw) } } func TestCleanJSONSchemaForAntigravityResponseHintsIgnoredConstraints(t *testing.T) { input := `{"type":"object","properties":{"value":{"type":"number","minimum":1,"maximum":2,"not":{"enum":[1.5]}}}}` result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input)) value := result.Get("properties.value") for _, keyword := range []string{"minimum", "maximum", "not"} { if value.Get(keyword).Exists() { t.Fatalf("ignored constraint %s survived: %s", keyword, result.Raw) } if !strings.Contains(value.Get("description").String(), keyword+":") { t.Fatalf("ignored constraint %s lost its hint: %s", keyword, result.Raw) } } } func TestSortByDepthUsesSegmentsAndIsStable(t *testing.T) { paths := []string{"root.verylong", "root.x.y", "first.same", "later.same"} sortByDepth(paths) want := []string{"root.x.y", "root.verylong", "first.same", "later.same"} if !reflect.DeepEqual(paths, want) { t.Fatalf("sortByDepth() = %v, want %v", paths, want) } } // TestCleanJSONSchemaStripsEncryptedMetadata covers Codex client tool definitions where // properties carry the Responses-only "encrypted" marker (e.g. "encrypted": true or "encrypted": false). // The Gemini backend strictly rejects unknown schema fields with an INVALID_ARGUMENT 400. func TestCleanJSONSchemaStripsEncryptedMetadata(t *testing.T) { input := `{ "type": "object", "properties": { "api_key": { "type": "string", "description": "API credential", "encrypted": true }, "timeout": { "type": "integer", "encrypted": false }, "nested": { "type": "object", "properties": { "secret": { "type": "string", "encrypted": true } } } }, "required": ["api_key"] }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "gemini": CleanJSONSchemaForGemini, "antigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, } { got := clean(input) if strings.Contains(got, `"encrypted"`) { t.Errorf("%s: 'encrypted' marker survived cleaning: %s", cleaner, got) } parsed := ojson.Parse(got) if !parsed.Get("properties.api_key.type").Exists() || parsed.Get("properties.api_key.description").String() != "API credential" { t.Errorf("%s: api_key schema was corrupted: %s", cleaner, got) } if !parsed.Get("properties.nested.properties.secret.type").Exists() { t.Errorf("%s: nested property secret was corrupted: %s", cleaner, got) } } } // TestCleanJSONSchemaKeepsPropertyNamedEncrypted guards the legitimate case where a tool // parameter itself is named "encrypted" (e.g. properties.encrypted: {"type": "boolean"}). func TestCleanJSONSchemaKeepsPropertyNamedEncrypted(t *testing.T) { input := `{ "type": "object", "properties": { "encrypted": { "type": "boolean", "description": "Whether the payload is encrypted", "encrypted": true }, "data": { "type": "string" } }, "required": ["encrypted"] }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "gemini": CleanJSONSchemaForGemini, } { got := clean(input) parsed := ojson.Parse(got) if !parsed.Get("properties.encrypted").Exists() { t.Errorf("%s: property named 'encrypted' was removed: %s", cleaner, got) } if parsed.Get("properties.encrypted.type").String() != "boolean" { t.Errorf("%s: property named 'encrypted' type corrupted: %s", cleaner, got) } // The inner attribute "encrypted": true must be stripped if parsed.Get("properties.encrypted.encrypted").Exists() { t.Errorf("%s: inner 'encrypted' attribute survived: %s", cleaner, got) } } } // TestCleanJSONSchema_BarePropertyMapNormalized covers Issue #5178: // MCP tools (e.g. Asana) emit bare property maps missing type:object and properties wrappers, // plus boolean required: true on child properties. func TestCleanJSONSchema_BarePropertyMapNormalized(t *testing.T) { input := `{ "type": "object", "properties": { "data": { "parent": { "type": "string", "required": true }, "insert_after": { "type": "string" }, "insert_before": { "type": "string" } }, "opts": { "opt_fields": { "type": "string" } } } }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "antigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, "gemini": CleanJSONSchemaForGemini, } { got := clean(input) parsed := ojson.Parse(got) // data must be normalized into an object schema with properties if parsed.Get("properties.data.type").String() != "object" { t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got) } if parsed.Get("properties.data.properties.parent.type").String() != "string" { t.Errorf("%s: properties.data.properties.parent.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.parent.type").String(), got) } if parsed.Get("properties.data.properties.insert_after.type").String() != "string" { t.Errorf("%s: properties.data.properties.insert_after.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.insert_after.type").String(), got) } if parsed.Get("properties.data.properties.insert_before.type").String() != "string" { t.Errorf("%s: properties.data.properties.insert_before.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.insert_before.type").String(), got) } // parent required: true must be promoted to data.required array var dataReq []string for _, r := range parsed.Get("properties.data.required").Array() { dataReq = append(dataReq, r.String()) } if !contains(dataReq, "parent") { t.Errorf("%s: properties.data.required = %v, want 'parent' included; got schema: %s", cleaner, dataReq, got) } // boolean required on parent node must be stripped if parsed.Get("properties.data.properties.parent.required").Exists() { t.Errorf("%s: properties.data.properties.parent.required survived; got schema: %s", cleaner, got) } // opts must also be normalized into an object schema if parsed.Get("properties.opts.type").String() != "object" { t.Errorf("%s: properties.opts.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.opts.type").String(), got) } if parsed.Get("properties.opts.properties.opt_fields.type").String() != "string" { t.Errorf("%s: properties.opts.properties.opt_fields.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.opts.properties.opt_fields.type").String(), got) } } } // TestCleanJSONSchema_NestedBarePropertyMap tests recursive normalization of multi-level bare property maps. func TestCleanJSONSchema_NestedBarePropertyMap(t *testing.T) { input := `{ "type": "object", "properties": { "data": { "workspace": { "type": "string", "required": true }, "task": { "name": { "type": "string", "required": true }, "notes": { "type": "string" } } } } }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "gemini": CleanJSONSchemaForGemini, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, } { got := clean(input) parsed := ojson.Parse(got) if parsed.Get("properties.data.type").String() != "object" { t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got) } if parsed.Get("properties.data.properties.workspace.type").String() != "string" { t.Errorf("%s: properties.data.properties.workspace.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.workspace.type").String(), got) } // Nested task should also be normalized to an object if parsed.Get("properties.data.properties.task.type").String() != "object" { t.Errorf("%s: properties.data.properties.task.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.properties.task.type").String(), got) } if parsed.Get("properties.data.properties.task.properties.name.type").String() != "string" { t.Errorf("%s: properties.data.properties.task.properties.name.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.task.properties.name.type").String(), got) } // Required promotion at both levels var dataReq []string for _, r := range parsed.Get("properties.data.required").Array() { dataReq = append(dataReq, r.String()) } if !contains(dataReq, "workspace") { t.Errorf("%s: properties.data.required = %v, want 'workspace'; got schema: %s", cleaner, dataReq, got) } var taskReq []string for _, r := range parsed.Get("properties.data.properties.task.required").Array() { taskReq = append(taskReq, r.String()) } if !contains(taskReq, "name") { t.Errorf("%s: properties.data.properties.task.required = %v, want 'name'; got schema: %s", cleaner, taskReq, got) } } } // TestCleanJSONSchema_BarePropertyMapWithKeywordNames tests that bare property maps with fields // named like schema keywords (title, description, format, type) are correctly normalized. func TestCleanJSONSchema_BarePropertyMapWithKeywordNames(t *testing.T) { input := `{ "type": "object", "properties": { "data": { "title": { "type": "string", "required": true }, "description": { "type": "string" }, "format": { "type": "string" }, "type": { "type": "string" } } } }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "gemini": CleanJSONSchemaForGemini, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, } { got := clean(input) parsed := ojson.Parse(got) if parsed.Get("properties.data.type").String() != "object" { t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got) } if parsed.Get("properties.data.properties.title.type").String() != "string" { t.Errorf("%s: properties.data.properties.title.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.title.type").String(), got) } if parsed.Get("properties.data.properties.description.type").String() != "string" { t.Errorf("%s: properties.data.properties.description.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.description.type").String(), got) } if parsed.Get("properties.data.properties.type.type").String() != "string" { t.Errorf("%s: properties.data.properties.type.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.type.type").String(), got) } var dataReq []string for _, r := range parsed.Get("properties.data.required").Array() { dataReq = append(dataReq, r.String()) } if !contains(dataReq, "title") { t.Errorf("%s: properties.data.required = %v, want 'title'; got schema: %s", cleaner, dataReq, got) } } } // TestCleanJSONSchema_ArrayItemsBarePropertyMap tests bare property map normalization inside array items. func TestCleanJSONSchema_ArrayItemsBarePropertyMap(t *testing.T) { input := `{ "type": "object", "properties": { "tasks": { "type": "array", "items": { "id": { "type": "string", "required": true }, "label": { "type": "string" } } } } }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "gemini": CleanJSONSchemaForGemini, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, } { got := clean(input) parsed := ojson.Parse(got) if parsed.Get("properties.tasks.items.type").String() != "object" { t.Errorf("%s: properties.tasks.items.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.tasks.items.type").String(), got) } if parsed.Get("properties.tasks.items.properties.id.type").String() != "string" { t.Errorf("%s: properties.tasks.items.properties.id.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.tasks.items.properties.id.type").String(), got) } var itemsReq []string for _, r := range parsed.Get("properties.tasks.items.required").Array() { itemsReq = append(itemsReq, r.String()) } if !contains(itemsReq, "id") { t.Errorf("%s: properties.tasks.items.required = %v, want 'id'; got schema: %s", cleaner, itemsReq, got) } } } // TestCleanJSONSchema_ToolArraysMissingItems covers Issue #5292: Gemini and Antigravity // reject tool array schemas that do not declare an items schema. func TestCleanJSONSchema_ToolArraysMissingItems(t *testing.T) { input := `{ "type": "object", "properties": { "params": { "type": "array" }, "values": { "type": ["array", "null"], "description": "no items" }, "existing": { "type": "array", "items": { "type": "number" } } } }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "antigravityLegacy": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "gemini": CleanJSONSchemaForGemini, } { t.Run(cleaner, func(t *testing.T) { got := ojson.Parse(clean(input)) for _, path := range []string{"properties.params.items.type", "properties.values.items.type"} { if itemType := got.Get(path).String(); itemType != "string" { t.Errorf("%s = %q, want string; got schema: %s", path, itemType, got.Raw) } } if itemType := got.Get("properties.existing.items.type").String(); itemType != "number" { t.Errorf("existing items type = %q, want number; got schema: %s", itemType, got.Raw) } rootArray := ojson.Parse(clean(`{"type":"array"}`)) if itemType := rootArray.Get("items.type").String(); itemType != "string" { t.Errorf("root items type = %q, want string; got schema: %s", itemType, rootArray.Raw) } }) } } func TestCleanJSONSchema_ResponseArrayMissingItemsUnchanged(t *testing.T) { input := `{"type":"object","properties":{"values":{"type":"array"}}}` got := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input)) if got.Get("properties.values.items").Exists() { t.Fatalf("response schema gained tool-only items placeholder: %s", got.Raw) } } // TestCleanJSONSchema_BooleanRequiredPromoted tests that boolean required: true is promoted // and boolean required: false is stripped without being added to the required array. func TestCleanJSONSchema_BooleanRequiredPromoted(t *testing.T) { input := `{ "type": "object", "properties": { "existing": { "type": "string" }, "name": { "type": "string", "required": true }, "age": { "type": "integer", "required": false }, "tag": { "type": "string" } }, "required": ["existing"] }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "gemini": CleanJSONSchemaForGemini, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, } { got := clean(input) parsed := ojson.Parse(got) var req []string for _, r := range parsed.Get("required").Array() { req = append(req, r.String()) } if !contains(req, "existing") || !contains(req, "name") { t.Errorf("%s: required = %v, want both 'existing' and 'name'; got schema: %s", cleaner, req, got) } if contains(req, "age") || contains(req, "tag") { t.Errorf("%s: required = %v, should not contain 'age' or 'tag'; got schema: %s", cleaner, req, got) } if parsed.Get("properties.name.required").Exists() { t.Errorf("%s: properties.name.required survived; got schema: %s", cleaner, got) } if parsed.Get("properties.age.required").Exists() { t.Errorf("%s: properties.age.required survived; got schema: %s", cleaner, got) } } } // TestCleanJSONSchema_PreservesLargeNumberPrecision tests that numbers are not corrupted by float64 precision loss. func TestCleanJSONSchema_PreservesLargeNumberPrecision(t *testing.T) { input := `{ "type": "object", "properties": { "big_int": { "type": "integer", "minimum": 9007199254740993 }, "bare_child": { "sub": { "type": "string" } } } }` result := CleanJSONSchemaForAntigravityResponse(input) // minimum is moved to description hint if !strings.Contains(result, "9007199254740993") { t.Errorf("large integer precision was lost: %s", result) } } // TestCleanJSONSchema_BarePropertyMapWithRequestAndToolsNames tests that property names like // "request", "tools", "headers", "messages" inside bare property maps are correctly normalized. func TestCleanJSONSchema_BarePropertyMapWithRequestAndToolsNames(t *testing.T) { input := `{ "type": "object", "properties": { "data": { "request": { "method": { "type": "string", "required": true }, "url": { "type": "string" } }, "headers": { "authorization": { "type": "string" } }, "tools": { "name": { "type": "string" } } } } }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "gemini": CleanJSONSchemaForGemini, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, } { got := clean(input) parsed := ojson.Parse(got) if parsed.Get("properties.data.type").String() != "object" { t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got) } if parsed.Get("properties.data.properties.headers.type").String() != "object" { t.Errorf("%s: properties.data.properties.headers.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.properties.headers.type").String(), got) } if parsed.Get("properties.data.properties.tools.type").String() != "object" { t.Errorf("%s: properties.data.properties.tools.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.properties.tools.type").String(), got) } if parsed.Get("properties.data.properties.request.type").String() != "object" { t.Errorf("%s: properties.data.properties.request.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.properties.request.type").String(), got) } if parsed.Get("properties.data.properties.request.properties.method.type").String() != "string" { t.Errorf("%s: properties.data.properties.request.properties.method.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.request.properties.method.type").String(), got) } var reqReq []string for _, r := range parsed.Get("properties.data.properties.request.required").Array() { reqReq = append(reqReq, r.String()) } if !contains(reqReq, "method") { t.Errorf("%s: request.required = %v, want 'method'; got schema: %s", cleaner, reqReq, got) } } } // TestCleanJSONSchema_BarePropertyMapWithSiblingDescription tests bare property maps with sibling // annotations (e.g. description, title, required) alongside child property definitions. func TestCleanJSONSchema_BarePropertyMapWithSiblingDescription(t *testing.T) { input := `{ "type": "object", "properties": { "data": { "description": "Task payload", "parent": { "type": "string", "required": true }, "insert_after": { "type": "string" } } } }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "gemini": CleanJSONSchemaForGemini, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, } { got := clean(input) parsed := ojson.Parse(got) if parsed.Get("properties.data.type").String() != "object" { t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got) } if parsed.Get("properties.data.description").String() != "Task payload" { t.Errorf("%s: properties.data.description = %q, want 'Task payload'; got schema: %s", cleaner, parsed.Get("properties.data.description").String(), got) } if parsed.Get("properties.data.properties.parent.type").String() != "string" { t.Errorf("%s: properties.data.properties.parent.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.parent.type").String(), got) } if parsed.Get("properties.data.properties.insert_after.type").String() != "string" { t.Errorf("%s: properties.data.properties.insert_after.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.insert_after.type").String(), got) } var dataReq []string for _, r := range parsed.Get("properties.data.required").Array() { dataReq = append(dataReq, r.String()) } if !contains(dataReq, "parent") { t.Errorf("%s: properties.data.required = %v, want 'parent'; got schema: %s", cleaner, dataReq, got) } } } // TestCleanJSONSchema_SingleKeySchemaWrapper tests that cleanNestedSchema wrapper {"schema": ...} // is unwrapped, normalized, and placeholder is properly placed without root pollution. func TestCleanJSONSchema_SingleKeySchemaWrapper(t *testing.T) { inner := `{ "type": "object", "properties": { "data": { "parent": { "type": "string", "required": true } } } }` wrapped := `{"schema": ` + inner + `}` result := CleanJSONSchemaForAntigravityTool(wrapped, true) parsed := ojson.Parse(result) if !parsed.Get("schema").Exists() { t.Fatalf("wrapper key 'schema' was lost: %s", result) } if parsed.Get("schema.properties.data.type").String() != "object" { t.Errorf("schema.properties.data.type = %q, want object; got: %s", parsed.Get("schema.properties.data.type").String(), result) } if parsed.Get("schema.properties.data.properties.parent.type").String() != "string" { t.Errorf("schema.properties.data.properties.parent.type = %q, want string; got: %s", parsed.Get("schema.properties.data.properties.parent.type").String(), result) } var dataReq []string for _, r := range parsed.Get("schema.properties.data.required").Array() { dataReq = append(dataReq, r.String()) } if !contains(dataReq, "parent") { t.Errorf("schema.properties.data.required = %v, want 'parent'; got: %s", dataReq, result) } } // TestCleanJSONSchema_BarePropertyMapWithExplicitTypeObject tests that nodes declaring // type: "object" but omitting properties wrapper are correctly normalized. func TestCleanJSONSchema_BarePropertyMapWithExplicitTypeObject(t *testing.T) { input := `{ "type": "object", "properties": { "data": { "type": "object", "parent": { "type": "string", "required": true }, "insert_after": { "type": "string" } } } }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "gemini": CleanJSONSchemaForGemini, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, } { got := clean(input) parsed := ojson.Parse(got) if parsed.Get("properties.data.type").String() != "object" { t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got) } if parsed.Get("properties.data.properties.parent.type").String() != "string" { t.Errorf("%s: properties.data.properties.parent.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.parent.type").String(), got) } if parsed.Get("properties.data.properties.insert_after.type").String() != "string" { t.Errorf("%s: properties.data.properties.insert_after.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.insert_after.type").String(), got) } var dataReq []string for _, r := range parsed.Get("properties.data.required").Array() { dataReq = append(dataReq, r.String()) } if !contains(dataReq, "parent") { t.Errorf("%s: properties.data.required = %v, want 'parent'; got schema: %s", cleaner, dataReq, got) } } } // TestCleanJSONSchema_BarePropertyMapWithNullable tests bare property maps with nullable: true. func TestCleanJSONSchema_BarePropertyMapWithNullable(t *testing.T) { input := `{ "type": "object", "properties": { "data": { "nullable": true, "description": "Task payload", "parent": { "type": "string" } } } }` for cleaner, clean := range map[string]func(string) string{ "antigravityResponse": CleanJSONSchemaForAntigravityResponse, "gemini": CleanJSONSchemaForGemini, } { got := clean(input) parsed := ojson.Parse(got) if parsed.Get("properties.data.type").String() != "object" { t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got) } if parsed.Get("properties.data.properties.parent.type").String() != "string" { t.Errorf("%s: properties.data.properties.parent.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.parent.type").String(), got) } } } // TestCleanJSONSchema_PreservesHTMLCharactersWithoutEscaping tests that < > & in descriptions // are not converted into HTML entities (\u003c, \u003e, \u0026). func TestCleanJSONSchema_PreservesHTMLCharactersWithoutEscaping(t *testing.T) { input := `{ "type": "object", "properties": { "data": { "description": "Uses & symbols > threshold", "parent": { "type": "string" } } } }` result := CleanJSONSchemaForAntigravityResponse(input) if strings.Contains(result, `\u003c`) || strings.Contains(result, `\u003e`) || strings.Contains(result, `\u0026`) { t.Errorf("HTML characters were escaped: %s", result) } if !strings.Contains(result, "") || !strings.Contains(result, "& symbols >") { t.Errorf("Original description with HTML characters was corrupted: %s", result) } } // TestCleanJSONSchema_VendorExtensionOnEnumNotWrappedIntoProperties tests that vendor extensions // on non-object types (e.g. x-google-enum-descriptions on a string enum) are not wrapped into properties. func TestCleanJSONSchema_VendorExtensionOnEnumNotWrappedIntoProperties(t *testing.T) { input := `{ "type": "string", "enum": ["FOO", "BAR"], "x-google-enum-descriptions": { "FOO": "Foo option", "BAR": "Bar option" } }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "gemini": CleanJSONSchemaForGemini, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, } { got := clean(input) parsed := ojson.Parse(got) if parsed.Get("properties").Exists() { t.Errorf("%s: string enum gained unexpected properties: %s", cleaner, got) } if parsed.Get("type").String() != "string" { t.Errorf("%s: string type corrupted: %s", cleaner, got) } } } // TestCleanJSONSchema_ObjectDefaultNotWrappedIntoProperties tests that object-typed default // is not wrapped into properties as an orphan bare property. func TestCleanJSONSchema_ObjectDefaultNotWrappedIntoProperties(t *testing.T) { input := `{ "type": "object", "properties": { "settings": { "type": "object", "default": { "theme": "dark", "lang": "en" } } } }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "gemini": CleanJSONSchemaForGemini, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, } { got := clean(input) parsed := ojson.Parse(got) // settings must not gain properties.default.properties.theme if parsed.Get("properties.settings.properties.default").Exists() { t.Errorf("%s: default was converted to property: %s", cleaner, got) } } } // TestCleanJSONSchema_MixedPropertiesAndOrphanBareProperty tests that orphan bare property maps // alongside an existing properties object are collected into properties. func TestCleanJSONSchema_MixedPropertiesAndOrphanBareProperty(t *testing.T) { input := `{ "type": "object", "properties": { "foo": { "type": "string" } }, "bar": { "type": "integer", "required": true } }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "gemini": CleanJSONSchemaForGemini, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, } { got := clean(input) parsed := ojson.Parse(got) if parsed.Get("properties.foo.type").String() != "string" { t.Errorf("%s: foo corrupted: %s", cleaner, got) } if parsed.Get("properties.bar.type").String() != "integer" { t.Errorf("%s: orphan bar was not moved to properties: %s", cleaner, got) } var req []string for _, r := range parsed.Get("required").Array() { req = append(req, r.String()) } if !contains(req, "bar") { t.Errorf("%s: bar required not promoted: %s", cleaner, got) } if parsed.Get("bar").Exists() { t.Errorf("%s: top-level bar survived: %s", cleaner, got) } } } // TestCleanJSONSchema_PreservesAdditionalPropertiesObjectSchema tests that a standalone // additionalProperties schema is recognized as a structural keyword and not wrapped as a property. func TestCleanJSONSchema_PreservesAdditionalPropertiesObjectSchema(t *testing.T) { input := `{ "additionalProperties": { "type": "string" } }` for cleaner, clean := range map[string]func(string) string{ "antigravityResponse": CleanJSONSchemaForAntigravityResponse, "gemini": CleanJSONSchemaForGemini, } { got := clean(input) parsed := ojson.Parse(got) // Should not be wrapped as properties.additionalProperties if parsed.Get("properties.additionalProperties").Exists() { t.Errorf("%s: additionalProperties was wrapped into properties: %s", cleaner, got) } } } // TestCleanJSONSchemaForAntigravityResponse_AnyOfRequiredOnlyBranches tests issue 5219 #2: // anyOf with required-only branches should not overwrite the parent object's type and properties, // and should materialize valid typed object branches from parent properties so the union is preserved. func TestCleanJSONSchemaForAntigravityResponse_AnyOfRequiredOnlyBranches(t *testing.T) { input := `{ "type": "object", "anyOf": [ {"required": ["left"]}, {"required": ["right"]} ], "properties": { "left": {"type": "integer"}, "right": {"type": "integer"} }, "additionalProperties": false }` got := CleanJSONSchemaForAntigravityResponse(input) parsed := ojson.Parse(got) if parsed.Get("type").String() != "object" { t.Fatalf("type = %q, want object; cleaned: %s", parsed.Get("type").String(), got) } if !parsed.Get("properties.left").Exists() || !parsed.Get("properties.right").Exists() { t.Fatalf("properties were wiped out; cleaned: %s", got) } branches := parsed.Get("anyOf").Array() if len(branches) != 2 { t.Fatalf("anyOf branch count = %d, want 2; cleaned: %s", len(branches), got) } if branches[0].Get("type").String() != "object" || !branches[0].Get("properties.left").Exists() || branches[0].Get("required.0").String() != "left" { t.Fatalf("anyOf[0] was not materialized into valid object branch: %s", branches[0].Raw) } if branches[1].Get("type").String() != "object" || !branches[1].Get("properties.right").Exists() || branches[1].Get("required.0").String() != "right" { t.Fatalf("anyOf[1] was not materialized into valid object branch: %s", branches[1].Raw) } } // TestCleanJSONSchemaForAntigravityResponse_ContainsKeywordStripped tests issue 5219 #3: // contains keyword in array schemas should be stripped and moved to description hint. func TestCleanJSONSchemaForAntigravityResponse_ContainsKeywordStripped(t *testing.T) { input := `{ "type": "object", "properties": { "tags": { "type": "array", "items": {"type": "string"}, "contains": {"enum": ["x"]} } }, "required": ["tags"], "additionalProperties": false }` for cleaner, clean := range map[string]func(string) string{ "antigravityResponse": CleanJSONSchemaForAntigravityResponse, "antigravity": CleanJSONSchemaForAntigravity, "gemini": CleanJSONSchemaForGemini, } { got := clean(input) parsed := ojson.Parse(got) if parsed.Get("properties.tags.contains").Exists() { t.Errorf("%s: contains keyword was not removed: %s", cleaner, got) } desc := parsed.Get("properties.tags.description").String() if !strings.Contains(desc, "contains") { t.Errorf("%s: contains description hint missing: %s", cleaner, got) } } } // TestCleanJSONSchema_RemovesDraft04IdAndSchemaIdentifierKeywords covers Issue #5888: // Draft-04 schema identifier "id" and Draft 2019-09/2020-12 identifier keywords ($anchor, $vocabulary, // $dynamicRef, $dynamicAnchor) should be stripped from schema nodes while preserving properties legitimately named "id". func TestCleanJSONSchema_RemovesDraft04IdAndSchemaIdentifierKeywords(t *testing.T) { // Repro case from Issue #5888: MCP tool property schema containing "id": "ContentType" input := `{ "id": "http://example.com/root.json", "$anchor": "rootAnchor", "$vocabulary": {"https://json-schema.org/draft/2020-12/vocab/core": true}, "type": "object", "properties": { "kind": { "type": "string", "enum": ["short", "video"], "id": "ContentType", "$anchor": "contentTypeAnchor", "$dynamicAnchor": "dynAnchor", "$dynamicRef": "#dynAnchor", "description": "Kind" }, "id": { "type": "string", "description": "Property legitimately named id should survive" } }, "required": ["kind"] }` for cleaner, clean := range map[string]func(string) string{ "gemini": CleanJSONSchemaForGemini, "antigravity": CleanJSONSchemaForAntigravity, "antigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, } { got := clean(input) parsed := ojson.Parse(got) // Root keywords should be stripped if parsed.Get("id").Exists() { t.Errorf("%s: root 'id' was not removed: %s", cleaner, got) } if parsed.Get("$anchor").Exists() { t.Errorf("%s: root '$anchor' was not removed: %s", cleaner, got) } if parsed.Get("$vocabulary").Exists() { t.Errorf("%s: root '$vocabulary' was not removed: %s", cleaner, got) } // Keywords inside property schema should be stripped if parsed.Get("properties.kind.id").Exists() { t.Errorf("%s: 'properties.kind.id' was not removed: %s", cleaner, got) } if parsed.Get("properties.kind.$anchor").Exists() { t.Errorf("%s: 'properties.kind.$anchor' was not removed: %s", cleaner, got) } if parsed.Get("properties.kind.$dynamicAnchor").Exists() { t.Errorf("%s: 'properties.kind.$dynamicAnchor' was not removed: %s", cleaner, got) } if parsed.Get("properties.kind.$dynamicRef").Exists() { t.Errorf("%s: 'properties.kind.$dynamicRef' was not removed: %s", cleaner, got) } // Property named "id" must be preserved if !parsed.Get("properties.id").Exists() { t.Errorf("%s: property named 'id' was incorrectly removed: %s", cleaner, got) } if parsed.Get("properties.id.type").String() != "string" { t.Errorf("%s: property named 'id' type corrupted: %s", cleaner, got) } } // Real-world MCP repro: definition carrying "id": "ContentType" expanded via $ref refInput := `{ "definitions": { "ContentType": { "type": "string", "enum": ["short", "video"], "id": "ContentType", "description": "Kind" } }, "type": "object", "properties": { "kind": { "$ref": "#/definitions/ContentType" } }, "required": ["kind"] }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, } { got := clean(refInput) parsed := ojson.Parse(got) if !parsed.Get("properties.kind").Exists() || parsed.Get("properties.kind.type").String() != "string" { t.Errorf("%s: inlined $ref property 'properties.kind' corrupted or missing: %s", cleaner, got) } if parsed.Get("properties.kind.id").Exists() { t.Errorf("%s: inlined $ref 'properties.kind.id' was not removed: %s", cleaner, got) } if parsed.Get("definitions").Exists() { t.Errorf("%s: 'definitions' was not removed: %s", cleaner, got) } } } func TestCleanJSONSchema_TrueBooleanSubschemas(t *testing.T) { // Issue #3551: Antigravity rejects OpenAI function tools containing `true` JSON Schema subschemas. input := `{ "type": "object", "properties": { "screenshot_id": true, "filename": true, "file_size": true, "source_file_checksum": true, "disabled_field": false }, "additionalProperties": true }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "antigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, "gemini": CleanJSONSchemaForGemini, } { got := clean(input) parsed := ojson.Parse(got) // true subschemas under properties must be converted to empty object schemas {} for _, prop := range []string{"screenshot_id", "filename", "file_size", "source_file_checksum"} { val := parsed.Get("properties." + prop) if !val.Exists() { t.Fatalf("%s: expected property %q to exist in %s", cleaner, prop, got) } if val.Type != ojson.JSON || val.Raw != "{}" { t.Errorf("%s: property %q should be normalized to {}, got %s (type %v)", cleaner, prop, val.Raw, val.Type) } } // false subschema must NOT be converted to {} to preserve rejection semantics disabledVal := parsed.Get("properties.disabled_field") if !disabledVal.Exists() { t.Fatalf("%s: expected property 'disabled_field' to exist in %s", cleaner, got) } if disabledVal.Type != ojson.False { t.Errorf("%s: property 'disabled_field' should remain false, got %s", cleaner, disabledVal.Raw) } } } func TestCleanJSONSchema_NestedTrueBooleanSubschemas(t *testing.T) { // Issue #3551: Verify boolean true subschema normalization in nested schema positions. input := `{ "type": "object", "properties": { "tags": { "type": "array", "items": true }, "tuple": { "type": "array", "items": [true, {"type": "string"}], "additionalItems": true }, "union": { "anyOf": [true, {"type": "string"}] }, "combination": { "allOf": [true, {"type": "object", "properties": {"opt": true}}] }, "metadata": { "type": "object", "properties": { "nested_true": true, "nested_false": false } }, "large_int": 9007199254740993 }, "$defs": { "custom_schema": true } }` for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "antigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, "gemini": CleanJSONSchemaForGemini, } { got := clean(input) parsed := ojson.Parse(got) // tags.items: true -> {} if val := parsed.Get("properties.tags.items"); val.Exists() && val.Type == ojson.True { t.Errorf("%s: tags.items should not be boolean true: %s", cleaner, got) } // nested properties: true -> {}, false preserved if val := parsed.Get("properties.metadata.properties.nested_true"); !val.Exists() || val.Type != ojson.JSON || val.Raw != "{}" { t.Errorf("%s: nested_true should be {}, got %s", cleaner, val.Raw) } if val := parsed.Get("properties.metadata.properties.nested_false"); !val.Exists() || val.Type != ojson.False { t.Errorf("%s: nested_false should remain false, got %s", cleaner, val.Raw) } // tuple items: true in list should be normalized to {} if val := parsed.Get("properties.tuple.items.0"); val.Exists() && val.Type == ojson.True { t.Errorf("%s: tuple items[0] should not be boolean true: %s", cleaner, got) } // union / combination inner property if val := parsed.Get("properties.combination.properties.opt"); val.Exists() { if val.Type == ojson.True { t.Errorf("%s: combination.properties.opt should not be boolean true: %s", cleaner, got) } } // large integer preservation if val := parsed.Get("properties.large_int"); !val.Exists() || val.Raw != "9007199254740993" { t.Errorf("%s: large_int corrupted, got %s", cleaner, val.Raw) } } } func TestCleanJSONSchema_RootAndWrappedTrue(t *testing.T) { // Verify root boolean true normalization to {} for cleaner, clean := range map[string]func(string) string{ "antigravity": CleanJSONSchemaForAntigravity, "antigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "antigravityResponse": CleanJSONSchemaForAntigravityResponse, "gemini": CleanJSONSchemaForGemini, } { got := clean("true") if got != "{}" { t.Errorf("%s: root true should normalize to {}, got %s", cleaner, got) } // Verify wrapped {"schema": true} normalization wrapped := `{"schema": true}` gotWrapped := clean(wrapped) parsed := ojson.Parse(gotWrapped) if parsed.Get("schema").Exists() && parsed.Get("schema").Type == ojson.True { t.Errorf("%s: wrapped schema true should normalize to {}, got %s", cleaner, gotWrapped) } } } func TestCleanJSONSchemaForGeminiJSONSchema_PreservesAdditionalPropertiesAndPattern_Issue5959(t *testing.T) { input := `{ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "SubmitTool", "type": "object", "additionalProperties": false, "properties": { "recipient": { "type": "string", "pattern": "^(alice|bob)$", "minLength": 3, "maxLength": 10 }, "amount": { "type": "number", "minimum": 1 }, "nested": { "type": "object", "additionalProperties": false, "properties": { "tag": { "type": "string", "pattern": "^[a-z]+$" } } }, "items_list": { "type": "array", "items": { "type": "string", "pattern": "^[0-9]+$" } } }, "required": ["recipient", "amount", "non_existent"] }` cleaned := CleanJSONSchemaForGeminiJSONSchema(input) parsed := ojson.Parse(cleaned) if got := parsed.Get("additionalProperties"); !got.Exists() || got.Type != ojson.False { t.Fatalf("additionalProperties should be preserved as false, got: %v. Cleaned: %s", got, cleaned) } if got := parsed.Get("properties.recipient.pattern"); !got.Exists() || got.String() != "^(alice|bob)$" { t.Fatalf("pattern should be preserved, got: %v. Cleaned: %s", got, cleaned) } if got := parsed.Get("properties.recipient.minLength").Int(); got != 3 { t.Fatalf("minLength should be preserved as 3, got: %v. Cleaned: %s", got, cleaned) } if got := parsed.Get("properties.recipient.maxLength").Int(); got != 10 { t.Fatalf("maxLength should be preserved as 10, got: %v. Cleaned: %s", got, cleaned) } if got := parsed.Get("properties.amount.minimum").Int(); got != 1 { t.Fatalf("minimum should be preserved as 1, got: %v. Cleaned: %s", got, cleaned) } if got := parsed.Get("properties.nested.additionalProperties"); !got.Exists() || got.Type != ojson.False { t.Fatalf("nested additionalProperties should be preserved as false, got: %v. Cleaned: %s", got, cleaned) } if got := parsed.Get("properties.nested.properties.tag.pattern"); !got.Exists() || got.String() != "^[a-z]+$" { t.Fatalf("nested tag pattern should be preserved, got: %v. Cleaned: %s", got, cleaned) } if got := parsed.Get("properties.items_list.items.pattern"); !got.Exists() || got.String() != "^[0-9]+$" { t.Fatalf("array items pattern should be preserved, got: %v. Cleaned: %s", got, cleaned) } if parsed.Get("title").Exists() { t.Fatalf("title should be removed. Cleaned: %s", cleaned) } if parsed.Get("$schema").Exists() { t.Fatalf("$schema should be removed. Cleaned: %s", cleaned) } if parsed.Get("description").Exists() && parsed.Get("description").String() == "No extra properties allowed" { t.Fatalf("additionalProperties: false should not be converted to description hint. Cleaned: %s", cleaned) } if got := parsed.Get("properties.recipient.description"); got.Exists() && strings.Contains(got.String(), "pattern:") { t.Fatalf("pattern should not be converted to description hint. Cleaned: %s", cleaned) } if got := parsed.Get("properties.nested.properties.tag.description"); got.Exists() && strings.Contains(got.String(), "pattern:") { t.Fatalf("nested pattern should not be converted to description hint. Cleaned: %s", cleaned) } if got := parsed.Get("properties.items_list.items.description"); got.Exists() && strings.Contains(got.String(), "pattern:") { t.Fatalf("array items pattern should not be converted to description hint. Cleaned: %s", cleaned) } // non_existent should be removed from required because it's not in properties required := parsed.Get("required").Array() if len(required) != 2 { t.Fatalf("required length = %d, want 2. Cleaned: %s", len(required), cleaned) } // Compare with legacy CleanJSONSchemaForGemini to verify backward compatibility legacyCleaned := CleanJSONSchemaForGemini(input) legacyParsed := ojson.Parse(legacyCleaned) if legacyParsed.Get("additionalProperties").Exists() { t.Fatalf("legacy cleaner should strip additionalProperties. Cleaned: %s", legacyCleaned) } if !strings.Contains(legacyParsed.Get("description").String(), "No extra properties allowed") { t.Fatalf("legacy cleaner should add description hint for additionalProperties. Cleaned: %s", legacyCleaned) } if legacyParsed.Get("properties.recipient.pattern").Exists() { t.Fatalf("legacy cleaner should strip pattern. Cleaned: %s", legacyCleaned) } } func TestCleanJSONSchemaForGeminiJSONSchema_PreservesSchemaValuedAdditionalProperties(t *testing.T) { input := `{ "type": "object", "additionalProperties": { "type": "string", "pattern": "^[a-z]+$", "minLength": 2 } }` cleaned := CleanJSONSchemaForGeminiJSONSchema(input) parsed := ojson.Parse(cleaned) ap := parsed.Get("additionalProperties") if !ap.Exists() || !ap.IsObject() { t.Fatalf("additionalProperties object should be preserved, got: %s", cleaned) } if got := ap.Get("type").String(); got != "string" { t.Fatalf("additionalProperties.type = %q, want string", got) } if got := ap.Get("pattern").String(); got != "^[a-z]+$" { t.Fatalf("additionalProperties.pattern = %q, want ^[a-z]+$", got) } if got := ap.Get("minLength").Int(); got != 2 { t.Fatalf("additionalProperties.minLength = %d, want 2", got) } if ap.Get("description").Exists() && strings.Contains(ap.Get("description").String(), "pattern:") { t.Fatalf("additionalProperties pattern should not be converted to description hint: %s", cleaned) } // Legacy cleaner should strip schema-valued additionalProperties legacyCleaned := CleanJSONSchemaForGemini(input) if ojson.Get(legacyCleaned, "additionalProperties").Exists() { t.Fatalf("legacy CleanJSONSchemaForGemini should strip additionalProperties schema: %s", legacyCleaned) } } // TestCleanJSONSchema_ArrayItemsRequireArrayType_Issue6011 covers Issue #6011: // Gemini rejects tool schemas where a property declares "items" but its type is not "array" // (e.g. type is omitted or flattened to a non-array type), returning: // "field predicate failed: $type == Type.ARRAY". func TestCleanJSONSchema_ArrayItemsRequireArrayType_Issue6011(t *testing.T) { // Case 1: property declares "items" but omits "type": "array" (relying on JSON Schema inference) // Reproduces the error path reported in Issue #6011: properties.revision_reasons.items.properties.evidence_reference inputMissingType := `{ "type": "object", "properties": { "revision_reasons": { "type": "array", "items": { "type": "object", "properties": { "evidence_reference": { "description": "references to evidence", "items": { "type": "string" } } } } } } }` cleaners := map[string]func(string) string{ "gemini": CleanJSONSchemaForGemini, "geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema, "antigravity": CleanJSONSchemaForAntigravity, "antigravityLegacy": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, } for name, clean := range cleaners { t.Run(name+"_missing_type", func(t *testing.T) { cleaned := clean(inputMissingType) parsed := ojson.Parse(cleaned) target := parsed.Get("properties.revision_reasons.items.properties.evidence_reference") if !target.Exists() { t.Fatalf("evidence_reference property missing in %s: %s", name, cleaned) } if gotType := target.Get("type").String(); gotType != "array" { t.Fatalf("%s: evidence_reference.type = %q, want 'array'; cleaned schema: %s", name, gotType, cleaned) } if gotItemsType := target.Get("items.type").String(); gotItemsType != "string" { t.Fatalf("%s: evidence_reference.items.type = %q, want 'string'; cleaned schema: %s", name, gotItemsType, cleaned) } }) } // Case 2: property declares union type: ["string", "array"] with items inputUnionType := `{ "type": "object", "properties": { "revision_reasons": { "type": "array", "items": { "type": "object", "properties": { "evidence_reference": { "type": ["string", "array"], "items": { "type": "string" } } } } } } }` for name, clean := range cleaners { t.Run(name+"_union_type_with_items", func(t *testing.T) { cleaned := clean(inputUnionType) parsed := ojson.Parse(cleaned) target := parsed.Get("properties.revision_reasons.items.properties.evidence_reference") if gotType := target.Get("type").String(); gotType != "array" { t.Fatalf("%s: evidence_reference.type = %q, want 'array' when items is defined; cleaned schema: %s", name, gotType, cleaned) } }) } // Case 3: property has explicit non-array type (e.g. "string") with extraneous items inputNonArrayWithItems := `{ "type": "object", "properties": { "label": { "type": "string", "items": { "type": "string" } }, "config": { "type": "object", "properties": { "key": { "type": "string" } }, "items": { "type": "string" } } } }` for name, clean := range cleaners { t.Run(name+"_non_array_strips_items", func(t *testing.T) { cleaned := clean(inputNonArrayWithItems) parsed := ojson.Parse(cleaned) if parsed.Get("properties.label.items").Exists() { t.Fatalf("%s: properties.label should not retain items on string type: %s", name, cleaned) } if parsed.Get("properties.config.items").Exists() { t.Fatalf("%s: properties.config should not retain items on object type: %s", name, cleaned) } if parsed.Get("properties.label.type").String() != "string" { t.Fatalf("%s: properties.label.type should remain string: %s", name, cleaned) } }) } // Case 4: property literally named "items" should be preserved and not treated as array items inputPropertyNamedItems := `{ "type": "object", "properties": { "items": { "type": "string", "description": "a field named items" } } }` for name, clean := range cleaners { t.Run(name+"_property_named_items_preserved", func(t *testing.T) { cleaned := clean(inputPropertyNamedItems) parsed := ojson.Parse(cleaned) itemProp := parsed.Get("properties.items") if !itemProp.Exists() { t.Fatalf("%s: property named 'items' was removed: %s", name, cleaned) } if itemProp.Get("type").String() != "string" { t.Fatalf("%s: property named 'items' type changed: %s", name, cleaned) } }) } // Case 5: Root array with items missing type inputRootArrayMissingType := `{ "items": { "type": "string" } }` for name, clean := range cleaners { t.Run(name+"_root_array_missing_type", func(t *testing.T) { cleaned := clean(inputRootArrayMissingType) parsed := ojson.Parse(cleaned) if parsed.Get("type").String() != "array" { t.Fatalf("%s: root schema type should be inferred as 'array': %s", name, cleaned) } if parsed.Get("items.type").String() != "string" { t.Fatalf("%s: root schema items.type should remain 'string': %s", name, cleaned) } }) } // Case 6: Cleaning is idempotent across all cleaners for name, clean := range cleaners { t.Run(name+"_idempotent", func(t *testing.T) { cleaned1 := clean(inputMissingType) cleaned2 := clean(cleaned1) if cleaned1 != cleaned2 { t.Fatalf("%s: cleaner is not idempotent.\nFirst:\n%s\nSecond:\n%s", name, cleaned1, cleaned2) } }) } // Case 7: Response schema regression check t.Run("antigravity_response_schema", func(t *testing.T) { // Response schema declaring items without type infers array type respInput := `{"type":"object","properties":{"names":{"items":{"type":"string"}}}}` cleaned := CleanJSONSchemaForAntigravityResponse(respInput) parsed := ojson.Parse(cleaned) if parsed.Get("properties.names.type").String() != "array" { t.Fatalf("response schema should infer type 'array' when items is defined: %s", cleaned) } // Response array missing items still remains untouched respNoItems := `{"type":"object","properties":{"values":{"type":"array"}}}` cleanedNoItems := CleanJSONSchemaForAntigravityResponse(respNoItems) parsedNoItems := ojson.Parse(cleanedNoItems) if parsedNoItems.Get("properties.values.items").Exists() { t.Fatalf("response array missing items should not gain placeholder items: %s", cleanedNoItems) } }) } func TestSanitizeArrayItems_PreservesItemsForUppercaseArrayType(t *testing.T) { input := `{ "type": "OBJECT", "properties": { "summary": {"type": "STRING"}, "brands": { "type": "ARRAY", "items": {"type": "STRING"} }, "catalog": { "type": "OBJECT", "properties": { "items": { "type": "ARRAY", "items": { "type": "OBJECT", "properties": {"id": {"type": "STRING"}} } } } } } }` cleaners := map[string]func(string) string{ "AntigravityResponse": CleanJSONSchemaForAntigravityResponse, "Antigravity": CleanJSONSchemaForAntigravity, "Gemini": CleanJSONSchemaForGemini, "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, } for name, clean := range cleaners { t.Run(name, func(t *testing.T) { got := clean(input) parsed := ojson.Parse(got) brandsItems := parsed.Get("properties.brands.items") if !brandsItems.Exists() { t.Fatalf("[%s] properties.brands.items was stripped from uppercase ARRAY: %s", name, got) } typeStr := parsed.Get("properties.brands.type").String() if !strings.EqualFold(typeStr, "array") { t.Fatalf("[%s] properties.brands.type corrupted: %s", name, got) } nestedItems := parsed.Get("properties.catalog.properties.items.items") if !nestedItems.Exists() { t.Fatalf("[%s] properties.catalog.properties.items.items was stripped: %s", name, got) } }) } } // TestCleanJSONSchema_EnforcesObjectTypeForProperties_Issue6394 tests issue 6394: // When a property defines nested properties/required (e.g. through a union type like ["string", "object"], // merged anyOf branches, or missing type), Gemini requires type to be OBJECT. // Properties or required must never be emitted on a non-OBJECT type node. func TestCleanJSONSchema_EnforcesObjectTypeForProperties_Issue6394(t *testing.T) { cleaners := map[string]func(string) string{ "Gemini": CleanJSONSchemaForGemini, "GeminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema, "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "AntigravityResponse": CleanJSONSchemaForAntigravityResponse, } t.Run("TypeArrayWithPropertiesPrefersObject", func(t *testing.T) { input := `{"type":"object","properties":{"requestBody":{"type":"object","properties":{"delivery":{"type":"object","properties":{"endpoint":{"type":["string","object"],"properties":{"traces":{"type":"string"}},"required":["traces"]}},"required":["endpoint"]}},"required":["delivery"]}},"required":["requestBody"]}` for name, clean := range cleaners { t.Run(name, func(t *testing.T) { got := clean(input) parsed := ojson.Parse(got) endpointType := parsed.Get("properties.requestBody.properties.delivery.properties.endpoint.type").String() if !strings.EqualFold(endpointType, "object") { t.Fatalf("[%s] endpoint type = %q, want object; cleaned: %s", name, endpointType, got) } if !parsed.Get("properties.requestBody.properties.delivery.properties.endpoint.properties.traces").Exists() { t.Fatalf("[%s] endpoint.properties.traces missing; cleaned: %s", name, got) } }) } }) t.Run("AnyOfUnionMergedIntoNodeWithoutTypeEnsuresObject", func(t *testing.T) { input := `{"type":"object","properties":{"endpoint":{"properties":{"traces":{"type":"string"}},"required":["traces"],"anyOf":[{"type":"string"},{"type":"object","properties":{"other":{"type":"string"}}}]}}}` for name, clean := range cleaners { t.Run(name, func(t *testing.T) { got := clean(input) parsed := ojson.Parse(got) endpointType := parsed.Get("properties.endpoint.type").String() if !strings.EqualFold(endpointType, "object") { t.Fatalf("[%s] endpoint type = %q, want object; cleaned: %s", name, endpointType, got) } if !parsed.Get("properties.endpoint.properties.traces").Exists() { t.Fatalf("[%s] endpoint.properties.traces missing; cleaned: %s", name, got) } }) } }) t.Run("PrimitiveTypeWithPropertiesPromotedToObject", func(t *testing.T) { input := `{"type":"object","properties":{"endpoint":{"type":"string","properties":{"traces":{"type":"string"}},"required":["traces"]}}}` for name, clean := range cleaners { t.Run(name, func(t *testing.T) { got := clean(input) parsed := ojson.Parse(got) endpointType := parsed.Get("properties.endpoint.type").String() if !strings.EqualFold(endpointType, "object") { t.Fatalf("[%s] endpoint type = %q, want object; cleaned: %s", name, endpointType, got) } }) } }) } // TestCleanJSONSchema_RemovesDependentKeywords_Issue6539 covers Issue #6539: // JSON Schema keywords dependentRequired, dependentSchemas, and dependencies are rejected by // the Gemini/Antigravity backend with 400 INVALID_ARGUMENT ("Unknown name \"dependentRequired\" ... Cannot find field") // and must be stripped while preserving legitimate tool properties with those names. func TestCleanJSONSchema_RemovesDependentKeywords_Issue6539(t *testing.T) { input := `{ "type": "object", "properties": { "baseline_url": {"type": "string"}, "candidate_url": {"type": "string"}, "options": { "type": "object", "properties": { "threshold": {"type": "number"}, "diff_color": {"type": "string"} }, "dependentRequired": { "threshold": ["diff_color"] }, "dependentSchemas": { "threshold": { "properties": { "diff_color": {"type": "string"} } } }, "dependencies": { "threshold": ["diff_color"] } }, "dependentRequired": { "type": "string", "description": "Legitimate property named dependentRequired" }, "dependentSchemas": { "type": "object", "properties": { "nested": {"type": "string"} }, "dependentRequired": { "nested": ["other"] } }, "dependencies": { "type": "string", "description": "Legitimate property named dependencies" } }, "dependentRequired": { "baseline_url": ["candidate_url"] }, "dependentSchemas": { "baseline_url": { "required": ["candidate_url"] } }, "dependencies": { "baseline_url": ["candidate_url"] } }` cleaners := map[string]func(string) string{ "Antigravity": CleanJSONSchemaForAntigravity, "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "AntigravityResponse": CleanJSONSchemaForAntigravityResponse, "Gemini": CleanJSONSchemaForGemini, "GeminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema, } for name, clean := range cleaners { t.Run(name, func(t *testing.T) { got := clean(input) parsed := ojson.Parse(got) for _, kw := range []string{"dependentRequired", "dependentSchemas", "dependencies"} { if parsed.Get(kw).Exists() { t.Errorf("[%s] root %q was not removed: %s", name, kw, got) } if parsed.Get("properties.options." + kw).Exists() { t.Errorf("[%s] nested properties.options.%s was not removed: %s", name, kw, got) } } if parsed.Get("properties.dependentSchemas.dependentRequired").Exists() { t.Errorf("[%s] properties.dependentSchemas.dependentRequired was not removed: %s", name, got) } for _, prop := range []string{"dependentRequired", "dependentSchemas", "dependencies"} { if !parsed.Get("properties." + prop).Exists() { t.Errorf("[%s] legitimate property %q was removed: %s", name, prop, got) } } }) } } // TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662 covers Issue #3662: // Multi-branch anyOf/oneOf unions (such as string | image object | audio object) must not be // flattened to a single selected branch, which previously caused non-first object branches (e.g. audio) // to disappear from the cleaned schema. // Multi-branch unions are preserved by every cleaner except the Antigravity Claude // tool cleaner, which flattens them for the Claude-side upstream that rejects nested // multi-branch unions (Issue 3662 regression follow-up). func TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662(t *testing.T) { input := `{ "type": "object", "properties": { "item": { "anyOf": [ { "type": "string" }, { "type": "object", "properties": { "image": { "type": "string" } }, "required": ["image"] }, { "type": "object", "properties": { "audio": { "type": "string" } }, "required": ["audio"] } ] } }, "required": ["item"] }` cleaners := map[string]func(string) string{ "Antigravity": CleanJSONSchemaForAntigravity, "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "AntigravityResponse": CleanJSONSchemaForAntigravityResponse, "Gemini": CleanJSONSchemaForGemini, "GeminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema, } for name, clean := range cleaners { t.Run(name, func(t *testing.T) { got := clean(input) compareJSON(t, input, got) // Verify idempotency when cleaned a second time gotSecond := clean(got) compareJSON(t, got, gotSecond) }) } t.Run("NullableMultiBranchUnionStripsNullBranchAndSetsNullable", func(t *testing.T) { nullableUnion := `{ "type": "object", "properties": { "item": { "anyOf": [ { "type": "string" }, { "type": "object", "properties": { "image": { "type": "string" } }, "required": ["image"] }, { "type": "object", "properties": { "audio": { "type": "string" } }, "required": ["audio"] }, { "type": "null" } ] } }, "required": ["item"] }` agCleaned := ojson.Parse(CleanJSONSchemaForAntigravity(nullableUnion)) branches := agCleaned.Get("properties.item.anyOf").Array() if len(branches) != 3 { t.Fatalf("Antigravity anyOf branch count = %d, want 3: %s", len(branches), agCleaned.Raw) } if !agCleaned.Get("properties.item.nullable").Bool() { t.Fatalf("Antigravity properties.item.nullable = false, want true: %s", agCleaned.Raw) } if !agCleaned.Get("properties.item.anyOf.2.properties.audio").Exists() { t.Fatalf("Antigravity lost audio branch: %s", agCleaned.Raw) } geminiCleaned := ojson.Parse(CleanJSONSchemaForGeminiJSONSchema(nullableUnion)) if len(geminiCleaned.Get("properties.item.anyOf").Array()) != 3 { t.Fatalf("GeminiJSONSchema anyOf branch count = %d, want 3: %s", len(geminiCleaned.Get("properties.item.anyOf").Array()), geminiCleaned.Raw) } if geminiCleaned.Get("properties.item.nullable").Exists() { t.Fatalf("GeminiJSONSchema should not keep nullable keyword: %s", geminiCleaned.Raw) } if !strings.Contains(geminiCleaned.Get("properties.item.description").String(), "(nullable)") { t.Fatalf("GeminiJSONSchema missing (nullable) hint: %s", geminiCleaned.Raw) } // Nested nullable union in both [null, nestedUnion] and [nestedUnion, null] orders // must unwrap to the nested union with nullable metadata and remain idempotent. for _, nestedNullable := range []string{ `{"type":"object","properties":{"item":{"anyOf":[{"type":"null"},{"anyOf":[{"type":"string"},{"type":"integer"}]}]}}}`, `{"type":"object","properties":{"item":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"integer"}]},{"type":"null"}]}}}`, } { expectedNested := `{ "type": "object", "properties": { "item": { "anyOf": [ { "type": "string" }, { "type": "integer" } ], "nullable": true, "description": "(nullable)" } } }` cleanedOnce := CleanJSONSchemaForAntigravity(nestedNullable) compareJSON(t, expectedNested, cleanedOnce) cleanedTwice := CleanJSONSchemaForAntigravity(cleanedOnce) compareJSON(t, expectedNested, cleanedTwice) } }) t.Run("OneOfNormalizedToAnyOfOnNativeSchemaCarriers", func(t *testing.T) { oneOfInput := `{ "type": "object", "properties": { "item": { "oneOf": [ { "type": "string" }, { "type": "object", "properties": { "image": { "type": "string" } }, "required": ["image"] }, { "type": "object", "properties": { "audio": { "type": "string" } }, "required": ["audio"] } ] } }, "required": ["item"] }` for name, clean := range map[string]func(string) string{ "Antigravity": CleanJSONSchemaForAntigravity, "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "AntigravityResponse": CleanJSONSchemaForAntigravityResponse, "Gemini": CleanJSONSchemaForGemini, } { parsed := ojson.Parse(clean(oneOfInput)) if parsed.Get("properties.item.oneOf").Exists() { t.Fatalf("[%s] oneOf survived on native Schema carrier: %s", name, parsed.Raw) } if got := len(parsed.Get("properties.item.anyOf").Array()); got != 3 { t.Fatalf("[%s] anyOf branch count = %d, want 3: %s", name, got, parsed.Raw) } } // parametersJsonSchema carrier preserves standard JSON Schema oneOf jsonSchemaParsed := ojson.Parse(CleanJSONSchemaForGeminiJSONSchema(oneOfInput)) if got := len(jsonSchemaParsed.Get("properties.item.oneOf").Array()); got != 3 { t.Fatalf("[GeminiJSONSchema] oneOf branch count = %d, want 3: %s", got, jsonSchemaParsed.Raw) } }) t.Run("PreservesUnionAndBranchRequiredWhenParentHasProperties", func(t *testing.T) { parentPropsUnion := `{ "type": "object", "properties": {}, "anyOf": [ { "type": "object", "properties": { "image": { "type": "string" } }, "required": ["image"] }, { "type": "object", "properties": { "audio": { "type": "string" } }, "required": ["audio"] } ] }` for name, clean := range cleaners { t.Run(name, func(t *testing.T) { got := clean(parentPropsUnion) compareJSON(t, parentPropsUnion, got) }) } // Mixed union with a required-only branch materializes the required-only branch from parent properties // so that the alternative branch (e.g. text) is preserved in anyOf. mixedRequiredOnlyBranch := `{ "type": "object", "properties": { "image": { "type": "string" }, "audio": { "type": "string" }, "text": { "type": "string" } }, "anyOf": [ { "type": "object", "properties": { "image": { "type": "string" } }, "required": ["image"] }, { "type": "object", "properties": { "audio": { "type": "string" } }, "required": ["audio"] }, { "required": ["text"] } ] }` expectedMixedRequiredOnly := `{ "type": "object", "properties": { "image": { "type": "string" }, "audio": { "type": "string" }, "text": { "type": "string" } }, "anyOf": [ { "type": "object", "properties": { "image": { "type": "string" } }, "required": ["image"] }, { "type": "object", "properties": { "audio": { "type": "string" } }, "required": ["audio"] }, { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] } ] }` compareJSON(t, expectedMixedRequiredOnly, CleanJSONSchemaForAntigravity(mixedRequiredOnlyBranch)) // Pure required-only union branches on an object with parent properties are materialized into valid object branches. pureRequiredOnlyUnion := `{ "type": "object", "properties": { "image": { "type": "string" }, "audio": { "type": "string" } }, "anyOf": [ { "required": ["image"] }, { "required": ["audio"] } ] }` expectedPureRequiredOnly := `{ "type": "object", "properties": { "image": { "type": "string" }, "audio": { "type": "string" } }, "anyOf": [ { "type": "object", "properties": { "image": { "type": "string" } }, "required": ["image"] }, { "type": "object", "properties": { "audio": { "type": "string" } }, "required": ["audio"] } ] }` compareJSON(t, expectedPureRequiredOnly, CleanJSONSchemaForAntigravity(pureRequiredOnlyUnion)) }) t.Run("BooleanUnionBranchesAndSiblingAnyOfOneOf", func(t *testing.T) { // Boolean false subschema branches in anyOf/oneOf are unsatisfiable and must be stripped // instead of leaking raw boolean false into protobuf Schema anyOf. falseSingleBranch := `{"type":"object","properties":{"value":{"anyOf":[false,{"type":"string"}]}}}` expectedFalseSingle := `{"type":"object","properties":{"value":{"type":"string"}}}` compareJSON(t, expectedFalseSingle, CleanJSONSchemaForAntigravity(falseSingleBranch)) falseMultiBranch := `{"type":"object","properties":{"value":{"oneOf":[false,{"type":"string"},{"type":"integer"}]}}}` expectedFalseMulti := `{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"integer"}]}}}` compareJSON(t, expectedFalseMulti, CleanJSONSchemaForAntigravity(falseMultiBranch)) // Boolean true subschema branch in anyOf/oneOf is normalized to an unconstrained {} branch and preserved. trueMultiBranch := `{"type":"object","properties":{"value":{"anyOf":[true,{"type":"string"},{"type":"integer"}]}}}` expectedTrueMulti := `{"type":"object","properties":{"value":{"anyOf":[{},{"type":"string"},{"type":"integer"}]}}}` compareJSON(t, expectedTrueMulti, CleanJSONSchemaForAntigravityTool(trueMultiBranch, false)) // When a node declares both anyOf and oneOf, native Schema carriers keep anyOf without concatenating // oneOf into it and record oneOf as a description hint, while GeminiJSONSchema preserves both. siblingUnions := `{ "type": "object", "properties": { "value": { "anyOf": [ { "type": "string" }, { "type": "integer" } ], "oneOf": [ { "type": "string" }, { "type": "boolean" } ] } } }` for name, clean := range map[string]func(string) string{ "Antigravity": CleanJSONSchemaForAntigravity, "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "AntigravityResponse": CleanJSONSchemaForAntigravityResponse, "Gemini": CleanJSONSchemaForGemini, } { parsed := ojson.Parse(clean(siblingUnions)) if parsed.Get("properties.value.oneOf").Exists() { t.Fatalf("[%s] sibling oneOf survived on native Schema carrier: %s", name, parsed.Raw) } if got := len(parsed.Get("properties.value.anyOf").Array()); got != 2 { t.Fatalf("[%s] anyOf branch count = %d, want 2 (must not concatenate oneOf): %s", name, got, parsed.Raw) } if !strings.Contains(parsed.Get("properties.value.description").String(), "oneOf:") { t.Fatalf("[%s] missing oneOf description hint: %s", name, parsed.Raw) } } jsonSchemaSibling := ojson.Parse(CleanJSONSchemaForGeminiJSONSchema(siblingUnions)) if len(jsonSchemaSibling.Get("properties.value.anyOf").Array()) != 2 || len(jsonSchemaSibling.Get("properties.value.oneOf").Array()) != 2 { t.Fatalf("[GeminiJSONSchema] expected both anyOf and oneOf to be preserved: %s", jsonSchemaSibling.Raw) } // Issue 3662 regression: only the Antigravity Claude tool cleaner flattens the // multi-branch unions that every other cleaner above preserves, because only the // Antigravity Claude backend is known to reject nested multi-branch unions when // it converts function declarations to Claude custom.input_schema. t.Run("AntigravityClaudeToolFlattensNestedUnions", func(t *testing.T) { flattened := ojson.Parse(CleanJSONSchemaForAntigravityClaudeTool(input, false)) item := flattened.Get("properties.item") if item.Get("anyOf").Exists() || item.Get("oneOf").Exists() { t.Fatalf("Antigravity Claude tool union survived cleaning: %s", flattened.Raw) } if got := item.Get("type").String(); got != "object" { t.Fatalf("Antigravity Claude flattened item type = %q, want object (strongest branch): %s", got, flattened.Raw) } if !item.Get("properties.image").Exists() { t.Fatalf("Antigravity Claude flattened item lost the first object branch: %s", flattened.Raw) } if desc := item.Get("description").String(); !strings.Contains(desc, "Accepts:") || !strings.Contains(desc, "string") { t.Fatalf("Antigravity Claude flattened item lost the dropped-branch hint: %s", flattened.Raw) } // Idempotency matters on the Antigravity path: translators clean the schema // once, then the executor cleans it again before sending it upstream. flattenedTwice := CleanJSONSchemaForAntigravityClaudeTool(flattened.Raw, false) compareJSON(t, flattened.Raw, flattenedTwice) // Double-cleaning across the canonical translator -> executor chain must // keep the flattened Claude shape unchanged on the second pass. recleaned := CleanJSONSchemaForAntigravityClaudeTool(CleanJSONSchemaForAntigravity(input), false) compareJSON(t, flattened.Raw, recleaned) compareJSON(t, flattened.Raw, CleanJSONSchemaForAntigravity(flattened.Raw)) // Nullable multi-branch unions must keep nullability through the whole // translator -> executor chain: the union-preserving first pass strips the // null branch and marks the parent nullable:true, so the flattening second // pass must inherit that marker instead of silently dropping it. nullableUnionInput := `{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}]}}}` directFlattened := CleanJSONSchemaForAntigravityClaudeTool(nullableUnionInput, false) directValue := ojson.Parse(directFlattened).Get("properties.value") if !directValue.Get("nullable").Bool() || directValue.Get("type").String() != "string" { t.Fatalf("direct Claude flattening lost nullability: %s", directFlattened) } chainedFlattened := CleanJSONSchemaForAntigravityClaudeTool(CleanJSONSchemaForAntigravity(nullableUnionInput), false) chainedValue := ojson.Parse(chainedFlattened).Get("properties.value") if !chainedValue.Get("nullable").Bool() || chainedValue.Get("type").String() != "string" || chainedValue.Get("anyOf").Exists() { t.Fatalf("translator -> executor chain lost nullability while flattening: %s", chainedFlattened) } compareJSON(t, chainedFlattened, CleanJSONSchemaForAntigravityClaudeTool(chainedFlattened, false)) // Boolean subschema branches must not leak raw booleans or unions into the // flattened output: true normalizes away, false is unstisfiable and dropped. trueBranch := `{"type":"object","properties":{"value":{"anyOf":[true,{"type":"string"},{"type":"integer"}]}}}` compareJSON(t, `{"type":"object","properties":{"value":{"type":"string","description":"Accepts: string | integer"}}}`, CleanJSONSchemaForAntigravityClaudeTool(trueBranch, false)) falseBranch := `{"type":"object","properties":{"value":{"oneOf":[false,{"type":"string"},{"type":"integer"}]}}}` compareJSON(t, `{"type":"object","properties":{"value":{"type":"string","description":"Accepts: string | integer"}}}`, CleanJSONSchemaForAntigravityClaudeTool(falseBranch, false)) // Sibling anyOf + oneOf on one node: both are flattened and neither raw union // JSON leaks into the description (paths are processed oneOf-first, so the // oneOf branch set wins the single remaining node). siblingClaude := ojson.Parse(CleanJSONSchemaForAntigravityClaudeTool(siblingUnions, false)) siblingValue := siblingClaude.Get("properties.value") if siblingValue.Get("anyOf").Exists() || siblingValue.Get("oneOf").Exists() { t.Fatalf("Antigravity Claude sibling union survived cleaning: %s", siblingClaude.Raw) } compareJSON(t, `{"type":"string","description":"Accepts: string | boolean"}`, siblingValue.Raw) compareJSON(t, siblingClaude.Raw, CleanJSONSchemaForAntigravityClaudeTool(siblingClaude.Raw, false)) }) // Null-only unions (anyOf:[null], oneOf:[null], anyOf:[false, null]) must not panic across any cleaner. nullOnlyCleaners := map[string]func(string) string{ "Antigravity": CleanJSONSchemaForAntigravity, "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "AntigravityClaudeTool": func(s string) string { return CleanJSONSchemaForAntigravityClaudeTool(s, false) }, "AntigravityResponse": CleanJSONSchemaForAntigravityResponse, "Gemini": CleanJSONSchemaForGemini, "GeminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema, } for _, nullOnlyInput := range []string{ `{"type":"object","properties":{"value":{"anyOf":[{"type":"null"}]}}}`, `{"type":"object","properties":{"value":{"oneOf":[{"type":"null"},{"type":"null"}]}}}`, `{"type":"object","properties":{"value":{"anyOf":[false,{"type":"null"}],"description":"Null only"}}}`, } { for name, clean := range nullOnlyCleaners { got := clean(nullOnlyInput) if !ojson.Valid(got) { t.Fatalf("[%s] invalid JSON for null-only union %s: %s", name, nullOnlyInput, got) } if ojson.Get(got, "properties.value.type").String() != "null" { t.Fatalf("[%s] expected properties.value.type = null, got: %s", name, got) } } } }) } // TestCleanJSONSchema_RemovesDiscriminatorKeyword_Issue6639 covers Issue #6639: // OpenAPI/JSON Schema keyword "discriminator" alongside preserved anyOf/oneOf unions // (both directly on a property schema and inside array items) or on object schemas // is rejected by the Gemini/Antigravity backend with 400 INVALID_ARGUMENT // ("Unknown name \"discriminator\" ... Cannot find field") and must be stripped while // preserving legitimate tool properties named "discriminator". func TestCleanJSONSchema_RemovesDiscriminatorKeyword_Issue6639(t *testing.T) { input := `{ "type": "object", "properties": { "target": { "anyOf": [ {"type": "object", "properties": {"kind": {"const": "a"}, "url": {"type": "string"}}}, {"type": "object", "properties": {"kind": {"const": "b"}, "path": {"type": "string"}}} ], "discriminator": {"propertyName": "kind"} }, "deliverables": { "type": "array", "items": { "anyOf": [ {"type": "object", "properties": {"kind": {"const": "file"}, "path": {"type": "string"}}}, {"type": "object", "properties": {"kind": {"const": "link"}, "url": {"type": "string"}}} ], "discriminator": {"propertyName": "kind"} } }, "polymorphicObject": { "type": "object", "properties": { "kind": {"type": "string"} }, "oneOf": [ {"properties": {"a": {"type": "string"}}}, {"properties": {"b": {"type": "number"}}} ], "discriminator": { "propertyName": "kind", "mapping": {"a": "#/definitions/A", "b": "#/definitions/B"} } }, "discriminator": { "type": "string", "description": "Legitimate property named discriminator" }, "nestedDiscriminatorProp": { "type": "object", "properties": { "discriminator": { "type": "object", "properties": { "kind": {"type": "string"} }, "discriminator": {"propertyName": "kind"} } } } }, "discriminator": {"propertyName": "target"} }` cleaners := map[string]func(string) string{ "Antigravity": CleanJSONSchemaForAntigravity, "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "AntigravityClaudeTool": func(s string) string { return CleanJSONSchemaForAntigravityClaudeTool(s, false) }, "AntigravityResponse": CleanJSONSchemaForAntigravityResponse, "Gemini": CleanJSONSchemaForGemini, "GeminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema, } for name, clean := range cleaners { t.Run(name, func(t *testing.T) { got := clean(input) parsed := ojson.Parse(got) for _, forbiddenPath := range []string{ "discriminator", "properties.target.discriminator", "properties.deliverables.items.discriminator", "properties.polymorphicObject.discriminator", "properties.nestedDiscriminatorProp.properties.discriminator.discriminator", } { if parsed.Get(forbiddenPath).Exists() { t.Errorf("[%s] %s was not removed: %s", name, forbiddenPath, got) } } if name != "AntigravityClaudeTool" { if gotBranches := len(parsed.Get("properties.target.anyOf").Array()); gotBranches != 2 { t.Errorf("[%s] properties.target.anyOf branch count = %d, want 2: %s", name, gotBranches, got) } if gotBranches := len(parsed.Get("properties.deliverables.items.anyOf").Array()); gotBranches != 2 { t.Errorf("[%s] properties.deliverables.items.anyOf branch count = %d, want 2: %s", name, gotBranches, got) } } if !parsed.Get("properties.discriminator").Exists() { t.Errorf("[%s] legitimate property \"discriminator\" was removed: %s", name, got) } if !parsed.Get("properties.nestedDiscriminatorProp.properties.discriminator.properties.kind").Exists() { t.Errorf("[%s] legitimate nested property \"discriminator\" was removed: %s", name, got) } }) } } // TestCleanJSONSchema_RemovesContentMediaTypeKeyword_Issue6699 covers Issue #6699: // JSON Schema Content Vocabulary annotations ("contentMediaType" and "contentEncoding") // inside nested anyOf/oneOf branches, on property schemas, or at the schema root are // rejected by the Gemini/Antigravity backend with 400 INVALID_ARGUMENT // ("Unknown name \"contentMediaType\" ... Cannot find field") and must be stripped // while preserving the anyOf branches and any legitimate property named // "contentMediaType" or "contentEncoding". func TestCleanJSONSchema_RemovesContentMediaTypeKeyword_Issue6699(t *testing.T) { input := `{ "type": "object", "contentMediaType": "application/json", "contentEncoding": "utf-8", "properties": { "value": { "anyOf": [ { "type": "object", "properties": {} }, { "type": "string", "contentMediaType": "application/json", "contentEncoding": "base64" } ] }, "directPayload": { "type": "string", "contentMediaType": "application/json", "contentEncoding": "base64" }, "contentMediaType": { "type": "string", "contentMediaType": "text/plain", "description": "Legitimate property named contentMediaType" }, "contentEncoding": { "type": "string", "contentEncoding": "base64", "description": "Legitimate property named contentEncoding" } } }` cleaners := map[string]func(string) string{ "Antigravity": CleanJSONSchemaForAntigravity, "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "AntigravityClaudeTool": func(s string) string { return CleanJSONSchemaForAntigravityClaudeTool(s, false) }, "AntigravityResponse": CleanJSONSchemaForAntigravityResponse, "Gemini": CleanJSONSchemaForGemini, "GeminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema, } for name, clean := range cleaners { t.Run(name, func(t *testing.T) { got := clean(input) parsed := ojson.Parse(got) for _, forbiddenPath := range []string{ "contentMediaType", "contentEncoding", "properties.value.anyOf.1.contentMediaType", "properties.value.anyOf.1.contentEncoding", "properties.directPayload.contentMediaType", "properties.directPayload.contentEncoding", "properties.contentMediaType.contentMediaType", "properties.contentEncoding.contentEncoding", } { if parsed.Get(forbiddenPath).Exists() { t.Errorf("[%s] %s was not removed: %s", name, forbiddenPath, got) } } if name != "AntigravityClaudeTool" { if gotBranches := len(parsed.Get("properties.value.anyOf").Array()); gotBranches != 2 { t.Errorf("[%s] properties.value.anyOf branch count = %d, want 2: %s", name, gotBranches, got) } if gotType := parsed.Get("properties.value.anyOf.1.type").String(); gotType != "string" { t.Errorf("[%s] properties.value.anyOf.1.type = %q, want \"string\": %s", name, gotType, got) } } if !parsed.Get("properties.contentMediaType").Exists() { t.Errorf("[%s] legitimate property \"contentMediaType\" was removed: %s", name, got) } if !parsed.Get("properties.contentEncoding").Exists() { t.Errorf("[%s] legitimate property \"contentEncoding\" was removed: %s", name, got) } }) } } // TestIssue6732_GeminiCleanersInlineLocalDefs verifies that the Gemini cleaners expand local // $defs references before the definition container is stripped, so nested fields survive. func TestIssue6732_GeminiCleanersInlineLocalDefs(t *testing.T) { input := `{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"required":["person"],"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}` for name, clean := range map[string]func(string) string{ "gemini": CleanJSONSchemaForGemini, "geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema, } { got := clean(input) parsed := ojson.Parse(got) if gotType := parsed.Get("properties.person.properties.name.type").String(); gotType != "string" { t.Errorf("%s: inlined properties.person.properties.name.type = %q, want %q: %s", name, gotType, "string", got) } if parsed.Get("$defs").Exists() { t.Errorf("%s: $defs should be removed after inlining: %s", name, got) } if strings.Contains(got, "See: Person") { t.Errorf("%s: resolvable local ref degraded to a hint: %s", name, got) } } } // TestIssue6732_GeminiCleanersInlinePropertyReuse verifies that a Zod-style #/properties/... // reference is expanded by the Gemini cleaners instead of becoming a description hint. func TestIssue6732_GeminiCleanersInlinePropertyReuse(t *testing.T) { input := `{"type":"object","properties":{"billing":{"type":"object","properties":{"zip":{"type":"string"}}},"shipping":{"$ref":"#/properties/billing"}}}` for name, clean := range map[string]func(string) string{ "gemini": CleanJSONSchemaForGemini, "geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema, } { got := clean(input) parsed := ojson.Parse(got) if gotType := parsed.Get("properties.shipping.properties.zip.type").String(); gotType != "string" { t.Errorf("%s: properties.shipping.properties.zip.type = %q, want %q: %s", name, gotType, "string", got) } } } // TestIssue6732_GeminiCleanersReuseSameDefinitionTwice verifies that two properties referencing // the same definition both keep the referenced fields; bounding expansion must not degrade // ordinary reuse of a shared definition to a hint. func TestIssue6732_GeminiCleanersReuseSameDefinitionTwice(t *testing.T) { input := `{"type":"object","properties":{"billing":{"$ref":"#/$defs/Address"},"shipping":{"$ref":"#/$defs/Address"}},"$defs":{"Address":{"type":"object","properties":{"zip":{"type":"string"}}}}}` for name, clean := range map[string]func(string) string{ "gemini": CleanJSONSchemaForGemini, "geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema, } { got := clean(input) parsed := ojson.Parse(got) for _, field := range []string{"billing", "shipping"} { if gotType := parsed.Get("properties." + field + ".properties.zip.type").String(); gotType != "string" { t.Errorf("%s: properties.%s.properties.zip.type = %q, want %q: %s", name, field, gotType, "string", got) } } } } // TestIssue6732_WrappedSchemaResolvesAgainstInnerRoot verifies that a schema wrapped in the // single-key {"schema": ...} carrier used by the Antigravity executor resolves local refs // against the inner schema rather than the carrier. func TestIssue6732_WrappedSchemaResolvesAgainstInnerRoot(t *testing.T) { inner := `{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"required":["person"],"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}` wrapped := `{"schema":` + inner + `}` got := CleanJSONSchemaForAntigravityTool(wrapped, false) parsed := ojson.Parse(got) if gotType := parsed.Get("schema.properties.person.properties.name.type").String(); gotType != "string" { t.Errorf("wrapped schema.properties.person.properties.name.type = %q, want %q: %s", gotType, "string", got) } if parsed.Get("schema.$defs").Exists() { t.Errorf("wrapped $defs should be removed after inlining: %s", got) } if strings.Contains(got, "See: Person") { t.Errorf("wrapped resolvable local ref degraded to a hint: %s", got) } } // TestIssue6732_DefaultLiteralPreservedOnJSONSchemaRoute verifies that the parametersJsonSchema // route forwards default values verbatim even when they contain schema-looking keys. func TestIssue6732_DefaultLiteralPreservedOnJSONSchemaRoute(t *testing.T) { input := `{"type":"object","properties":{"report":{"type":"object","default":{"title":"Report","format":"pdf","id":7,"x-owner":"ops"}}}}` got := CleanJSONSchemaForGeminiJSONSchema(input) def := ojson.Parse(got).Get("properties.report.default") if !def.Exists() { t.Fatalf("default literal missing: %s", got) } if def.Get("title").String() != "Report" || def.Get("format").String() != "pdf" || def.Get("id").Int() != 7 || def.Get("x-owner").String() != "ops" { t.Errorf("default literal was rewritten, got %s: %s", def.Raw, got) } } // TestIssue6732_ExampleLiteralsPreserved verifies that example/examples values keep // schema-looking keys on the parametersJsonSchema route. func TestIssue6732_ExampleLiteralsPreserved(t *testing.T) { input := `{"type":"object","properties":{"a":{"type":"string","example":{"title":"Hi","x-owner":"ops","id":3},"examples":[{"title":"Hi2","x-owner":"ops","id":4}]}}}` got := CleanJSONSchemaForGeminiJSONSchema(input) parsed := ojson.Parse(got) example := parsed.Get("properties.a.example") if example.Get("title").String() != "Hi" || example.Get("x-owner").String() != "ops" || example.Get("id").Int() != 3 { t.Errorf("example literal was rewritten, got %s: %s", example.Raw, got) } exampleItem := parsed.Get("properties.a.examples.0") if exampleItem.Get("title").String() != "Hi2" || exampleItem.Get("x-owner").String() != "ops" || exampleItem.Get("id").Int() != 4 { t.Errorf("examples literal was rewritten, got %s: %s", exampleItem.Raw, got) } } // TestIssue6732_InlineLocalRefsLeavesLiteralRefs verifies that the inliner does not resolve // {"$ref": ...} objects that appear as client data inside a default value. func TestIssue6732_InlineLocalRefsLeavesLiteralRefs(t *testing.T) { input := `{"type":"object","properties":{"a":{"type":"object","default":{"$ref":"#/$defs/Person"}}},"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}}}}}` got := InlineLocalRefs(input) parsed := ojson.Parse(got) if ref := parsed.Get("properties.a.default.$ref").String(); ref != "#/$defs/Person" { t.Errorf("literal $ref inside default = %q, want it preserved verbatim: %s", ref, got) } if parsed.Get("properties.a.default.properties").Exists() { t.Errorf("literal $ref inside default was expanded: %s", got) } if !parsed.Get("$defs.Person.properties.name").Exists() { t.Errorf("InlineLocalRefs must not strip definitions: %s", got) } } // TestIssue6732_PropertiesNamedDefaultAndTypeRemainSchemas verifies that properties literally // named "default" and "type" keep their schema semantics: their own keywords are still cleaned // and their own $ref is still inlined. func TestIssue6732_PropertiesNamedDefaultAndTypeRemainSchemas(t *testing.T) { input := `{"type":"object","properties":{"default":{"$ref":"#/$defs/Person"},"type":{"type":"string","title":"Kind"}},"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}}}}}` got := CleanJSONSchemaForGeminiJSONSchema(input) parsed := ojson.Parse(got) if gotType := parsed.Get("properties.default.properties.name.type").String(); gotType != "string" { t.Errorf("property named \"default\" ref was not inlined, name type = %q: %s", gotType, got) } if parsed.Get("properties.type.title").Exists() { t.Errorf("property named \"type\" schema title should still be cleaned: %s", got) } } // TestIssue6732_ExpansionBudgetBoundsSharedDefs verifies that inlining a schema whose shared // definitions fan out exponentially stays bounded instead of expanding without limit, while the // affordable outer levels are still expanded. func TestIssue6732_ExpansionBudgetBoundsSharedDefs(t *testing.T) { input := geminiSchemaDiamondInput() got := CleanJSONSchemaForGeminiJSONSchema(input) if len(got) >= 512*1024 { t.Errorf("diamond schema output = %d bytes, want < %d (input %d bytes)", len(got), 512*1024, len(input)) } parsed := ojson.Parse(got) if !parsed.Get("properties.leaf").Exists() { t.Errorf("diamond schema lost the top-level property: %s", got) } if !parsed.Get("properties.leaf.properties.a.properties.a").Exists() { t.Errorf("diamond schema outer levels should still be expanded: %s", got) } if parsed.Get("$defs").Exists() { t.Errorf("diamond schema should not retain $defs: %s", got) } } // TestIssue6732_UnusedDefinitionsDoNotConsumeExpansionBudget verifies that definitions the // cleaners are about to delete anyway neither consume the expansion budget nor make the kept // fields depend on map iteration order. func TestIssue6732_UnusedDefinitionsDoNotConsumeExpansionBudget(t *testing.T) { var sb strings.Builder sb.WriteString(`{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"required":["person"],"$defs":{"leaf0":{"type":"string","description":"leaf leaf leaf leaf leaf leaf leaf leaf leaf leaf"},"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}`) for i := range 13 { target := "leaf0" if i > 0 { target = "D" + strconv.Itoa(i-1) } sb.WriteString(`,"D` + strconv.Itoa(i) + `":{"type":"object","properties":{"a":{"$ref":"#/$defs/` + target + `"},"b":{"$ref":"#/$defs/` + target + `"}}}`) } sb.WriteString(`}}`) input := sb.String() first := CleanJSONSchemaForGeminiJSONSchema(input) for run := range 30 { got := CleanJSONSchemaForGeminiJSONSchema(input) if got != first { t.Fatalf("run %d: output is not deterministic:\nfirst: %s\ngot: %s", run, first, got) } parsed := ojson.Parse(got) if gotType := parsed.Get("properties.person.properties.name.type").String(); gotType != "string" { t.Fatalf("run %d: used definition was not inlined, name type = %q: %s", run, gotType, got) } if parsed.Get("$defs").Exists() { t.Fatalf("run %d: $defs should be removed: %s", run, got) } } } // TestIssue6732_BudgetFallbackDoesNotCopyLargeDescriptions verifies that references degraded by // the expansion budget do not each copy a large target description, which would defeat the bound. func TestIssue6732_BudgetFallbackDoesNotCopyLargeDescriptions(t *testing.T) { var sb strings.Builder sb.WriteString(`{"type":"object","properties":{`) for i := range 200 { if i > 0 { sb.WriteByte(',') } sb.WriteString(`"p` + strconv.Itoa(i) + `":{"$ref":"#/$defs/Big"}`) } sb.WriteString(`},"$defs":{"Big":{"type":"object","description":"` + strings.Repeat("x", 32*1024) + `","properties":{"name":{"type":"string"}}}}}`) got := CleanJSONSchemaForGeminiJSONSchema(sb.String()) if len(got) >= 512*1024 { t.Errorf("shared large-description output = %d bytes, want < %d", len(got), 512*1024) } parsed := ojson.Parse(got) if !parsed.Get("properties.p0").Exists() || !parsed.Get("properties.p199").Exists() { t.Errorf("shared large-description schema lost properties: %s", got) } if gotType := parsed.Get("properties.p0.properties.name.type").String(); gotType != "string" { t.Errorf("first affordable expansion should keep its fields, name type = %q: %s", gotType, got) } if parsed.Get("$defs").Exists() { t.Errorf("$defs should be removed: %s", got) } } // TestIssue6732_NestedUnusedDefinitionsGraphDoesNotConsumeBudget verifies that a self-contained // reference graph inside a nested definitions container (which cleaning deletes at every level) // neither spends the expansion budget nor starves kept fields. func TestIssue6732_NestedUnusedDefinitionsGraphDoesNotConsumeBudget(t *testing.T) { var sb strings.Builder sb.WriteString(`{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"},"a":{"type":"object","$defs":{"Leaf":{"type":"string","description":"` + strings.Repeat("x", 1024) + `"}`) for i := range 256 { sb.WriteString(`,"U` + strconv.Itoa(i) + `":{"$ref":"#/properties/a/$defs/Leaf"}`) } sb.WriteString(`}}},"required":["person"],"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}`) got := CleanJSONSchemaForGeminiJSONSchema(sb.String()) parsed := ojson.Parse(got) if gotType := parsed.Get("properties.person.properties.name.type").String(); gotType != "string" { t.Errorf("nested unused definitions starved the kept field, name type = %q: %s", gotType, got) } if parsed.Get("properties.a.$defs").Exists() || parsed.Get("$defs").Exists() { t.Errorf("nested and root $defs should be removed: %s", got) } } // TestIssue6732_RefSiblingNameMapReferencesStillExpand verifies that references inside a $ref // node's own name-map siblings expand when the reference target itself expands. func TestIssue6732_RefSiblingNameMapReferencesStillExpand(t *testing.T) { input := `{"type":"object","properties":{"a":{"$ref":"#/$defs/Base","properties":{"x":{"$ref":"#/$defs/S"}}}},"$defs":{"Base":{"type":"object"},"S":{"type":"string"}}}` got := CleanJSONSchemaForGeminiJSONSchema(input) parsed := ojson.Parse(got) if gotType := parsed.Get("properties.a.properties.x.type").String(); gotType != "string" { t.Errorf("ref sibling name-map reference lost its type, got %q: %s", gotType, got) } } // TestIssue6732_CleanInliningStripsDefinitionsFromIntermediate verifies that the cleaning // inliner's intermediate output does not serialize definition containers (root or carried inside // expanded targets), so repeated references to a target holding a huge unused definitions block // cannot multiply that block in the intermediate JSON. func TestIssue6732_CleanInliningStripsDefinitionsFromIntermediate(t *testing.T) { person := `{"type":"object","properties":{"name":{"type":"string"}},"$defs":{"Unused":{"type":"string","description":"` + strings.Repeat("x", 280*1024) + `"}}}` input := `{"type":"object","properties":{"p0":{"$ref":"#/$defs/Person"},"p1":{"$ref":"#/$defs/Person"}},"$defs":{"Person":` + person + `}}` got := inlineLocalRefsForClean(input) if len(got) >= 64*1024 { t.Errorf("clean inlining intermediate = %d bytes, want < %d", len(got), 64*1024) } parsed := ojson.Parse(got) if parsed.Get("$defs").Exists() { t.Errorf("intermediate should not carry root $defs: %s", got) } if parsed.Get("properties.p0.$defs").Exists() { t.Errorf("intermediate should not carry target-nested $defs: %s", got) } if gotType := parsed.Get("properties.p1.properties.name.type").String(); gotType != "string" { t.Errorf("second expansion lost its fields, name type = %q: %s", gotType, got) } } // TestIssue6732_ArrayRefWithExplicitTypeKeepsTargetItems verifies that the placeholder items // Phase 0 adds to a typed but unresolved $ref node does not clobber the items of the referenced // definition during inlining. func TestIssue6732_ArrayRefWithExplicitTypeKeepsTargetItems(t *testing.T) { input := `{"type":"object","properties":{"rows":{"type":"array","$ref":"#/$defs/Rows"}},"$defs":{"Rows":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"}}}}}}` got := CleanJSONSchemaForGeminiJSONSchema(input) parsed := ojson.Parse(got) if gotType := parsed.Get("properties.rows.items.properties.name.type").String(); gotType != "string" { t.Errorf("array ref target items were clobbered, name type = %q: %s", gotType, got) } } // TestIssue6732_BudgetFallbackStillResolvesSiblings verifies that when a reference target is too // large to expand, sibling subschemas on the same node are still resolved instead of being copied // verbatim with their own references left dangling. func TestIssue6732_BudgetFallbackStillResolvesSiblings(t *testing.T) { input := `{"type":"object","properties":{"a":{"$ref":"#/$defs/Big","properties":{"x":{"$ref":"#/$defs/S"}}}},"$defs":{"Big":{"type":"object","description":"` + strings.Repeat("x", 280*1024) + `"},"S":{"type":"string"}}}` got := CleanJSONSchemaForGeminiJSONSchema(input) parsed := ojson.Parse(got) if gotType := parsed.Get("properties.a.properties.x.type").String(); gotType != "string" { t.Errorf("sibling reference after budget fallback lost its type, got %q: %s", gotType, got) } if strings.Contains(got, `"$ref"`) { t.Errorf("cleaned schema left a dangling reference: %s", got) } } // TestIssue6732_InlineLocalRefsDefinitionNamesKeepSchemaSemantics verifies that definitions whose // names look like schema keywords are treated as schemas by the public inliner: a definition named // "properties" keeps its literal default intact, and a definition named "default" still expands. func TestIssue6732_InlineLocalRefsDefinitionNamesKeepSchemaSemantics(t *testing.T) { input := `{"$defs":{"S":{"type":"string"},"properties":{"type":"object","default":{"$ref":"#/$defs/S"}},"default":{"type":"object","properties":{"x":{"$ref":"#/$defs/S"}}}}}` got := InlineLocalRefs(input) parsed := ojson.Parse(got) if ref := parsed.Get("$defs.properties.default.$ref").String(); ref != "#/$defs/S" { t.Errorf("literal default inside definition named \"properties\" was expanded, $ref = %q: %s", ref, got) } if gotType := parsed.Get("$defs.default.properties.x.type").String(); gotType != "string" { t.Errorf("definition named \"default\" was not resolved as a schema, x type = %q: %s", gotType, got) } } // TestIssue6732_TargetNestedDefinitionsExemptFromBudget verifies that unused definitions nested // inside a referenced target (which cleaning deletes) do not inflate the target's budget charge. func TestIssue6732_TargetNestedDefinitionsExemptFromBudget(t *testing.T) { person := `{"type":"object","properties":{"name":{"type":"string"}},"$defs":{"Unused":{"type":"string","description":"` + strings.Repeat("x", 280*1024) + `"}}}` cases := map[string]string{ "propertyRef": `{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"$defs":{"Person":` + person + `}}`, "rootRef": `{"$ref":"#/$defs/Person","$defs":{"Person":` + person + `}}`, } for caseName, input := range cases { got := CleanJSONSchemaForGeminiJSONSchema(input) parsed := ojson.Parse(got) namePath := "properties.person.properties.name.type" if caseName == "rootRef" { namePath = "properties.name.type" } if gotType := parsed.Get(namePath).String(); gotType != "string" { t.Errorf("%s: %s = %q, want %q: %s", caseName, namePath, gotType, "string", got) } if parsed.Get("$defs").Exists() { t.Errorf("%s: $defs should be removed: %s", caseName, got) } } } // TestIssue6732_ArrayRefWithoutTargetItemsGetsPlaceholder verifies that a typed array whose // referenced definition carries no items still ends up with the placeholder items Phase 0 would // have added, and that cleaning twice is stable. func TestIssue6732_ArrayRefWithoutTargetItemsGetsPlaceholder(t *testing.T) { input := `{"type":"object","properties":{"rows":{"type":"array","$ref":"#/$defs/Common"}},"$defs":{"Common":{"description":"Rows"}}}` first := CleanJSONSchemaForAntigravityTool(input, false) parsed := ojson.Parse(first) if gotType := parsed.Get("properties.rows.items.type").String(); gotType != "string" { t.Errorf("array ref without target items missing placeholder items, got %q: %s", gotType, first) } if second := CleanJSONSchemaForAntigravityTool(first, false); second != first { t.Errorf("cleaning is not idempotent for array refs:\nfirst: %s\nsecond: %s", first, second) } } // TestIssue6732_SmallTargetFanOutStaysBounded verifies that thousands of references to a small // target with a near-limit description cannot multiply the description without bound through a // mix of expansions and fallback copies. func TestIssue6732_SmallTargetFanOutStaysBounded(t *testing.T) { var sb strings.Builder sb.WriteString(`{"type":"object","properties":{`) for i := range 2000 { if i > 0 { sb.WriteByte(',') } sb.WriteString(`"p` + strconv.Itoa(i) + `":{"$ref":"#/$defs/S"}`) } sb.WriteString(`},"$defs":{"S":{"type":"string","description":"` + strings.Repeat("x", 512) + `"}}}`) got := CleanJSONSchemaForGeminiJSONSchema(sb.String()) if len(got) >= 512*1024 { t.Errorf("small-target fan-out output = %d bytes, want < %d", len(got), 512*1024) } } // TestIssue6732_EscapedDescriptionFanOutStaysBounded verifies that budget accounting covers // HTML-escaped string growth during JSON encoding. func TestIssue6732_EscapedDescriptionFanOutStaysBounded(t *testing.T) { input := `{"type":"object","properties":{"p0":{"$ref":"#/$defs/Big"},"p1":{"$ref":"#/$defs/Big"},"p2":{"$ref":"#/$defs/Big"},"p3":{"$ref":"#/$defs/Big"},"p4":{"$ref":"#/$defs/Big"},"p5":{"$ref":"#/$defs/Big"},"p6":{"$ref":"#/$defs/Big"},"p7":{"$ref":"#/$defs/Big"}},"$defs":{"Big":{"type":"object","description":"` + strings.Repeat("<", 32*1024) + `","properties":{"name":{"type":"string"}}}}}` got := CleanJSONSchemaForGeminiJSONSchema(input) if len(got) >= 512*1024 { t.Errorf("escaped-description fan-out output = %d bytes, want < %d", len(got), 512*1024) } } // TestIssue6732_DependenciesNameMapKeepsSchemaSemantics verifies that the Draft-07 dependencies // container behaves as a name map: a dependency keyed "default" holds a schema whose references // still expand in the public inliner. func TestIssue6732_DependenciesNameMapKeepsSchemaSemantics(t *testing.T) { input := `{"type":"object","dependencies":{"default":{"properties":{"x":{"$ref":"#/definitions/S"}}}},"definitions":{"S":{"type":"string"}}}` got := InlineLocalRefs(input) parsed := ojson.Parse(got) if gotType := parsed.Get("dependencies.default.properties.x.type").String(); gotType != "string" { t.Errorf("dependency schema named \"default\" was not resolved, x type = %q: %s", gotType, got) } if parsed.Get("dependencies.default.properties.x.$ref").Exists() { t.Errorf("dependency schema reference was left dangling: %s", got) } } // TestIssue6732_OmittedFallbackDescriptionNotCharged verifies that a target description the // fallback omits (because it exceeds the copy limit) is not charged to the expansion budget, so // a following small reference still expands. func TestIssue6732_OmittedFallbackDescriptionNotCharged(t *testing.T) { input := `{"type":"object","properties":{"a":{"$ref":"#/$defs/A"},"b":{"$ref":"#/$defs/B"}},"$defs":{"A":{"type":"string","description":"` + strings.Repeat("x", 262142) + `"},"B":{"type":"object","properties":{"name":{"type":"string"}}}}}` got := CleanJSONSchemaForGeminiJSONSchema(input) parsed := ojson.Parse(got) if gotType := parsed.Get("properties.b.properties.name.type").String(); gotType != "string" { t.Errorf("small reference after omitted fallback description lost its fields, name type = %q: %s", gotType, got) } } // TestIssue6732_SynthesizedTypeDoesNotOverrideRefTarget verifies that the type Phase 0 would // infer from a ref node's sibling items does not clobber the referenced definition's object // type, and that cleaning the result again is stable. func TestIssue6732_SynthesizedTypeDoesNotOverrideRefTarget(t *testing.T) { input := `{"$ref":"#/$defs/O","items":{"type":"integer"},"$defs":{"O":{"type":"object","properties":{"x":{"type":"string"}}}}}` first := CleanJSONSchemaForGeminiJSONSchema(input) parsed := ojson.Parse(first) if gotType := parsed.Get("type").String(); gotType != "object" { t.Errorf("root ref target type = %q, want %q: %s", gotType, "object", first) } if parsed.Get("items").Exists() { t.Errorf("object schema should not carry items: %s", first) } if gotType := parsed.Get("properties.x.type").String(); gotType != "string" { t.Errorf("target property lost its type, got %q: %s", gotType, first) } if second := CleanJSONSchemaForGeminiJSONSchema(first); second != first { t.Errorf("cleaning is not idempotent:\nfirst: %s\nsecond: %s", first, second) } } // TestIssue6732_PropertyNamedRefRemainsProperty verifies that a property literally named "$ref" // is treated as a property, not as a reference keyword, by the cleaner. func TestIssue6732_PropertyNamedRefRemainsProperty(t *testing.T) { input := `{"type":"object","properties":{"$ref":{"type":"string"}}}` got := CleanJSONSchemaForGeminiJSONSchema(input) parsed := ojson.Parse(got) if gotType := parsed.Get(`properties.\$ref.type`).String(); gotType != "string" { t.Errorf("property named $ref lost its schema, type = %q: %s", gotType, got) } if strings.Contains(got, "See:") { t.Errorf("property named $ref was converted into a hint: %s", got) } } // TestIssue6732_PropertyNamedConstRemainsProperty verifies that a property literally named // "const" holding a schema object is not converted into an enum constraint on its parent. func TestIssue6732_PropertyNamedConstRemainsProperty(t *testing.T) { input := `{"type":"object","properties":{"const":{"type":"string"}}}` got := CleanJSONSchemaForGeminiJSONSchema(input) parsed := ojson.Parse(got) if gotType := parsed.Get(`properties.const.type`).String(); gotType != "string" { t.Errorf("property named const lost its schema, type = %q: %s", gotType, got) } if parsed.Get("enum").Exists() { t.Errorf("property named const fabricates a root enum: %s", got) } } // geminiSchemaDiamondInput builds a schema referencing D12, where each Di is an object with two // properties referencing Di-1 and D0 references a leaf definition twice. Fully expanding it // produces 2^13 leaf copies. func geminiSchemaDiamondInput() string { var sb strings.Builder sb.WriteString(`{"type":"object","properties":{"leaf":{"$ref":"#/$defs/D12"}},"$defs":{"leaf0":{"type":"string","description":"leaf leaf leaf leaf leaf leaf leaf leaf leaf leaf"}`) for i := range 13 { target := "leaf0" if i > 0 { target = "D" + strconv.Itoa(i-1) } sb.WriteString(`,"D` + strconv.Itoa(i) + `":{"type":"object","properties":{"a":{"$ref":"#/$defs/` + target + `"},"b":{"$ref":"#/$defs/` + target + `"}}}`) } sb.WriteString(`}}`) return sb.String() } // TestCleanJSONSchema_RemovesReadOnlyWriteOnlyKeywords_Issue6778 covers Issue #6778: // JSON Schema annotation keywords "readOnly" and "writeOnly" (common in Google Discovery-derived // MCP tools such as Firestore) are rejected by the Gemini/Antigravity backend with 400 // INVALID_ARGUMENT ("Unknown name \"readOnly\" ... Cannot find field") and must be stripped // while preserving legitimate tool properties named "readOnly" or "writeOnly". func TestCleanJSONSchema_RemovesReadOnlyWriteOnlyKeywords_Issue6778(t *testing.T) { input := `{ "type": "object", "$schema": "http://json-schema.org/draft-04/schema#", "readOnly": false, "writeOnly": false, "properties": { "parent": { "type": "string", "description": "Parent resource name" }, "database": { "type": "object", "properties": { "name": { "type": "string", "description": "Database resource name", "readOnly": true }, "uid": { "type": "string", "readOnly": true }, "createTime": { "type": "string", "readOnly": true }, "etag": { "type": "string", "writeOnly": true }, "locationId": { "type": "string" } } }, "itemsList": { "type": "array", "items": { "type": "string", "readOnly": true, "writeOnly": false } }, "unionField": { "anyOf": [ { "type": "string", "readOnly": true }, { "type": "integer", "writeOnly": true } ] }, "readOnly": { "type": "boolean", "readOnly": true, "description": "Legitimate property named readOnly" }, "writeOnly": { "type": "boolean", "writeOnly": true, "description": "Legitimate property named writeOnly" } }, "required": ["parent"] }` cleaners := map[string]func(string) string{ "Antigravity": CleanJSONSchemaForAntigravity, "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, "AntigravityClaudeTool": func(s string) string { return CleanJSONSchemaForAntigravityClaudeTool(s, false) }, "AntigravityResponse": CleanJSONSchemaForAntigravityResponse, "Gemini": CleanJSONSchemaForGemini, "GeminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema, } for name, clean := range cleaners { t.Run(name, func(t *testing.T) { got := clean(input) parsed := ojson.Parse(got) for _, forbiddenPath := range []string{ "readOnly", "writeOnly", "properties.database.properties.name.readOnly", "properties.database.properties.uid.readOnly", "properties.database.properties.createTime.readOnly", "properties.database.properties.etag.writeOnly", "properties.itemsList.items.readOnly", "properties.itemsList.items.writeOnly", "properties.unionField.readOnly", "properties.unionField.writeOnly", "properties.unionField.anyOf.0.readOnly", "properties.unionField.anyOf.1.writeOnly", "properties.readOnly.readOnly", "properties.writeOnly.writeOnly", } { if parsed.Get(forbiddenPath).Exists() { t.Errorf("[%s] %s was not removed: %s", name, forbiddenPath, got) } } for _, keptField := range []string{ "name", "uid", "createTime", "etag", "locationId", } { if gotType := parsed.Get("properties.database.properties." + keptField + ".type").String(); gotType != "string" { t.Errorf("[%s] properties.database.properties.%s.type = %q, want \"string\": %s", name, keptField, gotType, got) } } if gotDesc := parsed.Get("properties.database.properties.name.description").String(); gotDesc != "Database resource name" { t.Errorf("[%s] properties.database.properties.name.description = %q, want %q: %s", name, gotDesc, "Database resource name", got) } if gotType := parsed.Get("properties.readOnly.type").String(); gotType != "boolean" { t.Errorf("[%s] legitimate property \"readOnly\" lost its type (%q): %s", name, gotType, got) } if gotDesc := parsed.Get("properties.readOnly.description").String(); gotDesc != "Legitimate property named readOnly" { t.Errorf("[%s] legitimate property \"readOnly\" lost its description (%q): %s", name, gotDesc, got) } if gotType := parsed.Get("properties.writeOnly.type").String(); gotType != "boolean" { t.Errorf("[%s] legitimate property \"writeOnly\" lost its type (%q): %s", name, gotType, got) } if gotDesc := parsed.Get("properties.writeOnly.description").String(); gotDesc != "Legitimate property named writeOnly" { t.Errorf("[%s] legitimate property \"writeOnly\" lost its description (%q): %s", name, gotDesc, got) } }) } } // TestIssue6950_ArrayItemsFromAllOfPreserved verifies that array schemas whose items come from // allOf branches (directly, through a $ref, or alongside a base array $ref/branch) retain their // real items schema instead of having it replaced by Phase 0's {"type":"string"} placeholder. func TestIssue6950_ArrayItemsFromAllOfPreserved(t *testing.T) { cleaners := map[string]struct { clean func(string) string preservesNullable bool }{ "Antigravity": { clean: CleanJSONSchemaForAntigravity, preservesNullable: true, }, "AntigravityTool": { clean: func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) }, preservesNullable: true, }, "AntigravityClaudeTool": { clean: func(s string) string { return CleanJSONSchemaForAntigravityClaudeTool(s, false) }, preservesNullable: true, }, "Gemini": { clean: CleanJSONSchemaForGemini, preservesNullable: false, }, } cases := []struct { name string input string path string wantItemType string wantDesc string wantNullable bool }{ { name: "direct_allof_items", input: `{"type":"object","properties":{"ids":{"type":"array","allOf":[{"items":{"type":"integer"}}]}}}`, path: "properties.ids", wantItemType: "integer", }, { name: "ref_in_allof_items", input: `{"type":"object","properties":{"ids":{"type":"array","allOf":[{"$ref":"#/$defs/Ids"}]}},"$defs":{"Ids":{"type":"array","items":{"type":"integer"}}}}`, path: "properties.ids", wantItemType: "integer", }, { name: "nullable_type_array_with_ref_in_allof", input: `{"type":"object","properties":{"ids":{"type":["array","null"],"allOf":[{"$ref":"#/$defs/Ids"}]}},"$defs":{"Ids":{"type":"array","items":{"type":"integer"}}}}`, path: "properties.ids", wantItemType: "integer", wantDesc: "(nullable)", wantNullable: true, }, { name: "split_allof_branches", input: `{"type":"object","properties":{"ids":{"allOf":[{"type":"array","description":"ID list"},{"items":{"type":"integer"}}]}}}`, path: "properties.ids", wantItemType: "integer", wantDesc: "ID list", }, { name: "ref_base_array_with_allof_items", input: `{"type":"object","properties":{"ids":{"$ref":"#/$defs/BaseArray","allOf":[{"items":{"type":"integer"}}]}},"$defs":{"BaseArray":{"type":"array","description":"ID list"}}}`, path: "properties.ids", wantItemType: "integer", wantDesc: "ID list", }, { name: "root_array_with_allof_items", input: `{"type":"array","allOf":[{"items":{"type":"integer"}}]}`, path: "", wantItemType: "integer", }, { name: "allof_without_items_gets_placeholder", input: `{"type":"object","properties":{"ids":{"type":"array","allOf":[{"description":"x"}]}}}`, path: "properties.ids", wantItemType: "string", wantDesc: "x", }, } for cleanerName, cleaner := range cleaners { for _, tc := range cases { t.Run(cleanerName+"/"+tc.name, func(t *testing.T) { got := cleaner.clean(tc.input) node := ojson.Parse(got) if tc.path != "" { node = node.Get(tc.path) } if gotType := node.Get("type").String(); gotType != "array" { t.Errorf("type = %q, want \"array\": %s", gotType, got) } if gotItemType := node.Get("items.type").String(); gotItemType != tc.wantItemType { t.Errorf("items.type = %q, want %q: %s", gotItemType, tc.wantItemType, got) } if tc.wantDesc != "" { if gotDesc := node.Get("description").String(); gotDesc != tc.wantDesc { t.Errorf("description = %q, want %q: %s", gotDesc, tc.wantDesc, got) } } if tc.wantNullable && cleaner.preservesNullable { if !node.Get("nullable").Bool() { t.Errorf("nullable = false, want true: %s", got) } } compareJSON(t, got, cleaner.clean(got)) }) } } } func TestIssue7149_CleanJSONSchemaForGeminiJSONSchemaNormalizesNumericConstraintStrings(t *testing.T) { input := `{ "type": "OBJECT", "minProperties": "1", "maxProperties": "8", "$defs": { "Tag": { "type": "STRING", "minLength": "2", "maxLength": "16" } }, "properties": { "Message": { "type": "STRING", "minLength": "1", "maxLength": "100", "enum": ["1", "2"], "default": "1", "example": "1", "examples": ["1", {"minLength": "1"}] }, "Items": { "type": "ARRAY", "minItems": "1", "maxItems": "4", "items": { "$ref": "#/$defs/Tag" } }, "EqualRef": { "$ref": "#/$defs/Tag", "minLength": 2 } } }` got := CleanJSONSchemaForGeminiJSONSchema(input) parsed := ojson.Parse(got) for _, tc := range []struct { path string want int64 }{ {"minProperties", 1}, {"maxProperties", 8}, {"properties.Message.minLength", 1}, {"properties.Message.maxLength", 100}, {"properties.Items.minItems", 1}, {"properties.Items.maxItems", 4}, {"properties.Items.items.minLength", 2}, {"properties.Items.items.maxLength", 16}, {"properties.EqualRef.minLength", 2}, {"properties.EqualRef.maxLength", 16}, } { val := parsed.Get(tc.path) if val.Type != ojson.Number || val.Int() != tc.want { t.Errorf("%s = %s (type %v), want number %d; output=%s", tc.path, val.Raw, val.Type, tc.want, got) } } for _, tc := range []struct { path string want string }{ {"properties.Message.enum.0", "1"}, {"properties.Message.enum.1", "2"}, {"properties.Message.default", "1"}, {"properties.Message.example", "1"}, {"properties.Message.examples.0", "1"}, {"properties.Message.examples.1.minLength", "1"}, } { val := parsed.Get(tc.path) if val.Type != ojson.String || val.String() != tc.want { t.Errorf("%s = %s (type %v), want string %q; output=%s", tc.path, val.Raw, val.Type, tc.want, got) } } extInput := `{"type":"string","minLength":"1","x-metadata":{"minLength":"2","maxItems":"3"},"customAnnotation":{"minProperties":"4"},"schema":{"minLength":"5"},"defs":{"metadata":{"minLength":"9"}},"properties":{"a\\b":{"type":"string","minLength":"6"},"a.b":{"type":"string","maxLength":"7"}},"patternProperties":{"^\\d+$":{"type":"string","minLength":"8"}}}` extGot := NormalizeJSONSchemaNumericConstraints(extInput) extParsed := ojson.Parse(extGot) if got := extParsed.Get("minLength"); got.Type != ojson.Number || got.Int() != 1 { t.Errorf("minLength = %s (type %v), want number 1; output=%s", got.Raw, got.Type, extGot) } if got := extParsed.Get("properties").Map()[`a\b`].Get("minLength"); got.Type != ojson.Number || got.Int() != 6 { t.Errorf(`properties["a\\b"].minLength = %s (type %v), want number 6; output=%s`, got.Raw, got.Type, extGot) } if _, BogusExists := extParsed.Get("properties").Map()["ab"]; BogusExists { t.Errorf(`unexpected fabricated property "ab" in output: %s`, extGot) } if got := extParsed.Get("properties").Map()["a.b"].Get("maxLength"); got.Type != ojson.Number || got.Int() != 7 { t.Errorf(`properties["a.b"].maxLength = %s (type %v), want number 7; output=%s`, got.Raw, got.Type, extGot) } if got := extParsed.Get("patternProperties").Map()[`^\d+$`].Get("minLength"); got.Type != ojson.Number || got.Int() != 8 { t.Errorf(`patternProperties["^\\d+$"].minLength = %s (type %v), want number 8; output=%s`, got.Raw, got.Type, extGot) } for _, path := range []struct { path string want string }{ {"x-metadata.minLength", "2"}, {"x-metadata.maxItems", "3"}, {"customAnnotation.minProperties", "4"}, {"schema.minLength", "5"}, {"defs.metadata.minLength", "9"}, } { got := extParsed.Get(path.path) if got.Type != ojson.String || got.String() != path.want { t.Errorf("%s = %s (type %v), want string %q; output=%s", path.path, got.Raw, got.Type, path.want, extGot) } } unicodeEscapedInput := `{"type":"string","\u006dinLength":"1","\u0070roperties":{"Msg":{"type":"string","\u006daxLength":"2"}}}` unicodeEscapedGot := NormalizeJSONSchemaNumericConstraints(unicodeEscapedInput) unicodeParsed := ojson.Parse(unicodeEscapedGot) if got := unicodeParsed.Get("minLength"); got.Type != ojson.Number || got.Int() != 1 { t.Errorf("unicode-escaped minLength = %s (type %v), want number 1; output=%s", got.Raw, got.Type, unicodeEscapedGot) } if got := unicodeParsed.Get("properties.Msg.maxLength"); got.Type != ojson.Number || got.Int() != 2 { t.Errorf("unicode-escaped properties.Msg.maxLength = %s (type %v), want number 2; output=%s", got.Raw, got.Type, unicodeEscapedGot) } }