diff --git a/internal/runtime/executor/antigravity_schema_sanitize_test.go b/internal/runtime/executor/antigravity_schema_sanitize_test.go index aa8b43f96..ea59aaeb8 100644 --- a/internal/runtime/executor/antigravity_schema_sanitize_test.go +++ b/internal/runtime/executor/antigravity_schema_sanitize_test.go @@ -275,6 +275,15 @@ func TestSanitizeAntigravityRequestSchemasProjectsUnionsAndPreservesEnumTypes(t payload := `{"request":{ "tools":[{"functionDeclarations":[{"name":"tool","parameters":{"type":"object","properties":{ "choice":{"anyOf":[{"type":"string"},{"type":"null"}]}, + "item":{"anyOf":[ + {"type":"string"}, + {"type":"object","properties":{"image":{"type":"string"}},"required":["image"]}, + {"type":"object","properties":{"audio":{"type":"string"}},"required":["audio"]} + ]}, + "variant":{"oneOf":[ + {"type":"object","properties":{"image":{"type":"string"}},"required":["image"]}, + {"type":"object","properties":{"audio":{"type":"string"}},"required":["audio"]} + ]}, "level":{"type":"number","enum":[1,2]} }}}]}], "generationConfig":{"responseSchema":{"type":"object","properties":{ @@ -304,7 +313,22 @@ func TestSanitizeAntigravityRequestSchemasProjectsUnionsAndPreservesEnumTypes(t toolSchema := gjson.Get(got, "request.tools.0.functionDeclarations.0.parameters") if toolSchema.Get("properties.choice.anyOf").Exists() { - t.Errorf("tool anyOf union was not flattened: %s", toolSchema.Raw) + t.Errorf("tool nullable single-branch anyOf union was not flattened: %s", toolSchema.Raw) + } + if gotBranches := len(toolSchema.Get("properties.item.anyOf").Array()); gotBranches != 3 { + t.Errorf("tool multi-branch anyOf count = %d, want 3: %s", gotBranches, toolSchema.Raw) + } + if !toolSchema.Get("properties.item.anyOf.2.properties.audio").Exists() { + t.Errorf("tool multi-branch anyOf lost audio branch: %s", toolSchema.Raw) + } + if toolSchema.Get("properties.variant.oneOf").Exists() { + t.Errorf("tool multi-branch oneOf should be converted to anyOf for parameters Schema carrier: %s", toolSchema.Raw) + } + if gotVariantBranches := len(toolSchema.Get("properties.variant.anyOf").Array()); gotVariantBranches != 2 { + t.Errorf("tool multi-branch variant.anyOf count = %d, want 2: %s", gotVariantBranches, toolSchema.Raw) + } + if !toolSchema.Get("properties.variant.anyOf.1.properties.audio").Exists() { + t.Errorf("tool multi-branch variant.anyOf lost audio branch: %s", toolSchema.Raw) } if gotType := toolSchema.Get("properties.level.type").String(); gotType != "number" { t.Errorf("tool enum type = %q, want number: %s", gotType, toolSchema.Raw) diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index 8c97b72cc..da55f2520 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -71,7 +71,7 @@ func CleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder bool) // additionalProperties: false (which Antigravity natively enforces for response schemas). // - Description hints + deletion: unsupported or accepted-but-ignored constraints. // - Flattened: allOf merged into properties/required. -// - Projected: anyOf/oneOf select the strongest branch; null branches become nullable:true. +// - Unions: multi-branch anyOf/oneOf unions are preserved; single-type nullable unions ([T, null]) project to T with nullable:true. // - Resolved: local $ref targets are inlined before $defs/definitions are removed. // - Dropped: unresolved $ref (after a hint), metadata, unsupported object-key constraints, // conditional keywords (after non-conflicting properties are retained), and x-* extensions. @@ -136,7 +136,7 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { jsonStr = mergeConditionals(jsonStr) jsonStr = mergeAllOf(jsonStr) if options.flattenUnions { - jsonStr = flattenAnyOfOneOf(jsonStr) + jsonStr = flattenAnyOfOneOf(jsonStr, options.preserveStandardConstraints) } jsonStr = flattenTypeArrays(jsonStr, options.antigravitySemantics) @@ -1121,80 +1121,233 @@ func mergeMissingSchemaAtPath(jsonStr, destination string, incoming gjson.Result return jsonStr } -func flattenAnyOfOneOf(jsonStr string) string { - for _, key := range []string{"anyOf", "oneOf"} { - paths := findPaths(jsonStr, key) - sortByDepth(paths) +func isRequiredOnlyUnionBranch(item gjson.Result) bool { + if !item.IsObject() || !item.Get("required").Exists() { + return false + } + for _, key := range []string{"type", "properties", "items", "enum", "anyOf", "oneOf", "allOf"} { + if item.Get(key).Exists() { + return false + } + } + return true +} - for _, p := range paths { - arr := gjson.Get(jsonStr, p) - if !arr.IsArray() || len(arr.Array()) == 0 { +func materializeRequiredOnlyBranch(item, parentProps gjson.Result) (gjson.Result, bool) { + if !parentProps.IsObject() { + return gjson.Result{}, false + } + reqs := getStrings(item.Raw, "required") + if len(reqs) == 0 { + return gjson.Result{}, false + } + raw := item.Raw + for _, reqName := range reqs { + escaped := escapeGJSONPathKey(reqName) + propVal := parentProps.Get(escaped) + if !propVal.Exists() { + return gjson.Result{}, false + } + updated, errSet := sjson.SetRawBytes([]byte(raw), "properties."+escaped, []byte(propVal.Raw)) + if errSet == nil { + raw = string(updated) + } + } + if updated, errSet := sjson.SetBytes([]byte(raw), "type", "object"); errSet == nil { + raw = string(updated) + } + return gjson.Parse(raw), true +} + +func isNullOnlyBranch(item gjson.Result) bool { + if item.Get("properties").Exists() || item.Get("items").Exists() { + return false + } + t := item.Get("type") + if t.Type == gjson.String { + return t.String() == "null" + } + if t.IsArray() && len(t.Array()) > 0 { + for _, elem := range t.Array() { + if elem.String() != "null" { + return false + } + } + return true + } + return false +} + +func partitionUnionBranches(items []gjson.Result, parentProps gjson.Result) (nonNullItems []gjson.Result, hasNull, hasFilteredBranch bool) { + for _, item := range items { + if item.Type == gjson.True { + nonNullItems = append(nonNullItems, gjson.Parse("{}")) + hasFilteredBranch = true + continue + } + if !item.IsObject() { + hasFilteredBranch = true + continue + } + if isNullOnlyBranch(item) { + hasNull = true + continue + } + if isRequiredOnlyUnionBranch(item) { + if materialized, ok := materializeRequiredOnlyBranch(item, parentProps); ok { + nonNullItems = append(nonNullItems, materialized) + hasFilteredBranch = true continue } + hasFilteredBranch = true + continue + } + nonNullItems = append(nonNullItems, item) + } + return +} - parentPath := trimSuffix(p, "."+key) - parent := gjson.Get(jsonStr, parentPath) - if parentPath == "" { - parent = gjson.Parse(jsonStr) +func flattenAnyOfOneOf(jsonStr string, preserveOneOf bool) string { + pathsByField := findPathsByFields(jsonStr, []string{"oneOf", "anyOf"}) + var paths []string + for _, key := range []string{"oneOf", "anyOf"} { + paths = append(paths, pathsByField[key]...) + } + sortByDepth(paths) + + for _, p := range paths { + key := "anyOf" + if p == "oneOf" || strings.HasSuffix(p, ".oneOf") { + key = "oneOf" + } + + arr := gjson.Get(jsonStr, p) + if !arr.IsArray() || len(arr.Array()) == 0 { + continue + } + + parentPath := trimSuffix(p, "."+key) + if isPropertyDefinition(parentPath) { + continue + } + parent := gjson.Get(jsonStr, parentPath) + if parentPath == "" { + parent = gjson.Parse(jsonStr) + } + + // When a node declares both anyOf and oneOf on a native Schema carrier (!preserveOneOf), + // protobuf Schema has only a single anyOf field and cannot represent a second union + // conjunction structurally. Preserve anyOf as the primary union and move the sibling + // oneOf constraint to a description hint instead of concatenating it into anyOf. + if key == "oneOf" && !preserveOneOf && parent.Get("anyOf").IsArray() { + jsonStr = appendHint(jsonStr, parentPath, "oneOf: "+arr.Raw) + jsonStr, _ = sjson.Delete(jsonStr, p) + continue + } + + items := arr.Array() + parentProps := parent.Get("properties") + nonNullItems, hasNull, hasFilteredBranch := partitionUnionBranches(items, parentProps) + + // Preserve multi-branch unions (2+ standalone non-null branches) so non-first branches are not lost. + // On native Gemini/Antigravity Schema carriers (!preserveOneOf), normalize oneOf to anyOf because + // the protobuf Schema message supports anyOf but rejects oneOf. + if len(nonNullItems) >= 2 { + targetKey := key + if key == "oneOf" && !preserveOneOf { + targetKey = "anyOf" } + targetPath := joinPath(parentPath, targetKey) - items := arr.Array() - - // If the parent already defines properties (e.g. an object schema with anyOf/oneOf constraints), - // do not replace the parent with a single branch. Instead, merge any branch properties - // into the parent and delete the union keyword. - if parentProps := parent.Get("properties"); parentProps.IsObject() { - hasNull := false - for _, item := range items { - if item.Get("type").String() == "null" { - hasNull = true - } - if branchProps := item.Get("properties"); branchProps.IsObject() { - branchProps.ForEach(func(propKey, propVal gjson.Result) bool { - destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(propKey.String())) - jsonStr = mergeMissingSchemaAtPath(jsonStr, destPath, propVal) - return true - }) - } + if targetKey != key || hasNull || hasFilteredBranch { + rawBranches := make([]json.RawMessage, 0, len(nonNullItems)) + for _, item := range nonNullItems { + rawBranches = append(rawBranches, json.RawMessage(item.Raw)) } - if hasNull { - updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true) + updated, errSet := sjson.SetBytes([]byte(jsonStr), targetPath, rawBranches) + if errSet == nil { jsonStr = string(updated) } - if parent.Get("type").String() == "" { - updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "type"), "object") + if targetKey != key { + jsonStr, _ = sjson.Delete(jsonStr, p) + } + } + if hasNull { + updated, errSet := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true) + if errSet == nil { jsonStr = string(updated) } + jsonStr = appendHint(jsonStr, parentPath, "(nullable)") + } + continue + } + + // If the parent already defines properties (e.g. an object schema with unmaterializable + // union constraints or a single-branch union), do not replace the parent with a single branch. + // Instead, merge any branch properties into the parent and delete the union keyword. + if parentProps.IsObject() { + for _, item := range nonNullItems { + if branchProps := item.Get("properties"); branchProps.IsObject() { + branchProps.ForEach(func(propKey, propVal gjson.Result) bool { + destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(propKey.String())) + jsonStr = mergeMissingSchemaAtPath(jsonStr, destPath, propVal) + return true + }) + } + } + if hasNull { + updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true) + jsonStr = string(updated) + } + if parent.Get("type").String() == "" { + updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "type"), "object") + jsonStr = string(updated) + } + jsonStr, _ = sjson.Delete(jsonStr, p) + continue + } + + if len(nonNullItems) == 0 { + if !hasNull { jsonStr, _ = sjson.Delete(jsonStr, p) continue } - - parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String() - bestIdx, allTypes := selectBest(items) - selected := items[bestIdx].Raw - hasNull := false - for _, item := range items { - if item.Get("type").String() == "null" { - hasNull = true - break - } - } - if hasNull && items[bestIdx].Get("type").String() != "null" { - updated, _ := sjson.SetBytes([]byte(selected), "nullable", true) - selected = string(updated) - } - - if parentDesc != "" { + selected := `{"type":"null"}` + if parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); parentDesc != "" { selected = mergeDescriptionRaw(selected, parentDesc) } - - if len(allTypes) > 1 { - hint := "Accepts: " + strings.Join(allTypes, " | ") - selected = appendHintRaw(selected, hint) - } - jsonStr = setRawAt(jsonStr, parentPath, selected) + continue } + + candidateItems := items + if hasFilteredBranch { + candidateItems = nonNullItems + if hasNull { + candidateItems = append(append([]gjson.Result(nil), nonNullItems...), gjson.Parse(`{"type":"null"}`)) + } + } + + parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String() + _, allTypes := selectBest(candidateItems) + selected := nonNullItems[0].Raw + if hasNull { + updated, _ := sjson.SetBytes([]byte(selected), "nullable", true) + selected = string(updated) + } + + if parentDesc != "" { + selected = mergeDescriptionRaw(selected, parentDesc) + } + + if len(allTypes) > 1 { + hint := "Accepts: " + strings.Join(allTypes, " | ") + selected = appendHintRaw(selected, hint) + } else if hasNull { + selected = appendHintRaw(selected, "(nullable)") + } + + jsonStr = setRawAt(jsonStr, parentPath, selected) } return jsonStr } @@ -1460,6 +1613,9 @@ func addEmptySchemaPlaceholder(jsonStr string) string { reqPath := joinPath(parentPath, "required") reqVal := gjson.Get(jsonStr, reqPath) hasRequiredProperties := reqVal.IsArray() && len(reqVal.Array()) > 0 + if gjson.Get(jsonStr, joinPath(parentPath, "anyOf")).IsArray() || gjson.Get(jsonStr, joinPath(parentPath, "oneOf")).IsArray() { + continue + } needsPlaceholder := false if !propsVal.Exists() { diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index 5da8f3f4d..4311b18bb 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -158,8 +158,10 @@ func TestCleanJSONSchemaForAntigravity_OneOfFlattening(t *testing.T) { "type": "object", "properties": { "config": { - "type": "string", - "description": "Accepts: string | integer" + "anyOf": [ + { "type": "string" }, + { "type": "integer" } + ] } } }` @@ -445,14 +447,22 @@ func TestCleanJSONSchemaForAntigravity_AnyOfAlternativeHints(t *testing.T) { } }` - result := CleanJSONSchemaForAntigravity(input) + expected := `{ + "type": "object", + "properties": { + "value": { + "anyOf": [ + { "type": "string" }, + { "type": "integer" } + ], + "nullable": true, + "description": "(nullable)" + } + } + }` - if !strings.Contains(result, "Accepts:") { - t.Errorf("Expected alternative types hint, got: %s", result) - } - if !strings.Contains(result, "string") || !strings.Contains(result, "integer") { - t.Errorf("Expected all alternative types in hint, got: %s", result) - } + result := CleanJSONSchemaForAntigravity(input) + compareJSON(t, expected, result) } func TestCleanJSONSchemaForAntigravity_NullableHint(t *testing.T) { @@ -549,7 +559,34 @@ func TestCleanJSONSchemaForAntigravity_AdditionalPropertiesHint(t *testing.T) { } func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *testing.T) { - input := `{ + 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": { @@ -562,18 +599,8 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *t } }` - expected := `{ - "type": "object", - "properties": { - "config": { - "type": "string", - "description": "Parent desc (Child desc) (Accepts: string | integer)" - } - } - }` - - result := CleanJSONSchemaForAntigravity(input) - compareJSON(t, expected, result) + resultMulti := CleanJSONSchemaForAntigravity(multiBranchInput) + compareJSON(t, multiBranchInput, resultMulti) } func TestCleanJSONSchemaForAntigravity_SingleEnumBecomesHint(t *testing.T) { @@ -2280,7 +2307,8 @@ func TestCleanJSONSchema_PreservesAdditionalPropertiesObjectSchema(t *testing.T) } // TestCleanJSONSchemaForAntigravityResponse_AnyOfRequiredOnlyBranches tests issue 5219 #2: -// anyOf with required-only branches should not overwrite the parent object's type and properties. +// 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", @@ -2304,8 +2332,15 @@ func TestCleanJSONSchemaForAntigravityResponse_AnyOfRequiredOnlyBranches(t *test if !parsed.Get("properties.left").Exists() || !parsed.Get("properties.right").Exists() { t.Fatalf("properties were wiped out; cleaned: %s", got) } - if parsed.Get("anyOf").Exists() { - t.Fatalf("anyOf was not removed; 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) } } @@ -3135,3 +3170,357 @@ func TestCleanJSONSchema_RemovesDependentKeywords_Issue6539(t *testing.T) { }) } } + +// 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. +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 := gjson.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 := gjson.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 := gjson.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 := gjson.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 := gjson.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 := gjson.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) + } + + // Null-only unions (anyOf:[null], oneOf:[null], anyOf:[false, null]) must not panic across any cleaner. + 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 cleaners { + got := clean(nullOnlyInput) + if !gjson.Valid(got) { + t.Fatalf("[%s] invalid JSON for null-only union %s: %s", name, nullOnlyInput, got) + } + if gjson.Get(got, "properties.value.type").String() != "null" { + t.Fatalf("[%s] expected properties.value.type = null, got: %s", name, got) + } + } + } + }) +}