fix(antigravity): preserve response and tool schema semantics
Unified · Split
| @@ -205,9 +205,8 @@ func sanitizeAntigravityToolSchemaDocument(payloadStr string, useAntigravitySche · 204 unmodified lines | ||
| 205 | 205 | payloadStr = renamed |
| 206 | 206 | } |
| 207 | 207 | |
| 208 | − toolSchemaCleaner := util.CleanJSONSchemaForGemini | |
| 209 | − if useAntigravitySchema { | |
| 210 | − toolSchemaCleaner = util.CleanJSONSchemaForAntigravity | |
| 208 | + toolSchemaCleaner := func(schema string) string { | |
| 209 | + return util.CleanJSONSchemaForAntigravityTool(schema, useAntigravitySchema) | |
| 211 | 210 | } |
| 212 | 211 | cleanNestedToolSchema := func(schemaRaw string) string { |
| 213 | 212 | return cleanNestedSchema(toolSchemaCleaner, schemaRaw) |
| @@ -222,10 +222,7 @@ func TestSanitizeAntigravityRequestSchemasMatchesWholePayloadCleaning(t *testing · 221 unmodified lines | ||
| 222 | 222 | for _, useAntigravitySchema := range []bool{false, true} { |
| 223 | 223 | for name, schema := range shapes { |
| 224 | 224 | doc := `{"request":{"tools":[{"functionDeclarations":[{"name":"t","parameters":` + schema + `}]}]}}` |
| 225 | − whole := util.CleanJSONSchemaForGemini(doc) | |
| 226 | − if useAntigravitySchema { | |
| 227 | − whole = util.CleanJSONSchemaForAntigravity(doc) | |
| 228 | − } | |
| 225 | + whole := util.CleanJSONSchemaForAntigravityTool(doc, useAntigravitySchema) | |
| 229 | 226 | want := gjson.Get(whole, schemaPath).Raw |
| 230 | 227 | got := gjson.Get(sanitizeAntigravityRequestSchemas(doc, useAntigravitySchema), schemaPath).Raw |
| 231 | 228 | if want != got { |
| @@ -274,7 +271,7 @@ func TestSanitizeAntigravityRequestSchemasKeepsResponseSchemasPlaceholderFree(t · 42 unmodified lines | ||
| 274 | 271 | } |
| 275 | 272 | } |
| 276 | 273 | |
| 277 | −func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *testing.T) { | |
| 274 | +func TestSanitizeAntigravityRequestSchemasProjectsUnionsAndPreservesEnumTypes(t *testing.T) { | |
| 278 | 275 | payload := `{"request":{ |
| 279 | 276 | "tools":[{"functionDeclarations":[{"name":"tool","parameters":{"type":"object","properties":{ |
| 280 | 277 | "choice":{"anyOf":[{"type":"string"},{"type":"null"}]}, |
| @@ -291,9 +288,9 @@ func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *t · 10 unmodified lines | ||
| 291 | 288 | |
| 292 | 289 | got := sanitizeAntigravityRequestSchemas(payload, true) |
| 293 | 290 | responseSchema := gjson.Get(got, "request.generationConfig.responseSchema") |
| 294 | − union := responseSchema.Get("properties.action.anyOf") | |
| 295 | − if !union.IsArray() || len(union.Array()) != 2 || union.Get("1.type").String() != "null" { | |
| 296 | − t.Errorf("response anyOf union was flattened: %s", responseSchema.Raw) | |
| 291 | + action := responseSchema.Get("properties.action") | |
| 292 | + if action.Get("anyOf").Exists() || action.Get("type").String() != "object" || !action.Get("nullable").Bool() { | |
| 293 | + t.Errorf("response anyOf was not projected to nullable object: %s", responseSchema.Raw) | |
| 297 | 294 | } |
| 298 | 295 | conviction := responseSchema.Get("properties.conviction") |
| 299 | 296 | if gotType := conviction.Get("type").String(); gotType != "number" { |
| @@ -309,8 +306,35 @@ func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *t · 9 unmodified lines | ||
| 309 | 306 | if toolSchema.Get("properties.choice.anyOf").Exists() { |
| 310 | 307 | t.Errorf("tool anyOf union was not flattened: %s", toolSchema.Raw) |
| 311 | 308 | } |
| 312 | − if gotType := toolSchema.Get("properties.level.type").String(); gotType != "string" { | |
| 313 | − t.Errorf("tool enum type = %q, want string: %s", gotType, toolSchema.Raw) | |
| 309 | + if gotType := toolSchema.Get("properties.level.type").String(); gotType != "number" { | |
| 310 | + t.Errorf("tool enum type = %q, want number: %s", gotType, toolSchema.Raw) | |
| 311 | + } | |
| 312 | +} | |
| 313 | + | |
| 314 | +func TestSanitizeAntigravityToolSchemasKeepNativeTypeAndNullableOnBothPaths(t *testing.T) { | |
| 315 | + payload := `{"request":{"tools":[{"functionDeclarations":[{"name":"tool","parameters":{ | |
| 316 | + "type":"object", | |
| 317 | + "properties":{ | |
| 318 | + "level":{"type":"number","enum":[1,2]}, | |
| 319 | + "note":{"type":["string","null"]} | |
| 320 | + }, | |
| 321 | + "required":["level","note"] | |
| 322 | + }}]}]}}` | |
| 323 | + | |
| 324 | + for _, requirePlaceholder := range []bool{false, true} { | |
| 325 | + got := sanitizeAntigravityRequestSchemas(payload, requirePlaceholder) | |
| 326 | + schema := gjson.Get(got, "request.tools.0.functionDeclarations.0.parameters") | |
| 327 | + if schema.Get("properties.level.type").String() != "number" { | |
| 328 | + t.Fatalf("placeholder=%v changed numeric tool argument type: %s", requirePlaceholder, schema.Raw) | |
| 329 | + } | |
| 330 | + for _, member := range schema.Get("properties.level.enum").Array() { | |
| 331 | + if member.Type != gjson.String { | |
| 332 | + t.Fatalf("placeholder=%v left non-string proto enum: %s", requirePlaceholder, schema.Raw) | |
| 333 | + } | |
| 334 | + } | |
| 335 | + if !schema.Get("properties.note.nullable").Bool() || schema.Get("required.1").String() != "note" { | |
| 336 | + t.Fatalf("placeholder=%v lost native nullable/required semantics: %s", requirePlaceholder, schema.Raw) | |
| 337 | + } | |
| 314 | 338 | } |
| 315 | 339 | } |
| 316 | 340 | |
| @@ -2,6 +2,7 @@ · 1 unmodified line | ||
| 2 | 2 | package util |
| 3 | 3 | |
| 4 | 4 | import ( |
| 5 | + "encoding/json" | |
| 5 | 6 | "fmt" |
| 6 | 7 | "sort" |
| 7 | 8 | "strconv" |
| @@ -26,9 +27,13 @@ const placeholderReasonDescription = "Brief explanation of why you are calling t · 18 unmodified lines | ||
| 26 | 27 | |
| 27 | 28 | type jsonSchemaCleanOptions struct { |
| 28 | 29 | addPlaceholder bool |
| 30 | + antigravitySemantics bool | |
| 31 | + removeToolTitle bool | |
| 29 | 32 | removeGeminiMetadata bool |
| 30 | 33 | flattenUnions bool |
| 31 | 34 | forceEnumStringType bool |
| 35 | + dropAllEnums bool | |
| 36 | + dropBooleanEnums bool | |
| 32 | 37 | preserveAdditionalPropertiesFalse bool |
| 33 | 38 | } |
| 34 | 39 | |
| @@ -36,10 +41,20 @@ type jsonSchemaCleanOptions struct { · 1 unmodified line | ||
| 36 | 41 | // It handles unsupported keywords, type flattening, and schema simplification while preserving |
| 37 | 42 | // semantic information as description hints and adding placeholders required by VALIDATED mode. |
| 38 | 43 | func CleanJSONSchemaForAntigravity(jsonStr string) string { |
| 44 | + return CleanJSONSchemaForAntigravityTool(jsonStr, true) | |
| 45 | +} | |
| 46 | + | |
| 47 | +// CleanJSONSchemaForAntigravityTool transforms an Antigravity function schema. The private | |
| 48 | +// backend accepts enum members only as strings, but the declared type still controls the JSON | |
| 49 | +// type of generated function arguments, so numeric and boolean types must not be rewritten. | |
| 50 | +// requirePlaceholder is used only for Claude VALIDATED mode. | |
| 51 | +func CleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder bool) string { | |
| 39 | 52 | return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ |
| 40 | − addPlaceholder: true, | |
| 41 | − flattenUnions: true, | |
| 42 | − forceEnumStringType: true, | |
| 53 | + addPlaceholder: requirePlaceholder, | |
| 54 | + antigravitySemantics: true, | |
| 55 | + removeToolTitle: !requirePlaceholder, | |
| 56 | + flattenUnions: true, | |
| 57 | + dropAllEnums: true, | |
| 43 | 58 | }) |
| 44 | 59 | } |
| 45 | 60 | |
| @@ -47,16 +62,19 @@ func CleanJSONSchemaForAntigravity(jsonStr string) string { · 1 unmodified line | ||
| 47 | 62 | // compatibility rewrites that would alter the client's structured output contract. |
| 48 | 63 | // |
| 49 | 64 | // Sanitization policy: |
| 50 | −// - Passthrough: type, properties, items, required, description, enum, union structures (anyOf/oneOf), | |
| 51 | −// and additionalProperties: false (which Antigravity natively enforces as a closed-object constraint). | |
| 52 | −// - Description hints + deletion: unsupported constraints (minLength, maxLength, pattern, minItems, | |
| 53 | −// maxItems, uniqueItems, format, default, examples). | |
| 65 | +// - Passthrough: type, properties, items, required, description, enum, nullable, and | |
| 66 | +// additionalProperties: false (which Antigravity natively enforces for response schemas). | |
| 67 | +// - Description hints + deletion: unsupported or accepted-but-ignored constraints. | |
| 54 | 68 | // - Flattened: allOf merged into properties/required. |
| 55 | −// - Dropped: $schema, $defs, definitions, const (converted to enum first), $ref (converted to hint), | |
| 56 | −// $id, propertyNames, patternProperties, conditional schemas (if/then/else), non-false additionalProperties, | |
| 57 | −// and x-* extensions. | |
| 69 | +// - Projected: anyOf/oneOf select the strongest branch; null branches become nullable:true. | |
| 70 | +// - Resolved: local $ref targets are inlined before $defs/definitions are removed. | |
| 71 | +// - Dropped: unresolved $ref (after a hint), metadata, unsupported object-key constraints, | |
| 72 | +// conditional keywords (after non-conflicting properties are retained), and x-* extensions. | |
| 58 | 73 | func CleanJSONSchemaForAntigravityResponse(jsonStr string) string { |
| 59 | 74 | return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ |
| 75 | + antigravitySemantics: true, | |
| 76 | + flattenUnions: true, | |
| 77 | + dropBooleanEnums: true, | |
| 60 | 78 | preserveAdditionalPropertiesFalse: true, |
| 61 | 79 | }) |
| 62 | 80 | } |
| @@ -74,14 +92,21 @@ func CleanJSONSchemaForGemini(jsonStr string) string { · 11 unmodified lines | ||
| 74 | 92 | // cleanJSONSchema performs the core cleaning operations on the JSON schema. |
| 75 | 93 | func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { |
| 76 | 94 | // Phase 1: Convert and add hints |
| 77 | − jsonStr = convertRefsToHints(jsonStr) | |
| 95 | + if options.antigravitySemantics { | |
| 96 | + jsonStr = inlineLocalRefs(jsonStr) | |
| 97 | + } | |
| 98 | + jsonStr = convertRefsToHints(jsonStr, options.antigravitySemantics) | |
| 78 | 99 | jsonStr = convertConstToEnum(jsonStr) |
| 79 | 100 | jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType) |
| 80 | 101 | jsonStr = addEnumHints(jsonStr) |
| 102 | + jsonStr = dropIgnoredEnumsToHints(jsonStr, options) | |
| 81 | 103 | if !options.preserveAdditionalPropertiesFalse { |
| 82 | 104 | jsonStr = addAdditionalPropertiesHints(jsonStr) |
| 83 | 105 | } |
| 84 | − jsonStr = moveConstraintsToDescription(jsonStr) | |
| 106 | + jsonStr = moveConstraintsToDescription(jsonStr, options) | |
| 107 | + if options.antigravitySemantics { | |
| 108 | + jsonStr = moveNotToDescription(jsonStr) | |
| 109 | + } | |
| 85 | 110 | |
| 86 | 111 | // Phase 2: Flatten complex structures |
| 87 | 112 | jsonStr = mergeConditionals(jsonStr) |
| @@ -89,7 +114,7 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { · 1 unmodified line | ||
| 89 | 114 | if options.flattenUnions { |
| 90 | 115 | jsonStr = flattenAnyOfOneOf(jsonStr) |
| 91 | 116 | } |
| 92 | − jsonStr = flattenTypeArrays(jsonStr) | |
| 117 | + jsonStr = flattenTypeArrays(jsonStr, options.antigravitySemantics) | |
| 93 | 118 | |
| 94 | 119 | // Phase 3: Cleanup |
| 95 | 120 | jsonStr = removeUnsupportedKeywords(jsonStr, options) |
| @@ -97,6 +122,10 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { · 1 unmodified line | ||
| 97 | 122 | // Gemini schema cleanup: remove nullable/title and placeholder-only fields. |
| 98 | 123 | jsonStr = removeKeywords(jsonStr, []string{"nullable", "title"}) |
| 99 | 124 | jsonStr = removePlaceholderFields(jsonStr) |
| 125 | + } else if options.removeToolTitle { | |
| 126 | + // Legacy non-VALIDATED Antigravity requests used the Gemini cleaner, which drops title. | |
| 127 | + // Keep that harmless metadata policy without losing Antigravity's native nullable support. | |
| 128 | + jsonStr = removeKeywords(jsonStr, []string{"title"}) | |
| 100 | 129 | } |
| 101 | 130 | jsonStr = cleanupRequiredFields(jsonStr) |
| 102 | 131 | // Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement) |
| @@ -193,28 +222,151 @@ func removePlaceholderFields(jsonStr string) string { · 90 unmodified lines | ||
| 193 | 222 | return jsonStr |
| 194 | 223 | } |
| 195 | 224 | |
| 196 | −// convertRefsToHints converts $ref to description hints (Lazy Hint strategy). | |
| 197 | −func convertRefsToHints(jsonStr string) string { | |
| 225 | +// inlineLocalRefs resolves JSON Pointer references against the original schema before definition | |
| 226 | +// containers are stripped. Each expansion receives its own copy, sibling keywords override the | |
| 227 | +// referenced definition, and cycles terminate as a typed hint instead of recursing forever. | |
| 228 | +func inlineLocalRefs(jsonStr string) string { | |
| 229 | + if !strings.Contains(jsonStr, `"$ref"`) { | |
| 230 | + return jsonStr | |
| 231 | + } | |
| 232 | + | |
| 233 | + decoder := json.NewDecoder(strings.NewReader(jsonStr)) | |
| 234 | + decoder.UseNumber() | |
| 235 | + var root any | |
| 236 | + if err := decoder.Decode(&root); err != nil { | |
| 237 | + return jsonStr | |
| 238 | + } | |
| 239 | + | |
| 240 | + resolved := resolveLocalRefs(root, root, make(map[string]bool)) | |
| 241 | + out, err := json.Marshal(resolved) | |
| 242 | + if err != nil { | |
| 243 | + return jsonStr | |
| 244 | + } | |
| 245 | + return string(out) | |
| 246 | +} | |
| 247 | + | |
| 248 | +func resolveLocalRefs(root, value any, active map[string]bool) any { | |
| 249 | + switch node := value.(type) { | |
| 250 | + case []any: | |
| 251 | + out := make([]any, len(node)) | |
| 252 | + for i, item := range node { | |
| 253 | + out[i] = resolveLocalRefs(root, item, active) | |
| 254 | + } | |
| 255 | + return out | |
| 256 | + case map[string]any: | |
| 257 | + ref, hasRef := node["$ref"].(string) | |
| 258 | + if hasRef && strings.HasPrefix(ref, "#/") { | |
| 259 | + if target, ok := resolveJSONPointer(root, ref); ok { | |
| 260 | + if active[ref] { | |
| 261 | + return cyclicRefFallback(node, target, ref) | |
| 262 | + } | |
| 263 | + active[ref] = true | |
| 264 | + resolvedTarget := resolveLocalRefs(root, target, active) | |
| 265 | + delete(active, ref) | |
| 266 | + if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget { | |
| 267 | + out := make(map[string]any, len(targetMap)+len(node)) | |
| 268 | + for key, item := range targetMap { | |
| 269 | + out[key] = item | |
| 270 | + } | |
| 271 | + for key, item := range node { | |
| 272 | + if key == "$ref" { | |
| 273 | + continue | |
| 274 | + } | |
| 275 | + out[key] = resolveLocalRefs(root, item, active) | |
| 276 | + } | |
| 277 | + return out | |
| 278 | + } | |
| 279 | + } | |
| 280 | + } | |
| 281 | + | |
| 282 | + out := make(map[string]any, len(node)) | |
| 283 | + for key, item := range node { | |
| 284 | + out[key] = resolveLocalRefs(root, item, active) | |
| 285 | + } | |
| 286 | + return out | |
| 287 | + default: | |
| 288 | + return value | |
| 289 | + } | |
| 290 | +} | |
| 291 | + | |
| 292 | +func resolveJSONPointer(root any, ref string) (any, bool) { | |
| 293 | + current := root | |
| 294 | + for _, rawPart := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { | |
| 295 | + part := strings.ReplaceAll(strings.ReplaceAll(rawPart, "~1", "/"), "~0", "~") | |
| 296 | + switch node := current.(type) { | |
| 297 | + case map[string]any: | |
| 298 | + var ok bool | |
| 299 | + current, ok = node[part] | |
| 300 | + if !ok { | |
| 301 | + return nil, false | |
| 302 | + } | |
| 303 | + case []any: | |
| 304 | + index, err := strconv.Atoi(part) | |
| 305 | + if err != nil || index < 0 || index >= len(node) { | |
| 306 | + return nil, false | |
| 307 | + } | |
| 308 | + current = node[index] | |
| 309 | + default: | |
| 310 | + return nil, false | |
| 311 | + } | |
| 312 | + } | |
| 313 | + return current, true | |
| 314 | +} | |
| 315 | + | |
| 316 | +func cyclicRefFallback(node map[string]any, target any, ref string) map[string]any { | |
| 317 | + out := make(map[string]any, len(node)+2) | |
| 318 | + if targetMap, ok := target.(map[string]any); ok { | |
| 319 | + for _, key := range []string{"type", "nullable", "description"} { | |
| 320 | + if value, exists := targetMap[key]; exists { | |
| 321 | + out[key] = value | |
| 322 | + } | |
| 323 | + } | |
| 324 | + } | |
| 325 | + for key, value := range node { | |
| 326 | + if key != "$ref" { | |
| 327 | + out[key] = value | |
| 328 | + } | |
| 329 | + } | |
| 330 | + name := refName(ref) | |
| 331 | + hint := "See: " + name | |
| 332 | + if description, _ := out["description"].(string); description != "" { | |
| 333 | + out["description"] = mergeHint(description, hint) | |
| 334 | + } else { | |
| 335 | + out["description"] = hint | |
| 336 | + } | |
| 337 | + return out | |
| 338 | +} | |
| 339 | + | |
| 340 | +func refName(ref string) string { | |
| 341 | + if index := strings.LastIndex(ref, "/"); index >= 0 && index+1 < len(ref) { | |
| 342 | + return strings.ReplaceAll(strings.ReplaceAll(ref[index+1:], "~1", "/"), "~0", "~") | |
| 343 | + } | |
| 344 | + return ref | |
| 345 | +} | |
| 346 | + | |
| 347 | +// convertRefsToHints retains sibling keywords and converts only unresolved or external references | |
| 348 | +// to descriptions. Local references have already been expanded by inlineLocalRefs. | |
| 349 | +func convertRefsToHints(jsonStr string, preserveSiblings bool) string { | |
| 198 | 350 | paths := findPaths(jsonStr, "$ref") |
| 199 | 351 | sortByDepth(paths) |
| 200 | 352 | |
| 201 | 353 | for _, p := range paths { |
| 202 | 354 | refVal := gjson.Get(jsonStr, p).String() |
| 203 | − defName := refVal | |
| 204 | − if idx := strings.LastIndex(refVal, "/"); idx >= 0 { | |
| 205 | − defName = refVal[idx+1:] | |
| 206 | − } | |
| 355 | + defName := refName(refVal) | |
| 207 | 356 | |
| 208 | 357 | parentPath := trimSuffix(p, ".$ref") |
| 209 | 358 | hint := fmt.Sprintf("See: %s", defName) |
| 210 | − if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" { | |
| 211 | − hint = fmt.Sprintf("%s (%s)", existing, hint) | |
| 359 | + if !preserveSiblings { | |
| 360 | + if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" { | |
| 361 | + hint = fmt.Sprintf("%s (%s)", existing, hint) | |
| 362 | + } | |
| 363 | + replacement := `{"type":"object","description":""}` | |
| 364 | + replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint) | |
| 365 | + jsonStr = setRawAt(jsonStr, parentPath, string(replacementBytes)) | |
| 366 | + continue | |
| 212 | 367 | } |
| 213 | − | |
| 214 | − replacement := `{"type":"object","description":""}` | |
| 215 | − replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint) | |
| 216 | − replacement = string(replacementBytes) | |
| 217 | − jsonStr = setRawAt(jsonStr, parentPath, replacement) | |
| 368 | + jsonStr, _ = sjson.Delete(jsonStr, p) | |
| 369 | + jsonStr = appendHint(jsonStr, parentPath, hint) | |
| 218 | 370 | } |
| 219 | 371 | return jsonStr |
| 220 | 372 | } |
| @@ -235,8 +387,8 @@ func convertConstToEnum(jsonStr string) string { · 14 unmodified lines | ||
| 235 | 387 | } |
| 236 | 388 | |
| 237 | 389 | // convertEnumValuesToStrings ensures all enum values use the string representation required by |
| 238 | −// Gemini's proto schema. Tool schemas also require a string type, while response schemas preserve | |
| 239 | −// their declared type because the upstream decoder uses it to select the emitted JSON value type. | |
| 390 | +// Gemini's proto schema. The declared type remains independent: Antigravity uses it to choose the | |
| 391 | +// emitted JSON type on both response and function-argument paths. | |
| 240 | 392 | func convertEnumValuesToStrings(jsonStr string, forceStringType bool) string { |
| 241 | 393 | for _, p := range findPaths(jsonStr, "enum") { |
| 242 | 394 | arr := gjson.Get(jsonStr, p) |
| @@ -280,6 +432,25 @@ func addEnumHints(jsonStr string) string { · 37 unmodified lines | ||
| 280 | 432 | return jsonStr |
| 281 | 433 | } |
| 282 | 434 | |
| 435 | +// Antigravity does not enforce enum on function arguments and ignores boolean response enums. | |
| 436 | +// Preserve the advisory values in description, but do not leave an unenforced constraint in the | |
| 437 | +// schema contract. Response enums for string, number, and integer remain native constraints. | |
| 438 | +func dropIgnoredEnumsToHints(jsonStr string, options jsonSchemaCleanOptions) string { | |
| 439 | + for _, path := range findPaths(jsonStr, "enum") { | |
| 440 | + parentPath := trimSuffix(path, ".enum") | |
| 441 | + shouldDrop := options.dropAllEnums || (options.dropBooleanEnums && gjson.Get(jsonStr, joinPath(parentPath, "type")).String() == "boolean") | |
| 442 | + if !shouldDrop { | |
| 443 | + continue | |
| 444 | + } | |
| 445 | + enum := gjson.Get(jsonStr, path) | |
| 446 | + if enum.IsArray() && len(enum.Array()) == 1 { | |
| 447 | + jsonStr = appendHint(jsonStr, parentPath, "Allowed: "+enum.Array()[0].String()) | |
| 448 | + } | |
| 449 | + jsonStr, _ = sjson.Delete(jsonStr, path) | |
| 450 | + } | |
| 451 | + return jsonStr | |
| 452 | +} | |
| 453 | + | |
| 283 | 454 | func addAdditionalPropertiesHints(jsonStr string) string { |
| 284 | 455 | for _, p := range findPaths(jsonStr, "additionalProperties") { |
| 285 | 456 | if gjson.Get(jsonStr, p).Type == gjson.False { |
| @@ -295,9 +466,18 @@ var unsupportedConstraints = []string{ · 9 unmodified lines | ||
| 295 | 466 | "default", "examples", // Claude rejects these in VALIDATED mode |
| 296 | 467 | } |
| 297 | 468 | |
| 298 | −func moveConstraintsToDescription(jsonStr string) string { | |
| 299 | − pathsByField := findPathsByFields(jsonStr, unsupportedConstraints) | |
| 300 | − for _, key := range unsupportedConstraints { | |
| 469 | +func constraintKeywords(options jsonSchemaCleanOptions) []string { | |
| 470 | + keywords := append([]string(nil), unsupportedConstraints...) | |
| 471 | + if options.antigravitySemantics { | |
| 472 | + keywords = append(keywords, "minimum", "maximum", "multipleOf") | |
| 473 | + } | |
| 474 | + return keywords | |
| 475 | +} | |
| 476 | + | |
| 477 | +func moveConstraintsToDescription(jsonStr string, options jsonSchemaCleanOptions) string { | |
| 478 | + constraints := constraintKeywords(options) | |
| 479 | + pathsByField := findPathsByFields(jsonStr, constraints) | |
| 480 | + for _, key := range constraints { | |
| 301 | 481 | for _, p := range pathsByField[key] { |
| 302 | 482 | val := gjson.Get(jsonStr, p) |
| 303 | 483 | if !val.Exists() || val.IsObject() || val.IsArray() { |
| @@ -313,6 +493,17 @@ func moveConstraintsToDescription(jsonStr string) string { · 9 unmodified lines | ||
| 313 | 493 | return jsonStr |
| 314 | 494 | } |
| 315 | 495 | |
| 496 | +func moveNotToDescription(jsonStr string) string { | |
| 497 | + for _, path := range findPaths(jsonStr, "not") { | |
| 498 | + value := gjson.Get(jsonStr, path) | |
| 499 | + if !value.Exists() || isPropertyDefinition(trimSuffix(path, ".not")) { | |
| 500 | + continue | |
| 501 | + } | |
| 502 | + jsonStr = appendHint(jsonStr, trimSuffix(path, ".not"), "not: "+value.Raw) | |
| 503 | + } | |
| 504 | + return jsonStr | |
| 505 | +} | |
| 506 | + | |
| 316 | 507 | func mergeConditionals(jsonStr string) string { |
| 317 | 508 | pathsByField := findPathsByFields(jsonStr, []string{"then", "else"}) |
| 318 | 509 | var paths []string |
| @@ -367,31 +558,59 @@ func mergeAllOf(jsonStr string) string { · 48 unmodified lines | ||
| 367 | 558 | parentPath := trimSuffix(p, ".allOf") |
| 368 | 559 | |
| 369 | 560 | for _, item := range allOf.Array() { |
| 370 | − if props := item.Get("properties"); props.IsObject() { | |
| 371 | − props.ForEach(func(key, value gjson.Result) bool { | |
| 372 | − destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(key.String())) | |
| 373 | − updated, _ := sjson.SetRawBytes([]byte(jsonStr), destPath, []byte(value.Raw)) | |
| 374 | − jsonStr = string(updated) | |
| 375 | − return true | |
| 376 | − }) | |
| 377 | − } | |
| 378 | − if req := item.Get("required"); req.IsArray() { | |
| 379 | − reqPath := joinPath(parentPath, "required") | |
| 380 | − current := getStrings(jsonStr, reqPath) | |
| 381 | − for _, r := range req.Array() { | |
| 382 | − if s := r.String(); !contains(current, s) { | |
| 383 | − current = append(current, s) | |
| 561 | + if !item.IsObject() { | |
| 562 | + continue | |
| 563 | + } | |
| 564 | + item.ForEach(func(key, value gjson.Result) bool { | |
| 565 | + field := key.String() | |
| 566 | + switch field { | |
| 567 | + case "required": | |
| 568 | + if !value.IsArray() { | |
| 569 | + return true | |
| 570 | + } | |
| 571 | + reqPath := joinPath(parentPath, "required") | |
| 572 | + current := getStrings(jsonStr, reqPath) | |
| 573 | + for _, required := range value.Array() { | |
| 574 | + if name := required.String(); !contains(current, name) { | |
| 575 | + current = append(current, name) | |
| 576 | + } | |
| 384 | 577 | } |
| 578 | + updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, current) | |
| 579 | + jsonStr = string(updated) | |
| 580 | + case "if", "then", "else", "allOf": | |
| 581 | + // Conditional applicability cannot be represented by the upstream schema. | |
| 582 | + default: | |
| 583 | + destination := joinPath(parentPath, escapeGJSONPathKey(field)) | |
| 584 | + jsonStr = mergeMissingSchemaAtPath(jsonStr, destination, value) | |
| 385 | 585 | } |
| 386 | − updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, current) | |
| 387 | − jsonStr = string(updated) | |
| 388 | − } | |
| 586 | + return true | |
| 587 | + }) | |
| 389 | 588 | } |
| 390 | 589 | jsonStr, _ = sjson.Delete(jsonStr, p) |
| 391 | 590 | } |
| 392 | 591 | return jsonStr |
| 393 | 592 | } |
| 394 | 593 | |
| 594 | +// mergeMissingSchemaAtPath recursively fills absent fields without replacing any existing | |
| 595 | +// definition. A parent schema is the canonical definition; allOf and conditional branches may | |
| 596 | +// enrich gaps in it, but can never replace it with a narrower branch shell. | |
| 597 | +func mergeMissingSchemaAtPath(jsonStr, destination string, incoming gjson.Result) string { | |
| 598 | + existing := gjson.Get(jsonStr, destination) | |
| 599 | + if !existing.Exists() { | |
| 600 | + updated, _ := sjson.SetRawBytes([]byte(jsonStr), destination, []byte(incoming.Raw)) | |
| 601 | + return string(updated) | |
| 602 | + } | |
| 603 | + if !existing.IsObject() || !incoming.IsObject() { | |
| 604 | + return jsonStr | |
| 605 | + } | |
| 606 | + incoming.ForEach(func(key, value gjson.Result) bool { | |
| 607 | + child := joinPath(destination, escapeGJSONPathKey(key.String())) | |
| 608 | + jsonStr = mergeMissingSchemaAtPath(jsonStr, child, value) | |
| 609 | + return true | |
| 610 | + }) | |
| 611 | + return jsonStr | |
| 612 | +} | |
| 613 | + | |
| 395 | 614 | func flattenAnyOfOneOf(jsonStr string) string { |
| 396 | 615 | for _, key := range []string{"anyOf", "oneOf"} { |
| 397 | 616 | paths := findPaths(jsonStr, key) |
| @@ -409,6 +628,17 @@ func flattenAnyOfOneOf(jsonStr string) string { · 11 unmodified lines | ||
| 409 | 628 | items := arr.Array() |
| 410 | 629 | bestIdx, allTypes := selectBest(items) |
| 411 | 630 | selected := items[bestIdx].Raw |
| 631 | + hasNull := false | |
| 632 | + for _, item := range items { | |
| 633 | + if item.Get("type").String() == "null" { | |
| 634 | + hasNull = true | |
| 635 | + break | |
| 636 | + } | |
| 637 | + } | |
| 638 | + if hasNull && items[bestIdx].Get("type").String() != "null" { | |
| 639 | + updated, _ := sjson.SetBytes([]byte(selected), "nullable", true) | |
| 640 | + selected = string(updated) | |
| 641 | + } | |
| 412 | 642 | |
| 413 | 643 | if parentDesc != "" { |
| 414 | 644 | selected = mergeDescriptionRaw(selected, parentDesc) |
| @@ -452,7 +682,7 @@ func selectBest(items []gjson.Result) (bestIdx int, types []string) { · 37 unmodified lines | ||
| 452 | 682 | return |
| 453 | 683 | } |
| 454 | 684 | |
| 455 | −func flattenTypeArrays(jsonStr string) string { | |
| 685 | +func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string { | |
| 456 | 686 | paths := findPaths(jsonStr, "type") |
| 457 | 687 | sortByDepth(paths) |
| 458 | 688 | |
| @@ -490,15 +720,20 @@ func flattenTypeArrays(jsonStr string) string { · 31 unmodified lines | ||
| 490 | 720 | } |
| 491 | 721 | |
| 492 | 722 | if hasNull { |
| 723 | + if preserveNativeNullable { | |
| 724 | + updated, _ = sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true) | |
| 725 | + jsonStr = string(updated) | |
| 726 | + jsonStr = appendHint(jsonStr, parentPath, "(nullable)") | |
| 727 | + continue | |
| 728 | + } | |
| 729 | + | |
| 493 | 730 | parts := splitGJSONPath(p) |
| 494 | 731 | if len(parts) >= 3 && parts[len(parts)-3] == "properties" { |
| 495 | 732 | fieldNameEscaped := parts[len(parts)-2] |
| 496 | 733 | fieldName := unescapeGJSONPathKey(fieldNameEscaped) |
| 497 | 734 | objectPath := strings.Join(parts[:len(parts)-3], ".") |
| 498 | 735 | nullableFields[objectPath] = append(nullableFields[objectPath], fieldName) |
| 499 | − | |
| 500 | − propPath := joinPath(objectPath, "properties."+fieldNameEscaped) | |
| 501 | − jsonStr = appendHint(jsonStr, propPath, "(nullable)") | |
| 736 | + jsonStr = appendHint(jsonStr, joinPath(objectPath, "properties."+fieldNameEscaped), "(nullable)") | |
| 502 | 737 | } |
| 503 | 738 | } |
| 504 | 739 | } |
| @@ -511,12 +746,11 @@ func flattenTypeArrays(jsonStr string) string { · 6 unmodified lines | ||
| 511 | 746 | } |
| 512 | 747 | |
| 513 | 748 | var filtered []string |
| 514 | − for _, r := range req.Array() { | |
| 515 | − if !contains(fields, r.String()) { | |
| 516 | − filtered = append(filtered, r.String()) | |
| 749 | + for _, required := range req.Array() { | |
| 750 | + if !contains(fields, required.String()) { | |
| 751 | + filtered = append(filtered, required.String()) | |
| 517 | 752 | } |
| 518 | 753 | } |
| 519 | − | |
| 520 | 754 | if len(filtered) == 0 { |
| 521 | 755 | jsonStr, _ = sjson.Delete(jsonStr, reqPath) |
| 522 | 756 | } else { |
| @@ -528,12 +762,15 @@ func flattenTypeArrays(jsonStr string) string { · 5 unmodified lines | ||
| 528 | 762 | } |
| 529 | 763 | |
| 530 | 764 | func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) string { |
| 531 | − keywords := append(unsupportedConstraints, | |
| 765 | + keywords := append(constraintKeywords(options), | |
| 532 | 766 | "$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties", |
| 533 | 767 | "propertyNames", "patternProperties", // Gemini doesn't support these schema keywords |
| 534 | 768 | "if", "then", "else", |
| 535 | 769 | "$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", // Schema metadata fields unsupported by Gemini |
| 536 | 770 | ) |
| 771 | + if options.antigravitySemantics { | |
| 772 | + keywords = append(keywords, "not") | |
| 773 | + } | |
| 537 | 774 | |
| 538 | 775 | deletePaths := make([]string, 0) |
| 539 | 776 | pathsByField := findPathsByFields(jsonStr, keywords) |
| @@ -746,7 +983,9 @@ func walkForFields(value gjson.Result, path string, fields map[string]struct{}, · 206 unmodified lines | ||
| 746 | 983 | } |
| 747 | 984 | |
| 748 | 985 | func sortByDepth(paths []string) { |
| 749 | − sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) }) | |
| 986 | + sort.SliceStable(paths, func(i, j int) bool { | |
| 987 | + return len(splitGJSONPath(paths[i])) > len(splitGJSONPath(paths[j])) | |
| 988 | + }) | |
| 750 | 989 | } |
| 751 | 990 | |
| 752 | 991 | func trimSuffix(path, suffix string) string { |
| @@ -25,7 +25,7 @@ func TestCleanJSONSchemaForAntigravity_ConstToEnum(t *testing.T) { · 24 unmodified lines | ||
| 25 | 25 | "properties": { |
| 26 | 26 | "kind": { |
| 27 | 27 | "type": "string", |
| 28 | − "enum": ["InsightVizNode"] | |
| 28 | + "description": "Allowed: InsightVizNode" | |
| 29 | 29 | } |
| 30 | 30 | } |
| 31 | 31 | }` |
| @@ -53,13 +53,14 @@ func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable(t *testing.T) { · 21 unmodified lines | ||
| 53 | 53 | "properties": { |
| 54 | 54 | "name": { |
| 55 | 55 | "type": "string", |
| 56 | + "nullable": true, | |
| 56 | 57 | "description": "(nullable)" |
| 57 | 58 | }, |
| 58 | 59 | "other": { |
| 59 | 60 | "type": "string" |
| 60 | 61 | } |
| 61 | 62 | }, |
| 62 | − "required": ["other"] | |
| 63 | + "required": ["name", "other"] | |
| 63 | 64 | }` |
| 64 | 65 | |
| 65 | 66 | result := CleanJSONSchemaForAntigravity(input) |
| @@ -125,6 +126,7 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_SmartSelection(t *testing · 59 unmodified lines | ||
| 125 | 126 | "properties": { |
| 126 | 127 | "query": { |
| 127 | 128 | "type": "object", |
| 129 | + "nullable": true, | |
| 128 | 130 | "description": "Accepts: null | object", |
| 129 | 131 | "properties": { |
| 130 | 132 | "_": { "type": "boolean" }, |
| @@ -214,20 +216,18 @@ func TestCleanJSONSchemaForAntigravity_RefHandling(t *testing.T) { · 83 unmodified lines | ||
| 214 | 216 | } |
| 215 | 217 | }` |
| 216 | 218 | |
| 217 | − // After $ref is converted to placeholder object, empty schema placeholder is also added | |
| 219 | + // The local reference is expanded before definitions are removed. Claude VALIDATED mode adds | |
| 220 | + // only its optional-object placeholder; the referenced property definition remains intact. | |
| 218 | 221 | expected := `{ |
| 219 | 222 | "type": "object", |
| 220 | 223 | "properties": { |
| 221 | 224 | "customer": { |
| 222 | 225 | "type": "object", |
| 223 | − "description": "See: User", | |
| 224 | 226 | "properties": { |
| 225 | − "reason": { | |
| 226 | − "type": "string", | |
| 227 | − "description": "Brief explanation of why you are calling this tool" | |
| 228 | − } | |
| 227 | + "name": { "type": "string" }, | |
| 228 | + "_": { "type": "boolean" } | |
| 229 | 229 | }, |
| 230 | − "required": ["reason"] | |
| 230 | + "required": ["_"] | |
| 231 | 231 | } |
| 232 | 232 | } |
| 233 | 233 | }` |
| @@ -255,20 +255,17 @@ func TestCleanJSONSchemaForAntigravity_RefHandling_DescriptionEscaping(t *testin · 21 unmodified lines | ||
| 255 | 255 | } |
| 256 | 256 | }` |
| 257 | 257 | |
| 258 | − // After $ref is converted, empty schema placeholder is also added | |
| 259 | 258 | expected := `{ |
| 260 | 259 | "type": "object", |
| 261 | 260 | "properties": { |
| 262 | 261 | "customer": { |
| 263 | 262 | "type": "object", |
| 264 | − "description": "He said \"hi\"\\nsecond line (See: User)", | |
| 263 | + "description": "He said \"hi\"\\nsecond line", | |
| 265 | 264 | "properties": { |
| 266 | − "reason": { | |
| 267 | − "type": "string", | |
| 268 | − "description": "Brief explanation of why you are calling this tool" | |
| 269 | − } | |
| 265 | + "name": { "type": "string" }, | |
| 266 | + "_": { "type": "boolean" } | |
| 270 | 267 | }, |
| 271 | − "required": ["reason"] | |
| 268 | + "required": ["_"] | |
| 272 | 269 | } |
| 273 | 270 | } |
| 274 | 271 | }` |
| @@ -299,9 +296,9 @@ func TestCleanJSONSchemaForAntigravity_CyclicRefDefaults(t *testing.T) { · 24 unmodified lines | ||
| 299 | 296 | t.Errorf("Expected type: object, got: %v", resMap["type"]) |
| 300 | 297 | } |
| 301 | 298 | |
| 302 | − desc, ok := resMap["description"].(string) | |
| 303 | − if !ok || !strings.Contains(desc, "Node") { | |
| 304 | − t.Errorf("Expected description hint containing 'Node', got: %v", resMap["description"]) | |
| 299 | + child := gjson.Get(result, "properties.child") | |
| 300 | + if child.Get("type").String() != "object" || !strings.Contains(child.Get("description").String(), "Node") { | |
| 301 | + t.Errorf("Expected typed cycle hint containing Node, got: %s", result) | |
| 305 | 302 | } |
| 306 | 303 | } |
| 307 | 304 | |
| @@ -499,13 +496,14 @@ func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable_DotKey(t *testing · 191 unmodified lines | ||
| 499 | 496 | "properties": { |
| 500 | 497 | "my.param": { |
| 501 | 498 | "type": "string", |
| 499 | + "nullable": true, | |
| 502 | 500 | "description": "(nullable)" |
| 503 | 501 | }, |
| 504 | 502 | "other": { |
| 505 | 503 | "type": "string" |
| 506 | 504 | } |
| 507 | 505 | }, |
| 508 | − "required": ["other"] | |
| 506 | + "required": ["my.param", "other"] | |
| 509 | 507 | }` |
| 510 | 508 | |
| 511 | 509 | result := CleanJSONSchemaForAntigravity(input) |
| @@ -578,7 +576,7 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *t · 66 unmodified lines | ||
| 578 | 576 | compareJSON(t, expected, result) |
| 579 | 577 | } |
| 580 | 578 | |
| 581 | −func TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) { | |
| 579 | +func TestCleanJSONSchemaForAntigravity_SingleEnumBecomesHint(t *testing.T) { | |
| 582 | 580 | input := `{ |
| 583 | 581 | "type": "object", |
| 584 | 582 | "properties": { |
| @@ -591,8 +589,8 @@ func TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) { · 6 unmodified lines | ||
| 591 | 589 | |
| 592 | 590 | result := CleanJSONSchemaForAntigravity(input) |
| 593 | 591 | |
| 594 | − if strings.Contains(result, "Allowed:") { | |
| 595 | − t.Errorf("Single value enum should not add Allowed hint, got: %s", result) | |
| 592 | + if !strings.Contains(result, "Allowed: fixed") || gjson.Get(result, "properties.kind.enum").Exists() { | |
| 593 | + t.Errorf("Ignored tool enum should become a hint, got: %s", result) | |
| 596 | 594 | } |
| 597 | 595 | } |
| 598 | 596 | |
| @@ -764,7 +762,7 @@ func TestCleanJSONSchemaForAntigravityResponseDoesNotAddToolPlaceholders(t *test · 165 unmodified lines | ||
| 764 | 762 | } |
| 765 | 763 | } |
| 766 | 764 | |
| 767 | −func TestCleanJSONSchemaForAntigravityResponsePreservesUnions(t *testing.T) { | |
| 765 | +func TestCleanJSONSchemaForAntigravityResponseProjectsIgnoredUnions(t *testing.T) { | |
| 768 | 766 | input := `{ |
| 769 | 767 | "type":"object", |
| 770 | 768 | "properties":{ |
| @@ -777,24 +775,18 @@ func TestCleanJSONSchemaForAntigravityResponsePreservesUnions(t *testing.T) { · 6 unmodified lines | ||
| 777 | 775 | }` |
| 778 | 776 | |
| 779 | 777 | result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) |
| 780 | − for _, testCase := range []struct { | |
| 781 | − path string | |
| 782 | − wantTypes []string | |
| 783 | − }{ | |
| 784 | − {path: "properties.action.anyOf", wantTypes: []string{"object", "null"}}, | |
| 785 | − {path: "properties.label.oneOf", wantTypes: []string{"string", "null"}}, | |
| 786 | − } { | |
| 787 | − union := result.Get(testCase.path) | |
| 788 | − if !union.IsArray() { | |
| 789 | − t.Errorf("response union %s was flattened: %s", testCase.path, result.Raw) | |
| 790 | − continue | |
| 791 | − } | |
| 792 | − var gotTypes []string | |
| 793 | − for _, branch := range union.Array() { | |
| 794 | − gotTypes = append(gotTypes, branch.Get("type").String()) | |
| 778 | + for _, path := range []string{"properties.action.anyOf", "properties.label.oneOf"} { | |
| 779 | + if result.Get(path).Exists() { | |
| 780 | + t.Errorf("ignored response union %s survived: %s", path, result.Raw) | |
| 795 | 781 | } |
| 796 | − if !reflect.DeepEqual(gotTypes, testCase.wantTypes) { | |
| 797 | − t.Errorf("response union %s types = %v, want %v: %s", testCase.path, gotTypes, testCase.wantTypes, result.Raw) | |
| 782 | + } | |
| 783 | + for _, testCase := range []struct{ path, wantType string }{ | |
| 784 | + {path: "properties.action", wantType: "object"}, | |
| 785 | + {path: "properties.label", wantType: "string"}, | |
| 786 | + } { | |
| 787 | + schema := result.Get(testCase.path) | |
| 788 | + if schema.Get("type").String() != testCase.wantType || !schema.Get("nullable").Bool() { | |
| 789 | + t.Errorf("%s was not projected to nullable %s: %s", testCase.path, testCase.wantType, result.Raw) | |
| 798 | 790 | } |
| 799 | 791 | } |
| 800 | 792 | } |
| @@ -978,8 +970,7 @@ func TestCleanJSONSchemaForAntigravity_MultipleFormats(t *testing.T) { · 177 unmodified lines | ||
| 978 | 970 | } |
| 979 | 971 | } |
| 980 | 972 | |
| 981 | −func TestCleanJSONSchemaForAntigravity_NumericEnumToString(t *testing.T) { | |
| 982 | − // Gemini API requires enum values to be strings, not numbers | |
| 973 | +func TestCleanJSONSchemaForAntigravity_ToolEnumsBecomeHints(t *testing.T) { | |
| 983 | 974 | input := `{ |
| 984 | 975 | "type": "object", |
| 985 | 976 | "properties": { |
| @@ -992,32 +983,23 @@ func TestCleanJSONSchemaForAntigravity_NumericEnumToString(t *testing.T) { · 6 unmodified lines | ||
| 992 | 983 | result := CleanJSONSchemaForAntigravity(input) |
| 993 | 984 | parsed := gjson.Parse(result) |
| 994 | 985 | |
| 995 | − // Tool enum schemas require both string values and a string type. | |
| 996 | − for _, path := range []string{"properties.priority", "properties.level"} { | |
| 997 | − if gotType := parsed.Get(path + ".type").String(); gotType != "string" { | |
| 998 | − t.Errorf("Tool enum type at %s = %q, want string: %s", path, gotType, result) | |
| 986 | + // Antigravity ignores function-argument enum but still uses the declared type to choose the | |
| 987 | + // emitted JSON type. Preserve types and convert enum values to advisory hints. | |
| 988 | + for path, wantType := range map[string]string{ | |
| 989 | + "properties.priority": "integer", | |
| 990 | + "properties.level": "number", | |
| 991 | + "properties.status": "string", | |
| 992 | + } { | |
| 993 | + if gotType := parsed.Get(path + ".type").String(); gotType != wantType { | |
| 994 | + t.Errorf("Tool enum type at %s = %q, want %s: %s", path, gotType, wantType, result) | |
| 995 | + } | |
| 996 | + if parsed.Get(path+".enum").Exists() || !strings.Contains(parsed.Get(path+".description").String(), "Allowed:") { | |
| 997 | + t.Errorf("Tool enum at %s was not projected to a hint: %s", path, result) | |
| 999 | 998 | } |
| 1000 | − } | |
| 1001 | − | |
| 1002 | − // Numeric enum values should be converted to strings | |
| 1003 | − if strings.Contains(result, `"enum":[0,1,2]`) { | |
| 1004 | − t.Errorf("Integer enum values should be converted to strings, got: %s", result) | |
| 1005 | − } | |
| 1006 | − if strings.Contains(result, `"enum":[1.5,2.5,3.5]`) { | |
| 1007 | − t.Errorf("Float enum values should be converted to strings, got: %s", result) | |
| 1008 | − } | |
| 1009 | − // Should contain string versions | |
| 1010 | − if !strings.Contains(result, `"0"`) || !strings.Contains(result, `"1"`) || !strings.Contains(result, `"2"`) { | |
| 1011 | − t.Errorf("Integer enum values should be converted to string format, got: %s", result) | |
| 1012 | − } | |
| 1013 | − // String enum values should remain unchanged | |
| 1014 | − if !strings.Contains(result, `"active"`) || !strings.Contains(result, `"inactive"`) { | |
| 1015 | − t.Errorf("String enum values should remain unchanged, got: %s", result) | |
| 1016 | 999 | } |
| 1017 | 1000 | } |
| 1018 | 1001 | |
| 1019 | −func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) { | |
| 1020 | − // Boolean enum values should also be converted to strings | |
| 1002 | +func TestCleanJSONSchemaForAntigravity_BooleanToolEnumBecomesHint(t *testing.T) { | |
| 1021 | 1003 | input := `{ |
| 1022 | 1004 | "type": "object", |
| 1023 | 1005 | "properties": { |
| @@ -1027,13 +1009,9 @@ func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) { · 3 unmodified lines | ||
| 1027 | 1009 | |
| 1028 | 1010 | result := CleanJSONSchemaForAntigravity(input) |
| 1029 | 1011 | |
| 1030 | − // Boolean enum values should be converted to strings | |
| 1031 | − if strings.Contains(result, `"enum":[true,false]`) { | |
| 1032 | − t.Errorf("Boolean enum values should be converted to strings, got: %s", result) | |
| 1033 | − } | |
| 1034 | − // Should contain string versions "true" and "false" | |
| 1035 | − if !strings.Contains(result, `"true"`) || !strings.Contains(result, `"false"`) { | |
| 1036 | − t.Errorf("Boolean enum values should be converted to string format, got: %s", result) | |
| 1012 | + value := gjson.Get(result, "properties.enabled") | |
| 1013 | + if value.Get("enum").Exists() || value.Get("type").String() != "boolean" || !strings.Contains(value.Get("description").String(), "Allowed: true, false") { | |
| 1014 | + t.Errorf("Boolean tool enum should become a typed hint, got: %s", result) | |
| 1037 | 1015 | } |
| 1038 | 1016 | } |
| 1039 | 1017 | |
| @@ -1450,3 +1428,107 @@ func TestCleanJSONSchema_ConditionalKeywords(t *testing.T) { · 410 unmodified lines | ||
| 1450 | 1428 | } |
| 1451 | 1429 | } |
| 1452 | 1430 | } |
| 1431 | + | |
| 1432 | +func TestCleanJSONSchemaForAntigravityResponseConditionalCannotOverwriteParent(t *testing.T) { | |
| 1433 | + input := `{ | |
| 1434 | + "type":"object", | |
| 1435 | + "properties":{ | |
| 1436 | + "kind":{"type":"string"}, | |
| 1437 | + "action":{"type":"object","properties":{"full":{"type":"string"}},"required":["full"]} | |
| 1438 | + }, | |
| 1439 | + "required":["kind","action"], | |
| 1440 | + "allOf":[{ | |
| 1441 | + "if":{"properties":{"kind":{"const":"skip"}}}, | |
| 1442 | + "then":{"properties":{ | |
| 1443 | + "action":{"type":"null"}, | |
| 1444 | + "branch_only":{"type":"integer"} | |
| 1445 | + }} | |
| 1446 | + }] | |
| 1447 | + }` | |
| 1448 | + | |
| 1449 | + result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) | |
| 1450 | + action := result.Get("properties.action") | |
| 1451 | + if action.Get("type").String() != "object" || !action.Get("properties.full").Exists() { | |
| 1452 | + t.Fatalf("conditional branch replaced canonical action: %s", result.Raw) | |
| 1453 | + } | |
| 1454 | + if action.Get("required.0").String() != "full" || !result.Get("properties.branch_only").Exists() { | |
| 1455 | + t.Fatalf("conditional merge lost parent or branch-only information: %s", result.Raw) | |
| 1456 | + } | |
| 1457 | + if result.Get("allOf").Exists() || strings.Contains(result.Raw, `"if"`) || strings.Contains(result.Raw, `"then"`) { | |
| 1458 | + t.Fatalf("unsupported conditional keywords survived: %s", result.Raw) | |
| 1459 | + } | |
| 1460 | +} | |
| 1461 | + | |
| 1462 | +func TestCleanJSONSchemaForAntigravityResponseInlinesLocalRef(t *testing.T) { | |
| 1463 | + input := `{ | |
| 1464 | + "$defs":{"Payload":{"type":"object","properties":{"id":{"type":"integer"}},"required":["id"]}}, | |
| 1465 | + "type":"object", | |
| 1466 | + "properties":{"payload":{"$ref":"#/$defs/Payload"}}, | |
| 1467 | + "required":["payload"] | |
| 1468 | + }` | |
| 1469 | + | |
| 1470 | + result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) | |
| 1471 | + if result.Get(`\$defs`).Exists() || strings.Contains(result.Raw, `"$ref"`) { | |
| 1472 | + t.Fatalf("local reference metadata survived: %s", result.Raw) | |
| 1473 | + } | |
| 1474 | + payload := result.Get("properties.payload") | |
| 1475 | + if payload.Get("type").String() != "object" || payload.Get("properties.id.type").String() != "integer" || payload.Get("required.0").String() != "id" { | |
| 1476 | + t.Fatalf("local reference definition was not inlined: %s", result.Raw) | |
| 1477 | + } | |
| 1478 | +} | |
| 1479 | + | |
| 1480 | +func TestCleanJSONSchemaForAntigravityResponseTypeArrayUsesNativeNullable(t *testing.T) { | |
| 1481 | + input := `{"type":"object","properties":{"value":{"type":["number","null"]}},"required":["value"]}` | |
| 1482 | + result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) | |
| 1483 | + value := result.Get("properties.value") | |
| 1484 | + if value.Get("type").String() != "number" || !value.Get("nullable").Bool() { | |
| 1485 | + t.Fatalf("type array was not projected to native nullable: %s", result.Raw) | |
| 1486 | + } | |
| 1487 | + if result.Get("required.0").String() != "value" { | |
| 1488 | + t.Fatalf("nullable required property became optional: %s", result.Raw) | |
| 1489 | + } | |
| 1490 | +} | |
| 1491 | + | |
| 1492 | +func TestCleanJSONSchemaForAntigravityToolKeepsNumericEnumType(t *testing.T) { | |
| 1493 | + input := `{"type":"object","properties":{"value":{"type":"number","enum":[1,2]}},"required":["value"]}` | |
| 1494 | + result := gjson.Parse(CleanJSONSchemaForAntigravityTool(input, false)) | |
| 1495 | + value := result.Get("properties.value") | |
| 1496 | + if value.Get("type").String() != "number" { | |
| 1497 | + t.Fatalf("numeric tool enum changed argument JSON type: %s", result.Raw) | |
| 1498 | + } | |
| 1499 | + if value.Get("enum").Exists() || !strings.Contains(value.Get("description").String(), "Allowed: 1, 2") { | |
| 1500 | + t.Fatalf("ignored tool enum was not projected to a hint: %s", result.Raw) | |
| 1501 | + } | |
| 1502 | +} | |
| 1503 | + | |
| 1504 | +func TestCleanJSONSchemaForAntigravityResponseDropsIgnoredBooleanEnum(t *testing.T) { | |
| 1505 | + input := `{"type":"object","properties":{"value":{"type":"boolean","enum":["true"]}},"required":["value"]}` | |
| 1506 | + result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) | |
| 1507 | + value := result.Get("properties.value") | |
| 1508 | + if value.Get("enum").Exists() || value.Get("type").String() != "boolean" || !strings.Contains(value.Get("description").String(), "Allowed: true") { | |
| 1509 | + t.Fatalf("ignored boolean response enum was not projected to a hint: %s", result.Raw) | |
| 1510 | + } | |
| 1511 | +} | |
| 1512 | + | |
| 1513 | +func TestCleanJSONSchemaForAntigravityResponseHintsIgnoredConstraints(t *testing.T) { | |
| 1514 | + input := `{"type":"object","properties":{"value":{"type":"number","minimum":1,"maximum":2,"not":{"enum":[1.5]}}}}` | |
| 1515 | + result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) | |
| 1516 | + value := result.Get("properties.value") | |
| 1517 | + for _, keyword := range []string{"minimum", "maximum", "not"} { | |
| 1518 | + if value.Get(keyword).Exists() { | |
| 1519 | + t.Fatalf("ignored constraint %s survived: %s", keyword, result.Raw) | |
| 1520 | + } | |
| 1521 | + if !strings.Contains(value.Get("description").String(), keyword+":") { | |
| 1522 | + t.Fatalf("ignored constraint %s lost its hint: %s", keyword, result.Raw) | |
| 1523 | + } | |
| 1524 | + } | |
| 1525 | +} | |
| 1526 | + | |
| 1527 | +func TestSortByDepthUsesSegmentsAndIsStable(t *testing.T) { | |
| 1528 | + paths := []string{"root.verylong", "root.x.y", "first.same", "later.same"} | |
| 1529 | + sortByDepth(paths) | |
| 1530 | + want := []string{"root.x.y", "root.verylong", "first.same", "later.same"} | |
| 1531 | + if !reflect.DeepEqual(paths, want) { | |
| 1532 | + t.Fatalf("sortByDepth() = %v, want %v", paths, want) | |
| 1533 | + } | |
| 1534 | +} | |