fix(schema): preserve multi-branch anyOf/oneOf unions during schema sanitization

- Retain multi-branch `anyOf` and `oneOf` unions instead of flattening them into a single branch.
- Normalize `oneOf` to `anyOf` on native Gemini/Antigravity schema carriers while preserving `oneOf` when standard constraints are allowed.
- Materialize required-only union branches using parent object properties.
- Strip null and boolean `false` branches from unions, applying `nullable: true` and description hints where appropriate.

Closes: #3662
This commit is contained in:
Luis Pater
2026-10-13 10:04:32 +08:00
parent e235fb3d64
commit a0fd8448f5
3 changed files with 653 additions and 84 deletions

View File

@@ -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)

View File

@@ -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() {

View File

@@ -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)
}
}
}
})
}