Merge pull request #5008 from shengyy/fix/antigravity-schema-semantics

fix(antigravity): preserve response and tool schema semantics
This commit is contained in:
Luis Pater
2026-08-16 14:45:15 +08:00
committed by GitHub
4 changed files with 486 additions and 142 deletions

View File

@@ -205,9 +205,8 @@ func sanitizeAntigravityToolSchemaDocument(payloadStr string, useAntigravitySche
payloadStr = renamed
}
toolSchemaCleaner := util.CleanJSONSchemaForGemini
if useAntigravitySchema {
toolSchemaCleaner = util.CleanJSONSchemaForAntigravity
toolSchemaCleaner := func(schema string) string {
return util.CleanJSONSchemaForAntigravityTool(schema, useAntigravitySchema)
}
cleanNestedToolSchema := func(schemaRaw string) string {
return cleanNestedSchema(toolSchemaCleaner, schemaRaw)

View File

@@ -222,10 +222,7 @@ func TestSanitizeAntigravityRequestSchemasMatchesWholePayloadCleaning(t *testing
for _, useAntigravitySchema := range []bool{false, true} {
for name, schema := range shapes {
doc := `{"request":{"tools":[{"functionDeclarations":[{"name":"t","parameters":` + schema + `}]}]}}`
whole := util.CleanJSONSchemaForGemini(doc)
if useAntigravitySchema {
whole = util.CleanJSONSchemaForAntigravity(doc)
}
whole := util.CleanJSONSchemaForAntigravityTool(doc, useAntigravitySchema)
want := gjson.Get(whole, schemaPath).Raw
got := gjson.Get(sanitizeAntigravityRequestSchemas(doc, useAntigravitySchema), schemaPath).Raw
if want != got {
@@ -274,7 +271,7 @@ func TestSanitizeAntigravityRequestSchemasKeepsResponseSchemasPlaceholderFree(t
}
}
func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *testing.T) {
func TestSanitizeAntigravityRequestSchemasProjectsUnionsAndPreservesEnumTypes(t *testing.T) {
payload := `{"request":{
"tools":[{"functionDeclarations":[{"name":"tool","parameters":{"type":"object","properties":{
"choice":{"anyOf":[{"type":"string"},{"type":"null"}]},
@@ -291,9 +288,9 @@ func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *t
got := sanitizeAntigravityRequestSchemas(payload, true)
responseSchema := gjson.Get(got, "request.generationConfig.responseSchema")
union := responseSchema.Get("properties.action.anyOf")
if !union.IsArray() || len(union.Array()) != 2 || union.Get("1.type").String() != "null" {
t.Errorf("response anyOf union was flattened: %s", responseSchema.Raw)
action := responseSchema.Get("properties.action")
if action.Get("anyOf").Exists() || action.Get("type").String() != "object" || !action.Get("nullable").Bool() {
t.Errorf("response anyOf was not projected to nullable object: %s", responseSchema.Raw)
}
conviction := responseSchema.Get("properties.conviction")
if gotType := conviction.Get("type").String(); gotType != "number" {
@@ -309,8 +306,35 @@ func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *t
if toolSchema.Get("properties.choice.anyOf").Exists() {
t.Errorf("tool anyOf union was not flattened: %s", toolSchema.Raw)
}
if gotType := toolSchema.Get("properties.level.type").String(); gotType != "string" {
t.Errorf("tool enum type = %q, want string: %s", gotType, toolSchema.Raw)
if gotType := toolSchema.Get("properties.level.type").String(); gotType != "number" {
t.Errorf("tool enum type = %q, want number: %s", gotType, toolSchema.Raw)
}
}
func TestSanitizeAntigravityToolSchemasKeepNativeTypeAndNullableOnBothPaths(t *testing.T) {
payload := `{"request":{"tools":[{"functionDeclarations":[{"name":"tool","parameters":{
"type":"object",
"properties":{
"level":{"type":"number","enum":[1,2]},
"note":{"type":["string","null"]}
},
"required":["level","note"]
}}]}]}}`
for _, requirePlaceholder := range []bool{false, true} {
got := sanitizeAntigravityRequestSchemas(payload, requirePlaceholder)
schema := gjson.Get(got, "request.tools.0.functionDeclarations.0.parameters")
if schema.Get("properties.level.type").String() != "number" {
t.Fatalf("placeholder=%v changed numeric tool argument type: %s", requirePlaceholder, schema.Raw)
}
for _, member := range schema.Get("properties.level.enum").Array() {
if member.Type != gjson.String {
t.Fatalf("placeholder=%v left non-string proto enum: %s", requirePlaceholder, schema.Raw)
}
}
if !schema.Get("properties.note.nullable").Bool() || schema.Get("required.1").String() != "note" {
t.Fatalf("placeholder=%v lost native nullable/required semantics: %s", requirePlaceholder, schema.Raw)
}
}
}

View File

@@ -2,6 +2,7 @@
package util
import (
"encoding/json"
"fmt"
"sort"
"strconv"
@@ -26,9 +27,13 @@ const placeholderReasonDescription = "Brief explanation of why you are calling t
type jsonSchemaCleanOptions struct {
addPlaceholder bool
antigravitySemantics bool
removeToolTitle bool
removeGeminiMetadata bool
flattenUnions bool
forceEnumStringType bool
dropAllEnums bool
dropBooleanEnums bool
preserveAdditionalPropertiesFalse bool
}
@@ -36,10 +41,20 @@ type jsonSchemaCleanOptions struct {
// It handles unsupported keywords, type flattening, and schema simplification while preserving
// semantic information as description hints and adding placeholders required by VALIDATED mode.
func CleanJSONSchemaForAntigravity(jsonStr string) string {
return CleanJSONSchemaForAntigravityTool(jsonStr, true)
}
// CleanJSONSchemaForAntigravityTool transforms an Antigravity function schema. The private
// backend accepts enum members only as strings, but the declared type still controls the JSON
// type of generated function arguments, so numeric and boolean types must not be rewritten.
// requirePlaceholder is used only for Claude VALIDATED mode.
func CleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder bool) string {
return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
addPlaceholder: true,
flattenUnions: true,
forceEnumStringType: true,
addPlaceholder: requirePlaceholder,
antigravitySemantics: true,
removeToolTitle: !requirePlaceholder,
flattenUnions: true,
dropAllEnums: true,
})
}
@@ -47,16 +62,19 @@ func CleanJSONSchemaForAntigravity(jsonStr string) string {
// compatibility rewrites that would alter the client's structured output contract.
//
// Sanitization policy:
// - Passthrough: type, properties, items, required, description, enum, union structures (anyOf/oneOf),
// and additionalProperties: false (which Antigravity natively enforces as a closed-object constraint).
// - Description hints + deletion: unsupported constraints (minLength, maxLength, pattern, minItems,
// maxItems, uniqueItems, format, default, examples).
// - Passthrough: type, properties, items, required, description, enum, nullable, and
// additionalProperties: false (which Antigravity natively enforces for response schemas).
// - Description hints + deletion: unsupported or accepted-but-ignored constraints.
// - Flattened: allOf merged into properties/required.
// - Dropped: $schema, $defs, definitions, const (converted to enum first), $ref (converted to hint),
// $id, propertyNames, patternProperties, conditional schemas (if/then/else), non-false additionalProperties,
// and x-* extensions.
// - Projected: anyOf/oneOf select the strongest branch; null branches become 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.
func CleanJSONSchemaForAntigravityResponse(jsonStr string) string {
return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
antigravitySemantics: true,
flattenUnions: true,
dropBooleanEnums: true,
preserveAdditionalPropertiesFalse: true,
})
}
@@ -74,14 +92,21 @@ func CleanJSONSchemaForGemini(jsonStr string) string {
// cleanJSONSchema performs the core cleaning operations on the JSON schema.
func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
// Phase 1: Convert and add hints
jsonStr = convertRefsToHints(jsonStr)
if options.antigravitySemantics {
jsonStr = inlineLocalRefs(jsonStr)
}
jsonStr = convertRefsToHints(jsonStr, options.antigravitySemantics)
jsonStr = convertConstToEnum(jsonStr)
jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType)
jsonStr = addEnumHints(jsonStr)
jsonStr = dropIgnoredEnumsToHints(jsonStr, options)
if !options.preserveAdditionalPropertiesFalse {
jsonStr = addAdditionalPropertiesHints(jsonStr)
}
jsonStr = moveConstraintsToDescription(jsonStr)
jsonStr = moveConstraintsToDescription(jsonStr, options)
if options.antigravitySemantics {
jsonStr = moveNotToDescription(jsonStr)
}
// Phase 2: Flatten complex structures
jsonStr = mergeConditionals(jsonStr)
@@ -89,7 +114,7 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
if options.flattenUnions {
jsonStr = flattenAnyOfOneOf(jsonStr)
}
jsonStr = flattenTypeArrays(jsonStr)
jsonStr = flattenTypeArrays(jsonStr, options.antigravitySemantics)
// Phase 3: Cleanup
jsonStr = removeUnsupportedKeywords(jsonStr, options)
@@ -97,6 +122,10 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
// Gemini schema cleanup: remove nullable/title and placeholder-only fields.
jsonStr = removeKeywords(jsonStr, []string{"nullable", "title"})
jsonStr = removePlaceholderFields(jsonStr)
} else if options.removeToolTitle {
// Legacy non-VALIDATED Antigravity requests used the Gemini cleaner, which drops title.
// Keep that harmless metadata policy without losing Antigravity's native nullable support.
jsonStr = removeKeywords(jsonStr, []string{"title"})
}
jsonStr = cleanupRequiredFields(jsonStr)
// Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)
@@ -193,28 +222,151 @@ func removePlaceholderFields(jsonStr string) string {
return jsonStr
}
// convertRefsToHints converts $ref to description hints (Lazy Hint strategy).
func convertRefsToHints(jsonStr string) string {
// inlineLocalRefs resolves JSON Pointer references against the original schema before definition
// containers are stripped. Each expansion receives its own copy, sibling keywords override the
// referenced definition, and cycles terminate as a typed hint instead of recursing forever.
func inlineLocalRefs(jsonStr string) string {
if !strings.Contains(jsonStr, `"$ref"`) {
return jsonStr
}
decoder := json.NewDecoder(strings.NewReader(jsonStr))
decoder.UseNumber()
var root any
if err := decoder.Decode(&root); err != nil {
return jsonStr
}
resolved := resolveLocalRefs(root, root, make(map[string]bool))
out, err := json.Marshal(resolved)
if err != nil {
return jsonStr
}
return string(out)
}
func resolveLocalRefs(root, value any, active map[string]bool) any {
switch node := value.(type) {
case []any:
out := make([]any, len(node))
for i, item := range node {
out[i] = resolveLocalRefs(root, item, active)
}
return out
case map[string]any:
ref, hasRef := node["$ref"].(string)
if hasRef && strings.HasPrefix(ref, "#/") {
if target, ok := resolveJSONPointer(root, ref); ok {
if active[ref] {
return cyclicRefFallback(node, target, ref)
}
active[ref] = true
resolvedTarget := resolveLocalRefs(root, target, active)
delete(active, ref)
if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget {
out := make(map[string]any, len(targetMap)+len(node))
for key, item := range targetMap {
out[key] = item
}
for key, item := range node {
if key == "$ref" {
continue
}
out[key] = resolveLocalRefs(root, item, active)
}
return out
}
}
}
out := make(map[string]any, len(node))
for key, item := range node {
out[key] = resolveLocalRefs(root, item, active)
}
return out
default:
return value
}
}
func resolveJSONPointer(root any, ref string) (any, bool) {
current := root
for _, rawPart := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") {
part := strings.ReplaceAll(strings.ReplaceAll(rawPart, "~1", "/"), "~0", "~")
switch node := current.(type) {
case map[string]any:
var ok bool
current, ok = node[part]
if !ok {
return nil, false
}
case []any:
index, err := strconv.Atoi(part)
if err != nil || index < 0 || index >= len(node) {
return nil, false
}
current = node[index]
default:
return nil, false
}
}
return current, true
}
func cyclicRefFallback(node map[string]any, target any, ref string) map[string]any {
out := make(map[string]any, len(node)+2)
if targetMap, ok := target.(map[string]any); ok {
for _, key := range []string{"type", "nullable", "description"} {
if value, exists := targetMap[key]; exists {
out[key] = value
}
}
}
for key, value := range node {
if key != "$ref" {
out[key] = value
}
}
name := refName(ref)
hint := "See: " + name
if description, _ := out["description"].(string); description != "" {
out["description"] = mergeHint(description, hint)
} else {
out["description"] = hint
}
return out
}
func refName(ref string) string {
if index := strings.LastIndex(ref, "/"); index >= 0 && index+1 < len(ref) {
return strings.ReplaceAll(strings.ReplaceAll(ref[index+1:], "~1", "/"), "~0", "~")
}
return ref
}
// convertRefsToHints retains sibling keywords and converts only unresolved or external references
// to descriptions. Local references have already been expanded by inlineLocalRefs.
func convertRefsToHints(jsonStr string, preserveSiblings bool) string {
paths := findPaths(jsonStr, "$ref")
sortByDepth(paths)
for _, p := range paths {
refVal := gjson.Get(jsonStr, p).String()
defName := refVal
if idx := strings.LastIndex(refVal, "/"); idx >= 0 {
defName = refVal[idx+1:]
}
defName := refName(refVal)
parentPath := trimSuffix(p, ".$ref")
hint := fmt.Sprintf("See: %s", defName)
if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" {
hint = fmt.Sprintf("%s (%s)", existing, hint)
if !preserveSiblings {
if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" {
hint = fmt.Sprintf("%s (%s)", existing, hint)
}
replacement := `{"type":"object","description":""}`
replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint)
jsonStr = setRawAt(jsonStr, parentPath, string(replacementBytes))
continue
}
replacement := `{"type":"object","description":""}`
replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint)
replacement = string(replacementBytes)
jsonStr = setRawAt(jsonStr, parentPath, replacement)
jsonStr, _ = sjson.Delete(jsonStr, p)
jsonStr = appendHint(jsonStr, parentPath, hint)
}
return jsonStr
}
@@ -235,8 +387,8 @@ func convertConstToEnum(jsonStr string) string {
}
// convertEnumValuesToStrings ensures all enum values use the string representation required by
// Gemini's proto schema. Tool schemas also require a string type, while response schemas preserve
// their declared type because the upstream decoder uses it to select the emitted JSON value type.
// Gemini's proto schema. The declared type remains independent: Antigravity uses it to choose the
// emitted JSON type on both response and function-argument paths.
func convertEnumValuesToStrings(jsonStr string, forceStringType bool) string {
for _, p := range findPaths(jsonStr, "enum") {
arr := gjson.Get(jsonStr, p)
@@ -280,6 +432,25 @@ func addEnumHints(jsonStr string) string {
return jsonStr
}
// Antigravity does not enforce enum on function arguments and ignores boolean response enums.
// Preserve the advisory values in description, but do not leave an unenforced constraint in the
// schema contract. Response enums for string, number, and integer remain native constraints.
func dropIgnoredEnumsToHints(jsonStr string, options jsonSchemaCleanOptions) string {
for _, path := range findPaths(jsonStr, "enum") {
parentPath := trimSuffix(path, ".enum")
shouldDrop := options.dropAllEnums || (options.dropBooleanEnums && gjson.Get(jsonStr, joinPath(parentPath, "type")).String() == "boolean")
if !shouldDrop {
continue
}
enum := gjson.Get(jsonStr, path)
if enum.IsArray() && len(enum.Array()) == 1 {
jsonStr = appendHint(jsonStr, parentPath, "Allowed: "+enum.Array()[0].String())
}
jsonStr, _ = sjson.Delete(jsonStr, path)
}
return jsonStr
}
func addAdditionalPropertiesHints(jsonStr string) string {
for _, p := range findPaths(jsonStr, "additionalProperties") {
if gjson.Get(jsonStr, p).Type == gjson.False {
@@ -295,9 +466,18 @@ var unsupportedConstraints = []string{
"default", "examples", // Claude rejects these in VALIDATED mode
}
func moveConstraintsToDescription(jsonStr string) string {
pathsByField := findPathsByFields(jsonStr, unsupportedConstraints)
for _, key := range unsupportedConstraints {
func constraintKeywords(options jsonSchemaCleanOptions) []string {
keywords := append([]string(nil), unsupportedConstraints...)
if options.antigravitySemantics {
keywords = append(keywords, "minimum", "maximum", "multipleOf")
}
return keywords
}
func moveConstraintsToDescription(jsonStr string, options jsonSchemaCleanOptions) string {
constraints := constraintKeywords(options)
pathsByField := findPathsByFields(jsonStr, constraints)
for _, key := range constraints {
for _, p := range pathsByField[key] {
val := gjson.Get(jsonStr, p)
if !val.Exists() || val.IsObject() || val.IsArray() {
@@ -313,6 +493,17 @@ func moveConstraintsToDescription(jsonStr string) string {
return jsonStr
}
func moveNotToDescription(jsonStr string) string {
for _, path := range findPaths(jsonStr, "not") {
value := gjson.Get(jsonStr, path)
if !value.Exists() || isPropertyDefinition(trimSuffix(path, ".not")) {
continue
}
jsonStr = appendHint(jsonStr, trimSuffix(path, ".not"), "not: "+value.Raw)
}
return jsonStr
}
func mergeConditionals(jsonStr string) string {
pathsByField := findPathsByFields(jsonStr, []string{"then", "else"})
var paths []string
@@ -367,31 +558,59 @@ func mergeAllOf(jsonStr string) string {
parentPath := trimSuffix(p, ".allOf")
for _, item := range allOf.Array() {
if props := item.Get("properties"); props.IsObject() {
props.ForEach(func(key, value gjson.Result) bool {
destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(key.String()))
updated, _ := sjson.SetRawBytes([]byte(jsonStr), destPath, []byte(value.Raw))
jsonStr = string(updated)
return true
})
if !item.IsObject() {
continue
}
if req := item.Get("required"); req.IsArray() {
reqPath := joinPath(parentPath, "required")
current := getStrings(jsonStr, reqPath)
for _, r := range req.Array() {
if s := r.String(); !contains(current, s) {
current = append(current, s)
item.ForEach(func(key, value gjson.Result) bool {
field := key.String()
switch field {
case "required":
if !value.IsArray() {
return true
}
reqPath := joinPath(parentPath, "required")
current := getStrings(jsonStr, reqPath)
for _, required := range value.Array() {
if name := required.String(); !contains(current, name) {
current = append(current, name)
}
}
updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, current)
jsonStr = string(updated)
case "if", "then", "else", "allOf":
// Conditional applicability cannot be represented by the upstream schema.
default:
destination := joinPath(parentPath, escapeGJSONPathKey(field))
jsonStr = mergeMissingSchemaAtPath(jsonStr, destination, value)
}
updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, current)
jsonStr = string(updated)
}
return true
})
}
jsonStr, _ = sjson.Delete(jsonStr, p)
}
return jsonStr
}
// mergeMissingSchemaAtPath recursively fills absent fields without replacing any existing
// definition. A parent schema is the canonical definition; allOf and conditional branches may
// enrich gaps in it, but can never replace it with a narrower branch shell.
func mergeMissingSchemaAtPath(jsonStr, destination string, incoming gjson.Result) string {
existing := gjson.Get(jsonStr, destination)
if !existing.Exists() {
updated, _ := sjson.SetRawBytes([]byte(jsonStr), destination, []byte(incoming.Raw))
return string(updated)
}
if !existing.IsObject() || !incoming.IsObject() {
return jsonStr
}
incoming.ForEach(func(key, value gjson.Result) bool {
child := joinPath(destination, escapeGJSONPathKey(key.String()))
jsonStr = mergeMissingSchemaAtPath(jsonStr, child, value)
return true
})
return jsonStr
}
func flattenAnyOfOneOf(jsonStr string) string {
for _, key := range []string{"anyOf", "oneOf"} {
paths := findPaths(jsonStr, key)
@@ -409,6 +628,17 @@ func flattenAnyOfOneOf(jsonStr string) string {
items := arr.Array()
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 = mergeDescriptionRaw(selected, parentDesc)
@@ -452,7 +682,7 @@ func selectBest(items []gjson.Result) (bestIdx int, types []string) {
return
}
func flattenTypeArrays(jsonStr string) string {
func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string {
paths := findPaths(jsonStr, "type")
sortByDepth(paths)
@@ -490,15 +720,20 @@ func flattenTypeArrays(jsonStr string) string {
}
if hasNull {
if preserveNativeNullable {
updated, _ = sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true)
jsonStr = string(updated)
jsonStr = appendHint(jsonStr, parentPath, "(nullable)")
continue
}
parts := splitGJSONPath(p)
if len(parts) >= 3 && parts[len(parts)-3] == "properties" {
fieldNameEscaped := parts[len(parts)-2]
fieldName := unescapeGJSONPathKey(fieldNameEscaped)
objectPath := strings.Join(parts[:len(parts)-3], ".")
nullableFields[objectPath] = append(nullableFields[objectPath], fieldName)
propPath := joinPath(objectPath, "properties."+fieldNameEscaped)
jsonStr = appendHint(jsonStr, propPath, "(nullable)")
jsonStr = appendHint(jsonStr, joinPath(objectPath, "properties."+fieldNameEscaped), "(nullable)")
}
}
}
@@ -511,12 +746,11 @@ func flattenTypeArrays(jsonStr string) string {
}
var filtered []string
for _, r := range req.Array() {
if !contains(fields, r.String()) {
filtered = append(filtered, r.String())
for _, required := range req.Array() {
if !contains(fields, required.String()) {
filtered = append(filtered, required.String())
}
}
if len(filtered) == 0 {
jsonStr, _ = sjson.Delete(jsonStr, reqPath)
} else {
@@ -528,12 +762,15 @@ func flattenTypeArrays(jsonStr string) string {
}
func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) string {
keywords := append(unsupportedConstraints,
keywords := append(constraintKeywords(options),
"$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties",
"propertyNames", "patternProperties", // Gemini doesn't support these schema keywords
"if", "then", "else",
"$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", // Schema metadata fields unsupported by Gemini
)
if options.antigravitySemantics {
keywords = append(keywords, "not")
}
deletePaths := make([]string, 0)
pathsByField := findPathsByFields(jsonStr, keywords)
@@ -746,7 +983,9 @@ func walkForFields(value gjson.Result, path string, fields map[string]struct{},
}
func sortByDepth(paths []string) {
sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) })
sort.SliceStable(paths, func(i, j int) bool {
return len(splitGJSONPath(paths[i])) > len(splitGJSONPath(paths[j]))
})
}
func trimSuffix(path, suffix string) string {

View File

@@ -25,7 +25,7 @@ func TestCleanJSONSchemaForAntigravity_ConstToEnum(t *testing.T) {
"properties": {
"kind": {
"type": "string",
"enum": ["InsightVizNode"]
"description": "Allowed: InsightVizNode"
}
}
}`
@@ -53,13 +53,14 @@ func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable(t *testing.T) {
"properties": {
"name": {
"type": "string",
"nullable": true,
"description": "(nullable)"
},
"other": {
"type": "string"
}
},
"required": ["other"]
"required": ["name", "other"]
}`
result := CleanJSONSchemaForAntigravity(input)
@@ -125,6 +126,7 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_SmartSelection(t *testing
"properties": {
"query": {
"type": "object",
"nullable": true,
"description": "Accepts: null | object",
"properties": {
"_": { "type": "boolean" },
@@ -214,20 +216,18 @@ func TestCleanJSONSchemaForAntigravity_RefHandling(t *testing.T) {
}
}`
// After $ref is converted to placeholder object, empty schema placeholder is also added
// 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",
"description": "See: User",
"properties": {
"reason": {
"type": "string",
"description": "Brief explanation of why you are calling this tool"
}
"name": { "type": "string" },
"_": { "type": "boolean" }
},
"required": ["reason"]
"required": ["_"]
}
}
}`
@@ -255,20 +255,17 @@ func TestCleanJSONSchemaForAntigravity_RefHandling_DescriptionEscaping(t *testin
}
}`
// After $ref is converted, empty schema placeholder is also added
expected := `{
"type": "object",
"properties": {
"customer": {
"type": "object",
"description": "He said \"hi\"\\nsecond line (See: User)",
"description": "He said \"hi\"\\nsecond line",
"properties": {
"reason": {
"type": "string",
"description": "Brief explanation of why you are calling this tool"
}
"name": { "type": "string" },
"_": { "type": "boolean" }
},
"required": ["reason"]
"required": ["_"]
}
}
}`
@@ -299,9 +296,9 @@ func TestCleanJSONSchemaForAntigravity_CyclicRefDefaults(t *testing.T) {
t.Errorf("Expected type: object, got: %v", resMap["type"])
}
desc, ok := resMap["description"].(string)
if !ok || !strings.Contains(desc, "Node") {
t.Errorf("Expected description hint containing 'Node', got: %v", resMap["description"])
child := gjson.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)
}
}
@@ -499,13 +496,14 @@ func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable_DotKey(t *testing
"properties": {
"my.param": {
"type": "string",
"nullable": true,
"description": "(nullable)"
},
"other": {
"type": "string"
}
},
"required": ["other"]
"required": ["my.param", "other"]
}`
result := CleanJSONSchemaForAntigravity(input)
@@ -578,7 +576,7 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *t
compareJSON(t, expected, result)
}
func TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) {
func TestCleanJSONSchemaForAntigravity_SingleEnumBecomesHint(t *testing.T) {
input := `{
"type": "object",
"properties": {
@@ -591,8 +589,8 @@ func TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) {
result := CleanJSONSchemaForAntigravity(input)
if strings.Contains(result, "Allowed:") {
t.Errorf("Single value enum should not add Allowed hint, got: %s", result)
if !strings.Contains(result, "Allowed: fixed") || gjson.Get(result, "properties.kind.enum").Exists() {
t.Errorf("Ignored tool enum should become a hint, got: %s", result)
}
}
@@ -764,7 +762,7 @@ func TestCleanJSONSchemaForAntigravityResponseDoesNotAddToolPlaceholders(t *test
}
}
func TestCleanJSONSchemaForAntigravityResponsePreservesUnions(t *testing.T) {
func TestCleanJSONSchemaForAntigravityResponseProjectsIgnoredUnions(t *testing.T) {
input := `{
"type":"object",
"properties":{
@@ -777,24 +775,18 @@ func TestCleanJSONSchemaForAntigravityResponsePreservesUnions(t *testing.T) {
}`
result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
for _, testCase := range []struct {
path string
wantTypes []string
}{
{path: "properties.action.anyOf", wantTypes: []string{"object", "null"}},
{path: "properties.label.oneOf", wantTypes: []string{"string", "null"}},
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"},
} {
union := result.Get(testCase.path)
if !union.IsArray() {
t.Errorf("response union %s was flattened: %s", testCase.path, result.Raw)
continue
}
var gotTypes []string
for _, branch := range union.Array() {
gotTypes = append(gotTypes, branch.Get("type").String())
}
if !reflect.DeepEqual(gotTypes, testCase.wantTypes) {
t.Errorf("response union %s types = %v, want %v: %s", testCase.path, gotTypes, testCase.wantTypes, result.Raw)
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)
}
}
}
@@ -978,8 +970,7 @@ func TestCleanJSONSchemaForAntigravity_MultipleFormats(t *testing.T) {
}
}
func TestCleanJSONSchemaForAntigravity_NumericEnumToString(t *testing.T) {
// Gemini API requires enum values to be strings, not numbers
func TestCleanJSONSchemaForAntigravity_ToolEnumsBecomeHints(t *testing.T) {
input := `{
"type": "object",
"properties": {
@@ -992,32 +983,23 @@ func TestCleanJSONSchemaForAntigravity_NumericEnumToString(t *testing.T) {
result := CleanJSONSchemaForAntigravity(input)
parsed := gjson.Parse(result)
// Tool enum schemas require both string values and a string type.
for _, path := range []string{"properties.priority", "properties.level"} {
if gotType := parsed.Get(path + ".type").String(); gotType != "string" {
t.Errorf("Tool enum type at %s = %q, want string: %s", path, gotType, 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)
}
}
// Numeric enum values should be converted to strings
if strings.Contains(result, `"enum":[0,1,2]`) {
t.Errorf("Integer enum values should be converted to strings, got: %s", result)
}
if strings.Contains(result, `"enum":[1.5,2.5,3.5]`) {
t.Errorf("Float enum values should be converted to strings, got: %s", result)
}
// Should contain string versions
if !strings.Contains(result, `"0"`) || !strings.Contains(result, `"1"`) || !strings.Contains(result, `"2"`) {
t.Errorf("Integer enum values should be converted to string format, got: %s", result)
}
// String enum values should remain unchanged
if !strings.Contains(result, `"active"`) || !strings.Contains(result, `"inactive"`) {
t.Errorf("String enum values should remain unchanged, got: %s", result)
}
}
func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) {
// Boolean enum values should also be converted to strings
func TestCleanJSONSchemaForAntigravity_BooleanToolEnumBecomesHint(t *testing.T) {
input := `{
"type": "object",
"properties": {
@@ -1027,13 +1009,9 @@ func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) {
result := CleanJSONSchemaForAntigravity(input)
// Boolean enum values should be converted to strings
if strings.Contains(result, `"enum":[true,false]`) {
t.Errorf("Boolean enum values should be converted to strings, got: %s", result)
}
// Should contain string versions "true" and "false"
if !strings.Contains(result, `"true"`) || !strings.Contains(result, `"false"`) {
t.Errorf("Boolean enum values should be converted to string format, got: %s", result)
value := gjson.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)
}
}
@@ -1450,3 +1428,107 @@ func TestCleanJSONSchema_ConditionalKeywords(t *testing.T) {
}
}
}
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 := gjson.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 := gjson.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 := gjson.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 := gjson.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 := gjson.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 := gjson.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)
}
}