internal/util/gemini_schema_test.go
Rama main · réplica de lectura del repositorio original.
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 <tag> & 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, "<tag>") || !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)
}
}